Add components: service/auth-svc, service/chat-svc, worker/worker-svc
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
e3a1892ca7
commit
72fd32990a
@ -35,6 +35,90 @@ steps:
|
||||
event: push
|
||||
|
||||
# COMPONENT_STEPS_BELOW
|
||||
|
||||
# Woodpecker CI step for worker-svc worker
|
||||
# Add this step to your .woodpecker.yml
|
||||
|
||||
build-worker-svc:
|
||||
depends_on: [deps]
|
||||
image: woodpeckerci/plugin-kaniko
|
||||
settings:
|
||||
registry: registry.threesix.ai
|
||||
repo: sp4-verify-1770325799/worker-svc
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
context: .
|
||||
dockerfile: workers/worker-svc/Dockerfile
|
||||
cache: true
|
||||
skip-tls-verify: true
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
|
||||
deploy-worker-svc:
|
||||
image: bitnami/kubectl:latest
|
||||
commands:
|
||||
- kubectl set image deployment/sp4-verify-1770325799-worker-svc worker-svc=registry.threesix.ai/sp4-verify-1770325799/worker-svc:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
|
||||
# Woodpecker CI step for chat-svc service
|
||||
# Add this step to your .woodpecker.yml
|
||||
|
||||
build-chat-svc:
|
||||
depends_on: [deps]
|
||||
image: woodpeckerci/plugin-kaniko
|
||||
settings:
|
||||
registry: registry.threesix.ai
|
||||
repo: sp4-verify-1770325799/chat-svc
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
context: .
|
||||
dockerfile: services/chat-svc/Dockerfile
|
||||
cache: true
|
||||
skip-tls-verify: true
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
|
||||
deploy-chat-svc:
|
||||
image: bitnami/kubectl:latest
|
||||
commands:
|
||||
- kubectl set image deployment/sp4-verify-1770325799-chat-svc chat-svc=registry.threesix.ai/sp4-verify-1770325799/chat-svc:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
|
||||
# Woodpecker CI step for auth-svc service
|
||||
# Add this step to your .woodpecker.yml
|
||||
|
||||
build-auth-svc:
|
||||
depends_on: [deps]
|
||||
image: woodpeckerci/plugin-kaniko
|
||||
settings:
|
||||
registry: registry.threesix.ai
|
||||
repo: sp4-verify-1770325799/auth-svc
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
context: .
|
||||
dockerfile: services/auth-svc/Dockerfile
|
||||
cache: true
|
||||
skip-tls-verify: true
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
|
||||
deploy-auth-svc:
|
||||
image: bitnami/kubectl:latest
|
||||
commands:
|
||||
- kubectl set image deployment/sp4-verify-1770325799-auth-svc auth-svc=registry.threesix.ai/sp4-verify-1770325799/auth-svc:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
# Do not remove the marker above - component steps are inserted here
|
||||
|
||||
verify:
|
||||
|
||||
@ -76,4 +76,9 @@ sp4-verify-1770325799/
|
||||
|
||||
## Components
|
||||
|
||||
<!-- Components will be listed here as they're added -->
|
||||
| Component | Type | Path |
|
||||
|-----------|------|------|
|
||||
| **auth-svc** | API service | `services/auth-svc/` |
|
||||
| **chat-svc** | API service | `services/chat-svc/` |
|
||||
| **worker-svc** | Background worker | `workers/worker-svc/` |
|
||||
|
||||
|
||||
3
Procfile
3
Procfile
@ -1,2 +1,5 @@
|
||||
# Local development processes
|
||||
# Components will be added below as they're created
|
||||
auth-svc: cd services/auth-svc && make run
|
||||
chat-svc: cd services/chat-svc && make run
|
||||
worker-svc: cd workers/worker-svc && make run
|
||||
|
||||
3
go.work
3
go.work
@ -1,4 +1,7 @@
|
||||
go 1.23
|
||||
|
||||
use ./pkg
|
||||
use ./services/auth-svc
|
||||
use ./services/chat-svc
|
||||
use ./workers/worker-svc
|
||||
// Component modules will be added below
|
||||
|
||||
21
services/auth-svc/.env.example
Normal file
21
services/auth-svc/.env.example
Normal file
@ -0,0 +1,21 @@
|
||||
# auth-svc Service Configuration
|
||||
|
||||
# Server
|
||||
SERVER_PORT=8001
|
||||
SERVER_HOST=0.0.0.0
|
||||
|
||||
# App
|
||||
APP_NAME=auth-svc
|
||||
APP_ENVIRONMENT=development
|
||||
APP_DEBUG=true
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=debug
|
||||
LOG_FORMAT=text
|
||||
|
||||
# Auth (set AUTH_ENABLED=true to require JWT for protected routes)
|
||||
AUTH_ENABLED=false
|
||||
JWT_SECRET=dev-secret-change-in-production
|
||||
|
||||
# Database (if needed)
|
||||
DATABASE_URL=postgres://dev:dev@localhost:5432/sp4-verify-1770325799?sslmode=disable
|
||||
31
services/auth-svc/Dockerfile
Normal file
31
services/auth-svc/Dockerfile
Normal file
@ -0,0 +1,31 @@
|
||||
# Build stage
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Configure Go private modules
|
||||
# Disable workspace mode - each component builds independently with replace directives
|
||||
ENV GOPRIVATE=git.threesix.ai/*
|
||||
ENV GOWORK=off
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy shared pkg and this service only
|
||||
COPY pkg/ ./pkg/
|
||||
COPY services/auth-svc/ ./services/auth-svc/
|
||||
|
||||
# Build from the service directory (uses replace directive for ../pkg)
|
||||
RUN cd services/auth-svc && CGO_ENABLED=0 go build -o /auth-svc ./cmd/server
|
||||
|
||||
# Production stage
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /
|
||||
|
||||
COPY --from=builder /auth-svc /auth-svc
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
ENTRYPOINT ["/auth-svc"]
|
||||
34
services/auth-svc/Makefile
Normal file
34
services/auth-svc/Makefile
Normal file
@ -0,0 +1,34 @@
|
||||
.PHONY: build run test lint fmt docker-build clean
|
||||
|
||||
SERVICE := auth-svc
|
||||
BINARY := bin/$(SERVICE)
|
||||
GO_MODULE := git.threesix.ai/jordan/sp4-verify-1770325799
|
||||
|
||||
# Build the service binary
|
||||
build:
|
||||
go build -o $(BINARY) ./cmd/server
|
||||
|
||||
# Run the service locally
|
||||
run:
|
||||
go run ./cmd/server
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
# Run linter
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
# Format code
|
||||
fmt:
|
||||
gofmt -w .
|
||||
goimports -w -local $(GO_MODULE) .
|
||||
|
||||
# Build Docker image (run from monorepo root)
|
||||
docker-build:
|
||||
docker build -t $(SERVICE):latest -f Dockerfile ../..
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf bin/
|
||||
30
services/auth-svc/cmd/server/main.go
Normal file
30
services/auth-svc/cmd/server/main.go
Normal file
@ -0,0 +1,30 @@
|
||||
// Package main is the entry point for the auth-svc service.
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/app"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/adapter/memory"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/api"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/service"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create logger
|
||||
logger := logging.Default()
|
||||
|
||||
// Create adapters (repositories)
|
||||
exampleRepo := memory.NewExampleRepository()
|
||||
|
||||
// Create services (business logic)
|
||||
exampleService := service.NewExampleService(exampleRepo, logger)
|
||||
|
||||
// Create application
|
||||
application := app.New("auth-svc", app.WithDefaultPort(8001))
|
||||
|
||||
// Register routes with dependency injection
|
||||
api.RegisterRoutes(application, exampleService)
|
||||
|
||||
// Start server
|
||||
application.Run()
|
||||
}
|
||||
9
services/auth-svc/component.yaml
Normal file
9
services/auth-svc/component.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
name: auth-svc
|
||||
type: service
|
||||
port: 8001
|
||||
path: services/auth-svc
|
||||
dependencies: []
|
||||
# Add dependencies as needed:
|
||||
# - postgres
|
||||
# - redis
|
||||
# - other-service
|
||||
8
services/auth-svc/go.mod
Normal file
8
services/auth-svc/go.mod
Normal file
@ -0,0 +1,8 @@
|
||||
module git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc
|
||||
|
||||
go 1.23
|
||||
|
||||
require git.threesix.ai/jordan/sp4-verify-1770325799/pkg v0.0.0
|
||||
|
||||
// Use local workspace modules (for Docker builds without go.work)
|
||||
replace git.threesix.ai/jordan/sp4-verify-1770325799/pkg => ../../pkg
|
||||
0
services/auth-svc/go.sum
Normal file
0
services/auth-svc/go.sum
Normal file
106
services/auth-svc/internal/adapter/memory/example.go
Normal file
106
services/auth-svc/internal/adapter/memory/example.go
Normal file
@ -0,0 +1,106 @@
|
||||
// Package memory provides in-memory implementations of repository interfaces.
|
||||
// Useful for development, testing, and prototyping.
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/domain"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time verification that ExampleRepository implements port.ExampleRepository.
|
||||
var _ port.ExampleRepository = (*ExampleRepository)(nil)
|
||||
|
||||
// ExampleRepository is a thread-safe in-memory implementation of port.ExampleRepository.
|
||||
type ExampleRepository struct {
|
||||
mu sync.RWMutex
|
||||
examples map[domain.ExampleID]*domain.Example
|
||||
}
|
||||
|
||||
// NewExampleRepository creates a new in-memory example repository.
|
||||
func NewExampleRepository() *ExampleRepository {
|
||||
return &ExampleRepository{
|
||||
examples: make(map[domain.ExampleID]*domain.Example),
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all examples.
|
||||
func (r *ExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
result := make([]domain.Example, 0, len(r.examples))
|
||||
for _, e := range r.examples {
|
||||
result = append(result, *e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Get returns an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
func (r *ExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
e, ok := r.examples[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrExampleNotFound
|
||||
}
|
||||
// Return a copy to prevent external mutation
|
||||
copy := *e
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
// Create stores a new example.
|
||||
func (r *ExampleRepository) Create(ctx context.Context, example *domain.Example) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Store a copy to prevent external mutation
|
||||
copy := *example
|
||||
r.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update modifies an existing example.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
func (r *ExampleRepository) Update(ctx context.Context, example *domain.Example) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.examples[example.ID]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
// Store a copy to prevent external mutation
|
||||
copy := *example
|
||||
r.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
func (r *ExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.examples[id]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
delete(r.examples, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExistsByName checks if an example with the given name exists.
|
||||
func (r *ExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, e := range r.examples {
|
||||
if e.Name == name {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
170
services/auth-svc/internal/api/handlers/example.go
Normal file
170
services/auth-svc/internal/api/handlers/example.go
Normal file
@ -0,0 +1,170 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/app"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/httperror"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/domain"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/service"
|
||||
)
|
||||
|
||||
// Example handles HTTP requests for example resources.
|
||||
type Example struct {
|
||||
svc *service.ExampleService
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewExample creates a new Example handler with injected dependencies.
|
||||
func NewExample(svc *service.ExampleService, logger *logging.Logger) *Example {
|
||||
return &Example{
|
||||
svc: svc,
|
||||
logger: logger.WithComponent("ExampleHandler"),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRequest is the request body for creating an example.
|
||||
type CreateRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Description string `json:"description" validate:"max=500"`
|
||||
}
|
||||
|
||||
// UpdateRequest is the request body for updating an example.
|
||||
type UpdateRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Description string `json:"description" validate:"max=500"`
|
||||
}
|
||||
|
||||
// ExampleResponse is the response for an example resource.
|
||||
type ExampleResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// toResponse converts a domain example to an API response.
|
||||
func toResponse(e *domain.Example) ExampleResponse {
|
||||
return ExampleResponse{
|
||||
ID: e.ID.String(),
|
||||
Name: e.Name,
|
||||
Description: e.Description,
|
||||
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: e.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all examples.
|
||||
func (h *Example) List(w http.ResponseWriter, r *http.Request) error {
|
||||
examples, err := h.svc.List(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := make([]ExampleResponse, len(examples))
|
||||
for i, e := range examples {
|
||||
result[i] = toResponse(&e)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns an example by ID.
|
||||
func (h *Example) Get(w http.ResponseWriter, r *http.Request) error {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
// Validate UUID format
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return httperror.BadRequest("invalid id format")
|
||||
}
|
||||
|
||||
example, err := h.svc.Get(r.Context(), domain.ExampleID(id))
|
||||
if err != nil {
|
||||
return mapDomainError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, toResponse(example))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create creates a new example.
|
||||
func (h *Example) Create(w http.ResponseWriter, r *http.Request) error {
|
||||
var req CreateRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
example, err := h.svc.Create(r.Context(), service.CreateInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
})
|
||||
if err != nil {
|
||||
return mapDomainError(err)
|
||||
}
|
||||
|
||||
httpresponse.Created(w, r, toResponse(example))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates an existing example.
|
||||
func (h *Example) Update(w http.ResponseWriter, r *http.Request) error {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return httperror.BadRequest("invalid id format")
|
||||
}
|
||||
|
||||
var req UpdateRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
example, err := h.svc.Update(r.Context(), domain.ExampleID(id), service.UpdateInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
})
|
||||
if err != nil {
|
||||
return mapDomainError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, toResponse(example))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes an example by ID.
|
||||
func (h *Example) Delete(w http.ResponseWriter, r *http.Request) error {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return httperror.BadRequest("invalid id format")
|
||||
}
|
||||
|
||||
if err := h.svc.Delete(r.Context(), domain.ExampleID(id)); err != nil {
|
||||
return mapDomainError(err)
|
||||
}
|
||||
|
||||
httpresponse.NoContent(w)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mapDomainError converts domain errors to HTTP errors.
|
||||
func mapDomainError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrExampleNotFound):
|
||||
return httperror.NotFound("example not found")
|
||||
case errors.Is(err, domain.ErrDuplicateExample):
|
||||
return httperror.Conflict("example with this name already exists")
|
||||
case errors.Is(err, domain.ErrInvalidExampleName):
|
||||
return httperror.BadRequest("invalid example name")
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
402
services/auth-svc/internal/api/handlers/example_test.go
Normal file
402
services/auth-svc/internal/api/handlers/example_test.go
Normal file
@ -0,0 +1,402 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/domain"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/port"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/service"
|
||||
)
|
||||
|
||||
// mockExampleRepository implements port.ExampleRepository for testing.
|
||||
type mockExampleRepository struct {
|
||||
mu sync.RWMutex
|
||||
examples map[domain.ExampleID]*domain.Example
|
||||
}
|
||||
|
||||
var _ port.ExampleRepository = (*mockExampleRepository)(nil)
|
||||
|
||||
func newMockExampleRepository() *mockExampleRepository {
|
||||
return &mockExampleRepository{
|
||||
examples: make(map[domain.ExampleID]*domain.Example),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make([]domain.Example, 0, len(m.examples))
|
||||
for _, e := range m.examples {
|
||||
result = append(result, *e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
e, ok := m.examples[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrExampleNotFound
|
||||
}
|
||||
copy := *e
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
copy := *example
|
||||
m.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.examples[example.ID]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
copy := *example
|
||||
m.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.examples[id]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
delete(m.examples, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, e := range m.examples {
|
||||
if e.Name == name {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func newTestHandler() (*Example, *mockExampleRepository) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := service.NewExampleService(repo, logging.Nop())
|
||||
handler := NewExample(svc, logging.Nop())
|
||||
return handler, repo
|
||||
}
|
||||
|
||||
func TestExample_List(t *testing.T) {
|
||||
handler, repo := newTestHandler()
|
||||
|
||||
// Seed data
|
||||
ex, _ := domain.NewExample("test-id-1", "Test Example", "Description")
|
||||
_ = repo.Create(context.Background(), ex)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.List(w, r); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/examples", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp["data"]
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' field in response")
|
||||
}
|
||||
|
||||
items, ok := data.([]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' to be an array")
|
||||
}
|
||||
|
||||
if len(items) != 1 {
|
||||
t.Errorf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExample_Get(t *testing.T) {
|
||||
handler, repo := newTestHandler()
|
||||
|
||||
// Seed data
|
||||
ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Test Example", "Description")
|
||||
_ = repo.Create(context.Background(), ex)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid uuid - found",
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "valid uuid - not found",
|
||||
id: "550e8400-e29b-41d4-a716-446655440001",
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "invalid uuid",
|
||||
id: "not-a-uuid",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Get(w, r); err != nil {
|
||||
// Map error to status for testing
|
||||
switch tt.wantStatus {
|
||||
case http.StatusNotFound:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case http.StatusBadRequest:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
default:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/examples/"+tt.id, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExample_Create(t *testing.T) {
|
||||
handler, repo := newTestHandler()
|
||||
|
||||
// Seed existing data for duplicate test
|
||||
ex, _ := domain.NewExample("existing-id", "Existing Name", "")
|
||||
_ = repo.Create(context.Background(), ex)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body any
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid request",
|
||||
body: CreateRequest{
|
||||
Name: "New Example",
|
||||
Description: "A test description",
|
||||
},
|
||||
wantStatus: http.StatusCreated,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: nil,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "duplicate name",
|
||||
body: CreateRequest{
|
||||
Name: "Existing Name",
|
||||
Description: "Conflict",
|
||||
},
|
||||
wantStatus: http.StatusConflict,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Create(w, r); err != nil {
|
||||
switch tt.wantStatus {
|
||||
case http.StatusBadRequest:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
case http.StatusConflict:
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
default:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
var body []byte
|
||||
if tt.body != nil {
|
||||
var err error
|
||||
body, err = json.Marshal(tt.body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/examples", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExample_Delete(t *testing.T) {
|
||||
handler, repo := newTestHandler()
|
||||
|
||||
// Seed data
|
||||
ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "To Delete", "")
|
||||
_ = repo.Create(context.Background(), ex)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "existing example",
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
wantStatus: http.StatusNoContent,
|
||||
},
|
||||
{
|
||||
name: "non-existent example",
|
||||
id: "550e8400-e29b-41d4-a716-446655440001",
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Delete("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Delete(w, r); err != nil {
|
||||
if tt.wantStatus == http.StatusNotFound {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/examples/"+tt.id, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExample_Update(t *testing.T) {
|
||||
handler, repo := newTestHandler()
|
||||
|
||||
// Seed data
|
||||
ex1, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Example 1", "")
|
||||
_ = repo.Create(context.Background(), ex1)
|
||||
ex2, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440001", "Example 2", "")
|
||||
_ = repo.Create(context.Background(), ex2)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
body UpdateRequest
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid update",
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
body: UpdateRequest{
|
||||
Name: "Updated Name",
|
||||
Description: "Updated",
|
||||
},
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "name conflict",
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
body: UpdateRequest{
|
||||
Name: "Example 2",
|
||||
Description: "Conflict",
|
||||
},
|
||||
wantStatus: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
id: "550e8400-e29b-41d4-a716-446655440099",
|
||||
body: UpdateRequest{
|
||||
Name: "Whatever",
|
||||
Description: "",
|
||||
},
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Put("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Update(w, r); err != nil {
|
||||
switch tt.wantStatus {
|
||||
case http.StatusNotFound:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case http.StatusConflict:
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
default:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
body, _ := json.Marshal(tt.body)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/examples/"+tt.id, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
26
services/auth-svc/internal/api/handlers/health.go
Normal file
26
services/auth-svc/internal/api/handlers/health.go
Normal file
@ -0,0 +1,26 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
|
||||
)
|
||||
|
||||
// Health handles health check endpoints.
|
||||
type Health struct {
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewHealth creates a new Health handler.
|
||||
func NewHealth(logger *logging.Logger) *Health {
|
||||
return &Health{logger: logger}
|
||||
}
|
||||
|
||||
// Check returns the service health status.
|
||||
func (h *Health) Check(w http.ResponseWriter, r *http.Request) {
|
||||
httpresponse.OK(w, r, map[string]string{
|
||||
"service": "auth-svc",
|
||||
"status": "healthy",
|
||||
})
|
||||
}
|
||||
54
services/auth-svc/internal/api/routes.go
Normal file
54
services/auth-svc/internal/api/routes.go
Normal file
@ -0,0 +1,54 @@
|
||||
// Package api provides HTTP routing and handlers for the auth-svc service.
|
||||
package api
|
||||
|
||||
import (
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/app"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/auth"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/api/handlers"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/config"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/service"
|
||||
)
|
||||
|
||||
// RegisterRoutes registers all HTTP routes for the service.
|
||||
// Routes are mounted under /api/auth-svc to match the ingress path routing.
|
||||
// This allows the monorepo to expose multiple services under a single domain:
|
||||
// - https://domain/api/auth-svc/health
|
||||
// - https://domain/api/auth-svc/examples
|
||||
func RegisterRoutes(application *app.App, exampleService *service.ExampleService) {
|
||||
logger := application.Logger()
|
||||
cfg := config.Load()
|
||||
|
||||
// Initialize handlers with injected services
|
||||
healthHandler := handlers.NewHealth(logger)
|
||||
exampleHandler := handlers.NewExample(exampleService, logger)
|
||||
|
||||
// Build and mount OpenAPI spec
|
||||
spec := NewServiceSpec()
|
||||
application.EnableDocs(spec)
|
||||
|
||||
// Register API routes under /api/{service-name} to match ingress path routing.
|
||||
// The ingress routes /api/auth-svc/* to this service.
|
||||
application.Route("/api/auth-svc", func(r app.Router) {
|
||||
r.Get("/health", healthHandler.Check)
|
||||
|
||||
// Public routes (no auth required)
|
||||
r.Get("/examples", app.Wrap(exampleHandler.List))
|
||||
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
|
||||
|
||||
// Protected routes (auth required when enabled)
|
||||
r.Group(func(r app.Router) {
|
||||
if cfg.AuthEnabled {
|
||||
r.Use(auth.Middleware(auth.MiddlewareConfig{
|
||||
Validator: auth.NewJWTValidator(auth.JWTConfig{
|
||||
Secret: []byte(cfg.JWTSecret),
|
||||
Issuer: "sp4-verify-1770325799",
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
r.Post("/examples", app.Wrap(exampleHandler.Create))
|
||||
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
|
||||
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
|
||||
})
|
||||
})
|
||||
}
|
||||
112
services/auth-svc/internal/api/spec.go
Normal file
112
services/auth-svc/internal/api/spec.go
Normal file
@ -0,0 +1,112 @@
|
||||
package api
|
||||
|
||||
import "git.threesix.ai/jordan/sp4-verify-1770325799/pkg/openapi"
|
||||
|
||||
// NewServiceSpec builds the OpenAPI specification for the auth-svc service.
|
||||
func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
spec := openapi.NewOpenAPISpec("auth-svc API", "1.0.0").
|
||||
WithDescription("REST API for the auth-svc service").
|
||||
WithBearerSecurity("bearer", "JWT authentication token").
|
||||
WithTag("Health", "Service health endpoints").
|
||||
WithTag("Examples", "Example CRUD endpoints")
|
||||
|
||||
// Define reusable schemas
|
||||
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{
|
||||
"id": openapi.UUID().WithDescription("Unique identifier"),
|
||||
"name": openapi.String().WithDescription("Name of the example").WithExample("My Example"),
|
||||
"description": openapi.String().WithDescription("Optional description").WithExample("A description"),
|
||||
"created_at": openapi.DateTime().WithDescription("Creation timestamp"),
|
||||
"updated_at": openapi.DateTime().WithDescription("Last update timestamp"),
|
||||
}, "id", "name"))
|
||||
|
||||
spec.WithSchema("CreateExampleRequest", openapi.Object(map[string]openapi.Schema{
|
||||
"name": openapi.StringWithMinMax(1, 100).WithDescription("Name of the example"),
|
||||
"description": openapi.StringWithMinMax(0, 500).WithDescription("Optional description"),
|
||||
}, "name"))
|
||||
|
||||
spec.WithSchema("UpdateExampleRequest", openapi.Object(map[string]openapi.Schema{
|
||||
"name": openapi.StringWithMinMax(1, 100).WithDescription("Updated name"),
|
||||
"description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"),
|
||||
}))
|
||||
|
||||
// Health
|
||||
spec.AddPath("/api/auth-svc/health", "get", map[string]any{
|
||||
"summary": "Health check",
|
||||
"tags": []string{"Health"},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Service is healthy", openapi.Object(map[string]openapi.Schema{
|
||||
"service": openapi.String(),
|
||||
"status": openapi.String(),
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
// List examples
|
||||
spec.AddPath("/api/auth-svc/examples", "get", map[string]any{
|
||||
"summary": "List examples",
|
||||
"description": "Returns a paginated list of examples.",
|
||||
"tags": []string{"Examples"},
|
||||
"parameters": []any{openapi.PageParam(), openapi.PerPageParam()},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.RefArray("Example"))),
|
||||
},
|
||||
})
|
||||
|
||||
// Get example
|
||||
spec.AddPath("/api/auth-svc/examples/{id}", "get", map[string]any{
|
||||
"summary": "Get example by ID",
|
||||
"tags": []string{"Examples"},
|
||||
"parameters": []any{openapi.IDParam()},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Example"))),
|
||||
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Create example
|
||||
spec.AddPath("/api/auth-svc/examples", "post", map[string]any{
|
||||
"summary": "Create example",
|
||||
"description": "Creates a new example. Requires authentication.",
|
||||
"tags": []string{"Examples"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"requestBody": openapi.RequestBody(openapi.Ref("CreateExampleRequest"), true),
|
||||
"responses": map[string]any{
|
||||
"201": openapi.OpResponse("Created", openapi.ResponseSchema(openapi.Ref("Example"))),
|
||||
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
"422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Update example
|
||||
spec.AddPath("/api/auth-svc/examples/{id}", "put", map[string]any{
|
||||
"summary": "Update example",
|
||||
"description": "Updates an existing example. Requires authentication.",
|
||||
"tags": []string{"Examples"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"parameters": []any{openapi.IDParam()},
|
||||
"requestBody": openapi.RequestBody(openapi.Ref("UpdateExampleRequest"), true),
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("Example"))),
|
||||
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Delete example
|
||||
spec.AddPath("/api/auth-svc/examples/{id}", "delete", map[string]any{
|
||||
"summary": "Delete example",
|
||||
"description": "Deletes an example by ID. Requires authentication.",
|
||||
"tags": []string{"Examples"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"parameters": []any{openapi.IDParam()},
|
||||
"responses": map[string]any{
|
||||
"204": openapi.OpResponseNoContent(),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
return spec
|
||||
}
|
||||
34
services/auth-svc/internal/config/config.go
Normal file
34
services/auth-svc/internal/config/config.go
Normal file
@ -0,0 +1,34 @@
|
||||
// Package config provides service-specific configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/config"
|
||||
)
|
||||
|
||||
// Config extends the base config with auth-svc-specific settings.
|
||||
type Config struct {
|
||||
config.AppConfig
|
||||
Server config.ServerConfig
|
||||
Database config.DatabaseConfig
|
||||
Logging config.LoggingConfig
|
||||
|
||||
// Auth
|
||||
AuthEnabled bool
|
||||
JWTSecret string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables.
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
AppConfig: config.ReadAppConfig(),
|
||||
Server: config.ReadServerConfig(),
|
||||
Database: config.ReadDatabaseConfig(),
|
||||
Logging: config.ReadLoggingConfig(),
|
||||
|
||||
AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
}
|
||||
}
|
||||
21
services/auth-svc/internal/domain/errors.go
Normal file
21
services/auth-svc/internal/domain/errors.go
Normal file
@ -0,0 +1,21 @@
|
||||
// Package domain contains pure domain models with no external dependencies.
|
||||
// These types represent the core business concepts of the service.
|
||||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// Domain errors - these are business-level errors that should be translated
|
||||
// to appropriate HTTP status codes by the handler layer.
|
||||
var (
|
||||
// ErrNotFound indicates a requested resource does not exist.
|
||||
ErrNotFound = errors.New("not found")
|
||||
|
||||
// ErrExampleNotFound indicates the requested example does not exist.
|
||||
ErrExampleNotFound = errors.New("example not found")
|
||||
|
||||
// ErrDuplicateExample indicates an example with the same name already exists.
|
||||
ErrDuplicateExample = errors.New("example with this name already exists")
|
||||
|
||||
// ErrInvalidExampleName indicates the example name is invalid.
|
||||
ErrInvalidExampleName = errors.New("invalid example name")
|
||||
)
|
||||
89
services/auth-svc/internal/domain/example.go
Normal file
89
services/auth-svc/internal/domain/example.go
Normal file
@ -0,0 +1,89 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ExampleID is a strongly-typed identifier for examples.
|
||||
type ExampleID string
|
||||
|
||||
// String returns the string representation of the ID.
|
||||
func (id ExampleID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero returns true if the ID is empty.
|
||||
func (id ExampleID) IsZero() bool {
|
||||
return id == ""
|
||||
}
|
||||
|
||||
// Example name constraints.
|
||||
const (
|
||||
MinExampleNameLen = 1
|
||||
MaxExampleNameLen = 100
|
||||
MaxDescriptionLen = 500
|
||||
)
|
||||
|
||||
// Example represents an example domain entity.
|
||||
// This is a pure domain model with no external dependencies.
|
||||
type Example struct {
|
||||
ID ExampleID
|
||||
Name string
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewExample creates a new Example with validation.
|
||||
// Returns ErrInvalidExampleName if the name is invalid.
|
||||
func NewExample(id ExampleID, name, description string) (*Example, error) {
|
||||
if err := validateExampleName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDescription(description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
return &Example{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Description: description,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Update modifies the example's mutable fields with validation.
|
||||
// Returns ErrInvalidExampleName if the name is invalid.
|
||||
func (e *Example) Update(name, description string) error {
|
||||
if err := validateExampleName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDescription(description); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.Name = name
|
||||
e.Description = description
|
||||
e.UpdatedAt = time.Now().UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateExampleName validates an example name.
|
||||
func validateExampleName(name string) error {
|
||||
length := utf8.RuneCountInString(name)
|
||||
if length < MinExampleNameLen || length > MaxExampleNameLen {
|
||||
return ErrInvalidExampleName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDescription validates a description.
|
||||
func validateDescription(desc string) error {
|
||||
if utf8.RuneCountInString(desc) > MaxDescriptionLen {
|
||||
return ErrInvalidExampleName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
37
services/auth-svc/internal/port/example.go
Normal file
37
services/auth-svc/internal/port/example.go
Normal file
@ -0,0 +1,37 @@
|
||||
// Package port defines interfaces (ports) for external dependencies.
|
||||
// These interfaces define the contracts between the application core and
|
||||
// infrastructure adapters, enabling testability and flexibility.
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/domain"
|
||||
)
|
||||
|
||||
// ExampleRepository defines the interface for example persistence operations.
|
||||
// Implementations may use databases, in-memory storage, or external services.
|
||||
type ExampleRepository interface {
|
||||
// List returns all examples.
|
||||
List(ctx context.Context) ([]domain.Example, error)
|
||||
|
||||
// Get returns an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error)
|
||||
|
||||
// Create stores a new example.
|
||||
// The example must have a valid ID set.
|
||||
Create(ctx context.Context, example *domain.Example) error
|
||||
|
||||
// Update modifies an existing example.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
Update(ctx context.Context, example *domain.Example) error
|
||||
|
||||
// Delete removes an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
Delete(ctx context.Context, id domain.ExampleID) error
|
||||
|
||||
// ExistsByName checks if an example with the given name exists.
|
||||
// Used for duplicate detection.
|
||||
ExistsByName(ctx context.Context, name string) (bool, error)
|
||||
}
|
||||
137
services/auth-svc/internal/service/example.go
Normal file
137
services/auth-svc/internal/service/example.go
Normal file
@ -0,0 +1,137 @@
|
||||
// Package service provides business logic / use cases for the application.
|
||||
// Services orchestrate domain operations using port interfaces.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/domain"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/port"
|
||||
)
|
||||
|
||||
// ExampleService handles example-related business logic.
|
||||
type ExampleService struct {
|
||||
repo port.ExampleRepository
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewExampleService creates a new example service.
|
||||
func NewExampleService(repo port.ExampleRepository, logger *logging.Logger) *ExampleService {
|
||||
return &ExampleService{
|
||||
repo: repo,
|
||||
logger: logger.WithService("ExampleService"),
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all examples.
|
||||
func (s *ExampleService) List(ctx context.Context) ([]domain.Example, error) {
|
||||
return s.repo.List(ctx)
|
||||
}
|
||||
|
||||
// Get returns an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
func (s *ExampleService) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
|
||||
return s.repo.Get(ctx, id)
|
||||
}
|
||||
|
||||
// CreateInput contains the data needed to create an example.
|
||||
type CreateInput struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Create creates a new example with duplicate detection.
|
||||
// Returns domain.ErrDuplicateExample if name already exists.
|
||||
// Returns domain.ErrInvalidExampleName if name is invalid.
|
||||
func (s *ExampleService) Create(ctx context.Context, input CreateInput) (*domain.Example, error) {
|
||||
// Check for duplicates
|
||||
exists, err := s.repo.ExistsByName(ctx, input.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
return nil, domain.ErrDuplicateExample
|
||||
}
|
||||
|
||||
// Generate new ID
|
||||
id := domain.ExampleID(uuid.New().String())
|
||||
|
||||
// Create domain entity (validates name)
|
||||
example, err := domain.NewExample(id, input.Name, input.Description)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Persist
|
||||
if err := s.repo.Create(ctx, example); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("example created", "id", id, "name", input.Name)
|
||||
return example, nil
|
||||
}
|
||||
|
||||
// UpdateInput contains the data needed to update an example.
|
||||
type UpdateInput struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Update modifies an existing example.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
// Returns domain.ErrDuplicateExample if new name conflicts with another example.
|
||||
// Returns domain.ErrInvalidExampleName if name is invalid.
|
||||
func (s *ExampleService) Update(ctx context.Context, id domain.ExampleID, input UpdateInput) (*domain.Example, error) {
|
||||
// Fetch existing
|
||||
example, err := s.repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check for name conflicts (only if name changed)
|
||||
if example.Name != input.Name {
|
||||
exists, err := s.repo.ExistsByName(ctx, input.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
return nil, domain.ErrDuplicateExample
|
||||
}
|
||||
}
|
||||
|
||||
// Update domain entity (validates name)
|
||||
if err := example.Update(input.Name, input.Description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Persist
|
||||
if err := s.repo.Update(ctx, example); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("example updated", "id", id, "name", input.Name)
|
||||
return example, nil
|
||||
}
|
||||
|
||||
// Delete removes an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
func (s *ExampleService) Delete(ctx context.Context, id domain.ExampleID) error {
|
||||
// Verify exists before delete
|
||||
if _, err := s.repo.Get(ctx, id); err != nil {
|
||||
if errors.Is(err, domain.ErrExampleNotFound) {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.repo.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("example deleted", "id", id)
|
||||
return nil
|
||||
}
|
||||
282
services/auth-svc/internal/service/example_test.go
Normal file
282
services/auth-svc/internal/service/example_test.go
Normal file
@ -0,0 +1,282 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/domain"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/auth-svc/internal/port"
|
||||
)
|
||||
|
||||
// mockExampleRepository implements port.ExampleRepository for testing.
|
||||
type mockExampleRepository struct {
|
||||
mu sync.RWMutex
|
||||
examples map[domain.ExampleID]*domain.Example
|
||||
}
|
||||
|
||||
var _ port.ExampleRepository = (*mockExampleRepository)(nil)
|
||||
|
||||
func newMockExampleRepository() *mockExampleRepository {
|
||||
return &mockExampleRepository{
|
||||
examples: make(map[domain.ExampleID]*domain.Example),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make([]domain.Example, 0, len(m.examples))
|
||||
for _, e := range m.examples {
|
||||
result = append(result, *e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
e, ok := m.examples[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrExampleNotFound
|
||||
}
|
||||
// Return a copy to avoid mutation
|
||||
copy := *e
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Store a copy
|
||||
copy := *example
|
||||
m.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.examples[example.ID]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
// Store a copy
|
||||
copy := *example
|
||||
m.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.examples[id]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
delete(m.examples, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, e := range m.examples {
|
||||
if e.Name == name {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func TestExampleService_Create(t *testing.T) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := NewExampleService(repo, logging.Nop())
|
||||
|
||||
t.Run("creates example successfully", func(t *testing.T) {
|
||||
example, err := svc.Create(context.Background(), CreateInput{
|
||||
Name: "Test Example",
|
||||
Description: "A test description",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if example.Name != "Test Example" {
|
||||
t.Errorf("expected name 'Test Example', got '%s'", example.Name)
|
||||
}
|
||||
if example.ID.IsZero() {
|
||||
t.Error("expected non-empty ID")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects duplicate name", func(t *testing.T) {
|
||||
_, err := svc.Create(context.Background(), CreateInput{
|
||||
Name: "Test Example",
|
||||
Description: "Another description",
|
||||
})
|
||||
if err != domain.ErrDuplicateExample {
|
||||
t.Errorf("expected ErrDuplicateExample, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects empty name", func(t *testing.T) {
|
||||
_, err := svc.Create(context.Background(), CreateInput{
|
||||
Name: "",
|
||||
Description: "Description",
|
||||
})
|
||||
if err != domain.ErrInvalidExampleName {
|
||||
t.Errorf("expected ErrInvalidExampleName, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExampleService_Get(t *testing.T) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := NewExampleService(repo, logging.Nop())
|
||||
|
||||
// Create an example first
|
||||
created, _ := svc.Create(context.Background(), CreateInput{
|
||||
Name: "Get Test",
|
||||
Description: "Description",
|
||||
})
|
||||
|
||||
t.Run("returns existing example", func(t *testing.T) {
|
||||
example, err := svc.Get(context.Background(), created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if example.Name != "Get Test" {
|
||||
t.Errorf("expected name 'Get Test', got '%s'", example.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns not found for missing example", func(t *testing.T) {
|
||||
_, err := svc.Get(context.Background(), "nonexistent-id")
|
||||
if err != domain.ErrExampleNotFound {
|
||||
t.Errorf("expected ErrExampleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExampleService_Update(t *testing.T) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := NewExampleService(repo, logging.Nop())
|
||||
|
||||
// Create examples
|
||||
example1, _ := svc.Create(context.Background(), CreateInput{
|
||||
Name: "Update Test 1",
|
||||
Description: "Original",
|
||||
})
|
||||
_, _ = svc.Create(context.Background(), CreateInput{
|
||||
Name: "Update Test 2",
|
||||
Description: "Other",
|
||||
})
|
||||
|
||||
t.Run("updates example successfully", func(t *testing.T) {
|
||||
updated, err := svc.Update(context.Background(), example1.ID, UpdateInput{
|
||||
Name: "Updated Name",
|
||||
Description: "Updated description",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if updated.Name != "Updated Name" {
|
||||
t.Errorf("expected name 'Updated Name', got '%s'", updated.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allows same name on same example", func(t *testing.T) {
|
||||
_, err := svc.Update(context.Background(), example1.ID, UpdateInput{
|
||||
Name: "Updated Name",
|
||||
Description: "Same name",
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error updating with same name: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects name conflict", func(t *testing.T) {
|
||||
_, err := svc.Update(context.Background(), example1.ID, UpdateInput{
|
||||
Name: "Update Test 2",
|
||||
Description: "Conflict",
|
||||
})
|
||||
if err != domain.ErrDuplicateExample {
|
||||
t.Errorf("expected ErrDuplicateExample, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns not found for missing example", func(t *testing.T) {
|
||||
_, err := svc.Update(context.Background(), "nonexistent-id", UpdateInput{
|
||||
Name: "Anything",
|
||||
Description: "",
|
||||
})
|
||||
if err != domain.ErrExampleNotFound {
|
||||
t.Errorf("expected ErrExampleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExampleService_Delete(t *testing.T) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := NewExampleService(repo, logging.Nop())
|
||||
|
||||
// Create an example first
|
||||
created, _ := svc.Create(context.Background(), CreateInput{
|
||||
Name: "Delete Test",
|
||||
Description: "To be deleted",
|
||||
})
|
||||
|
||||
t.Run("deletes example successfully", func(t *testing.T) {
|
||||
err := svc.Delete(context.Background(), created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify deleted
|
||||
_, err = svc.Get(context.Background(), created.ID)
|
||||
if err != domain.ErrExampleNotFound {
|
||||
t.Errorf("expected ErrExampleNotFound after delete, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns not found for missing example", func(t *testing.T) {
|
||||
err := svc.Delete(context.Background(), "nonexistent-id")
|
||||
if err != domain.ErrExampleNotFound {
|
||||
t.Errorf("expected ErrExampleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExampleService_List(t *testing.T) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := NewExampleService(repo, logging.Nop())
|
||||
|
||||
t.Run("returns empty list initially", func(t *testing.T) {
|
||||
examples, err := svc.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(examples) != 0 {
|
||||
t.Errorf("expected 0 examples, got %d", len(examples))
|
||||
}
|
||||
})
|
||||
|
||||
// Create some examples
|
||||
_, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 1", Description: ""})
|
||||
_, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 2", Description: ""})
|
||||
|
||||
t.Run("returns all examples", func(t *testing.T) {
|
||||
examples, err := svc.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(examples) != 2 {
|
||||
t.Errorf("expected 2 examples, got %d", len(examples))
|
||||
}
|
||||
})
|
||||
}
|
||||
0
services/auth-svc/migrations/.gitkeep
Normal file
0
services/auth-svc/migrations/.gitkeep
Normal file
21
services/chat-svc/.env.example
Normal file
21
services/chat-svc/.env.example
Normal file
@ -0,0 +1,21 @@
|
||||
# chat-svc Service Configuration
|
||||
|
||||
# Server
|
||||
SERVER_PORT=8001
|
||||
SERVER_HOST=0.0.0.0
|
||||
|
||||
# App
|
||||
APP_NAME=chat-svc
|
||||
APP_ENVIRONMENT=development
|
||||
APP_DEBUG=true
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=debug
|
||||
LOG_FORMAT=text
|
||||
|
||||
# Auth (set AUTH_ENABLED=true to require JWT for protected routes)
|
||||
AUTH_ENABLED=false
|
||||
JWT_SECRET=dev-secret-change-in-production
|
||||
|
||||
# Database (if needed)
|
||||
DATABASE_URL=postgres://dev:dev@localhost:5432/sp4-verify-1770325799?sslmode=disable
|
||||
31
services/chat-svc/Dockerfile
Normal file
31
services/chat-svc/Dockerfile
Normal file
@ -0,0 +1,31 @@
|
||||
# Build stage
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Configure Go private modules
|
||||
# Disable workspace mode - each component builds independently with replace directives
|
||||
ENV GOPRIVATE=git.threesix.ai/*
|
||||
ENV GOWORK=off
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy shared pkg and this service only
|
||||
COPY pkg/ ./pkg/
|
||||
COPY services/chat-svc/ ./services/chat-svc/
|
||||
|
||||
# Build from the service directory (uses replace directive for ../pkg)
|
||||
RUN cd services/chat-svc && CGO_ENABLED=0 go build -o /chat-svc ./cmd/server
|
||||
|
||||
# Production stage
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /
|
||||
|
||||
COPY --from=builder /chat-svc /chat-svc
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
ENTRYPOINT ["/chat-svc"]
|
||||
34
services/chat-svc/Makefile
Normal file
34
services/chat-svc/Makefile
Normal file
@ -0,0 +1,34 @@
|
||||
.PHONY: build run test lint fmt docker-build clean
|
||||
|
||||
SERVICE := chat-svc
|
||||
BINARY := bin/$(SERVICE)
|
||||
GO_MODULE := git.threesix.ai/jordan/sp4-verify-1770325799
|
||||
|
||||
# Build the service binary
|
||||
build:
|
||||
go build -o $(BINARY) ./cmd/server
|
||||
|
||||
# Run the service locally
|
||||
run:
|
||||
go run ./cmd/server
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
# Run linter
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
# Format code
|
||||
fmt:
|
||||
gofmt -w .
|
||||
goimports -w -local $(GO_MODULE) .
|
||||
|
||||
# Build Docker image (run from monorepo root)
|
||||
docker-build:
|
||||
docker build -t $(SERVICE):latest -f Dockerfile ../..
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf bin/
|
||||
30
services/chat-svc/cmd/server/main.go
Normal file
30
services/chat-svc/cmd/server/main.go
Normal file
@ -0,0 +1,30 @@
|
||||
// Package main is the entry point for the chat-svc service.
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/app"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/adapter/memory"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/api"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/service"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create logger
|
||||
logger := logging.Default()
|
||||
|
||||
// Create adapters (repositories)
|
||||
exampleRepo := memory.NewExampleRepository()
|
||||
|
||||
// Create services (business logic)
|
||||
exampleService := service.NewExampleService(exampleRepo, logger)
|
||||
|
||||
// Create application
|
||||
application := app.New("chat-svc", app.WithDefaultPort(8001))
|
||||
|
||||
// Register routes with dependency injection
|
||||
api.RegisterRoutes(application, exampleService)
|
||||
|
||||
// Start server
|
||||
application.Run()
|
||||
}
|
||||
9
services/chat-svc/component.yaml
Normal file
9
services/chat-svc/component.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
name: chat-svc
|
||||
type: service
|
||||
port: 8001
|
||||
path: services/chat-svc
|
||||
dependencies: []
|
||||
# Add dependencies as needed:
|
||||
# - postgres
|
||||
# - redis
|
||||
# - other-service
|
||||
8
services/chat-svc/go.mod
Normal file
8
services/chat-svc/go.mod
Normal file
@ -0,0 +1,8 @@
|
||||
module git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc
|
||||
|
||||
go 1.23
|
||||
|
||||
require git.threesix.ai/jordan/sp4-verify-1770325799/pkg v0.0.0
|
||||
|
||||
// Use local workspace modules (for Docker builds without go.work)
|
||||
replace git.threesix.ai/jordan/sp4-verify-1770325799/pkg => ../../pkg
|
||||
0
services/chat-svc/go.sum
Normal file
0
services/chat-svc/go.sum
Normal file
106
services/chat-svc/internal/adapter/memory/example.go
Normal file
106
services/chat-svc/internal/adapter/memory/example.go
Normal file
@ -0,0 +1,106 @@
|
||||
// Package memory provides in-memory implementations of repository interfaces.
|
||||
// Useful for development, testing, and prototyping.
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/domain"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time verification that ExampleRepository implements port.ExampleRepository.
|
||||
var _ port.ExampleRepository = (*ExampleRepository)(nil)
|
||||
|
||||
// ExampleRepository is a thread-safe in-memory implementation of port.ExampleRepository.
|
||||
type ExampleRepository struct {
|
||||
mu sync.RWMutex
|
||||
examples map[domain.ExampleID]*domain.Example
|
||||
}
|
||||
|
||||
// NewExampleRepository creates a new in-memory example repository.
|
||||
func NewExampleRepository() *ExampleRepository {
|
||||
return &ExampleRepository{
|
||||
examples: make(map[domain.ExampleID]*domain.Example),
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all examples.
|
||||
func (r *ExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
result := make([]domain.Example, 0, len(r.examples))
|
||||
for _, e := range r.examples {
|
||||
result = append(result, *e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Get returns an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
func (r *ExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
e, ok := r.examples[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrExampleNotFound
|
||||
}
|
||||
// Return a copy to prevent external mutation
|
||||
copy := *e
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
// Create stores a new example.
|
||||
func (r *ExampleRepository) Create(ctx context.Context, example *domain.Example) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Store a copy to prevent external mutation
|
||||
copy := *example
|
||||
r.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update modifies an existing example.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
func (r *ExampleRepository) Update(ctx context.Context, example *domain.Example) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.examples[example.ID]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
// Store a copy to prevent external mutation
|
||||
copy := *example
|
||||
r.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
func (r *ExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.examples[id]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
delete(r.examples, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExistsByName checks if an example with the given name exists.
|
||||
func (r *ExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, e := range r.examples {
|
||||
if e.Name == name {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
170
services/chat-svc/internal/api/handlers/example.go
Normal file
170
services/chat-svc/internal/api/handlers/example.go
Normal file
@ -0,0 +1,170 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/app"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/httperror"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/domain"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/service"
|
||||
)
|
||||
|
||||
// Example handles HTTP requests for example resources.
|
||||
type Example struct {
|
||||
svc *service.ExampleService
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewExample creates a new Example handler with injected dependencies.
|
||||
func NewExample(svc *service.ExampleService, logger *logging.Logger) *Example {
|
||||
return &Example{
|
||||
svc: svc,
|
||||
logger: logger.WithComponent("ExampleHandler"),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRequest is the request body for creating an example.
|
||||
type CreateRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Description string `json:"description" validate:"max=500"`
|
||||
}
|
||||
|
||||
// UpdateRequest is the request body for updating an example.
|
||||
type UpdateRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Description string `json:"description" validate:"max=500"`
|
||||
}
|
||||
|
||||
// ExampleResponse is the response for an example resource.
|
||||
type ExampleResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// toResponse converts a domain example to an API response.
|
||||
func toResponse(e *domain.Example) ExampleResponse {
|
||||
return ExampleResponse{
|
||||
ID: e.ID.String(),
|
||||
Name: e.Name,
|
||||
Description: e.Description,
|
||||
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: e.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all examples.
|
||||
func (h *Example) List(w http.ResponseWriter, r *http.Request) error {
|
||||
examples, err := h.svc.List(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := make([]ExampleResponse, len(examples))
|
||||
for i, e := range examples {
|
||||
result[i] = toResponse(&e)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns an example by ID.
|
||||
func (h *Example) Get(w http.ResponseWriter, r *http.Request) error {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
// Validate UUID format
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return httperror.BadRequest("invalid id format")
|
||||
}
|
||||
|
||||
example, err := h.svc.Get(r.Context(), domain.ExampleID(id))
|
||||
if err != nil {
|
||||
return mapDomainError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, toResponse(example))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create creates a new example.
|
||||
func (h *Example) Create(w http.ResponseWriter, r *http.Request) error {
|
||||
var req CreateRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
example, err := h.svc.Create(r.Context(), service.CreateInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
})
|
||||
if err != nil {
|
||||
return mapDomainError(err)
|
||||
}
|
||||
|
||||
httpresponse.Created(w, r, toResponse(example))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates an existing example.
|
||||
func (h *Example) Update(w http.ResponseWriter, r *http.Request) error {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return httperror.BadRequest("invalid id format")
|
||||
}
|
||||
|
||||
var req UpdateRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
example, err := h.svc.Update(r.Context(), domain.ExampleID(id), service.UpdateInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
})
|
||||
if err != nil {
|
||||
return mapDomainError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, toResponse(example))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes an example by ID.
|
||||
func (h *Example) Delete(w http.ResponseWriter, r *http.Request) error {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return httperror.BadRequest("invalid id format")
|
||||
}
|
||||
|
||||
if err := h.svc.Delete(r.Context(), domain.ExampleID(id)); err != nil {
|
||||
return mapDomainError(err)
|
||||
}
|
||||
|
||||
httpresponse.NoContent(w)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mapDomainError converts domain errors to HTTP errors.
|
||||
func mapDomainError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrExampleNotFound):
|
||||
return httperror.NotFound("example not found")
|
||||
case errors.Is(err, domain.ErrDuplicateExample):
|
||||
return httperror.Conflict("example with this name already exists")
|
||||
case errors.Is(err, domain.ErrInvalidExampleName):
|
||||
return httperror.BadRequest("invalid example name")
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
402
services/chat-svc/internal/api/handlers/example_test.go
Normal file
402
services/chat-svc/internal/api/handlers/example_test.go
Normal file
@ -0,0 +1,402 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/domain"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/port"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/service"
|
||||
)
|
||||
|
||||
// mockExampleRepository implements port.ExampleRepository for testing.
|
||||
type mockExampleRepository struct {
|
||||
mu sync.RWMutex
|
||||
examples map[domain.ExampleID]*domain.Example
|
||||
}
|
||||
|
||||
var _ port.ExampleRepository = (*mockExampleRepository)(nil)
|
||||
|
||||
func newMockExampleRepository() *mockExampleRepository {
|
||||
return &mockExampleRepository{
|
||||
examples: make(map[domain.ExampleID]*domain.Example),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make([]domain.Example, 0, len(m.examples))
|
||||
for _, e := range m.examples {
|
||||
result = append(result, *e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
e, ok := m.examples[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrExampleNotFound
|
||||
}
|
||||
copy := *e
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
copy := *example
|
||||
m.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.examples[example.ID]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
copy := *example
|
||||
m.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.examples[id]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
delete(m.examples, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, e := range m.examples {
|
||||
if e.Name == name {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func newTestHandler() (*Example, *mockExampleRepository) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := service.NewExampleService(repo, logging.Nop())
|
||||
handler := NewExample(svc, logging.Nop())
|
||||
return handler, repo
|
||||
}
|
||||
|
||||
func TestExample_List(t *testing.T) {
|
||||
handler, repo := newTestHandler()
|
||||
|
||||
// Seed data
|
||||
ex, _ := domain.NewExample("test-id-1", "Test Example", "Description")
|
||||
_ = repo.Create(context.Background(), ex)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.List(w, r); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/examples", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp["data"]
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' field in response")
|
||||
}
|
||||
|
||||
items, ok := data.([]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' to be an array")
|
||||
}
|
||||
|
||||
if len(items) != 1 {
|
||||
t.Errorf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExample_Get(t *testing.T) {
|
||||
handler, repo := newTestHandler()
|
||||
|
||||
// Seed data
|
||||
ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Test Example", "Description")
|
||||
_ = repo.Create(context.Background(), ex)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid uuid - found",
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "valid uuid - not found",
|
||||
id: "550e8400-e29b-41d4-a716-446655440001",
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "invalid uuid",
|
||||
id: "not-a-uuid",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Get(w, r); err != nil {
|
||||
// Map error to status for testing
|
||||
switch tt.wantStatus {
|
||||
case http.StatusNotFound:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case http.StatusBadRequest:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
default:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/examples/"+tt.id, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExample_Create(t *testing.T) {
|
||||
handler, repo := newTestHandler()
|
||||
|
||||
// Seed existing data for duplicate test
|
||||
ex, _ := domain.NewExample("existing-id", "Existing Name", "")
|
||||
_ = repo.Create(context.Background(), ex)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body any
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid request",
|
||||
body: CreateRequest{
|
||||
Name: "New Example",
|
||||
Description: "A test description",
|
||||
},
|
||||
wantStatus: http.StatusCreated,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: nil,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "duplicate name",
|
||||
body: CreateRequest{
|
||||
Name: "Existing Name",
|
||||
Description: "Conflict",
|
||||
},
|
||||
wantStatus: http.StatusConflict,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Create(w, r); err != nil {
|
||||
switch tt.wantStatus {
|
||||
case http.StatusBadRequest:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
case http.StatusConflict:
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
default:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
var body []byte
|
||||
if tt.body != nil {
|
||||
var err error
|
||||
body, err = json.Marshal(tt.body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/examples", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExample_Delete(t *testing.T) {
|
||||
handler, repo := newTestHandler()
|
||||
|
||||
// Seed data
|
||||
ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "To Delete", "")
|
||||
_ = repo.Create(context.Background(), ex)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "existing example",
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
wantStatus: http.StatusNoContent,
|
||||
},
|
||||
{
|
||||
name: "non-existent example",
|
||||
id: "550e8400-e29b-41d4-a716-446655440001",
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Delete("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Delete(w, r); err != nil {
|
||||
if tt.wantStatus == http.StatusNotFound {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/examples/"+tt.id, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExample_Update(t *testing.T) {
|
||||
handler, repo := newTestHandler()
|
||||
|
||||
// Seed data
|
||||
ex1, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Example 1", "")
|
||||
_ = repo.Create(context.Background(), ex1)
|
||||
ex2, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440001", "Example 2", "")
|
||||
_ = repo.Create(context.Background(), ex2)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
body UpdateRequest
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid update",
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
body: UpdateRequest{
|
||||
Name: "Updated Name",
|
||||
Description: "Updated",
|
||||
},
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "name conflict",
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
body: UpdateRequest{
|
||||
Name: "Example 2",
|
||||
Description: "Conflict",
|
||||
},
|
||||
wantStatus: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
id: "550e8400-e29b-41d4-a716-446655440099",
|
||||
body: UpdateRequest{
|
||||
Name: "Whatever",
|
||||
Description: "",
|
||||
},
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Put("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Update(w, r); err != nil {
|
||||
switch tt.wantStatus {
|
||||
case http.StatusNotFound:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case http.StatusConflict:
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
default:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
body, _ := json.Marshal(tt.body)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/examples/"+tt.id, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
26
services/chat-svc/internal/api/handlers/health.go
Normal file
26
services/chat-svc/internal/api/handlers/health.go
Normal file
@ -0,0 +1,26 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
|
||||
)
|
||||
|
||||
// Health handles health check endpoints.
|
||||
type Health struct {
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewHealth creates a new Health handler.
|
||||
func NewHealth(logger *logging.Logger) *Health {
|
||||
return &Health{logger: logger}
|
||||
}
|
||||
|
||||
// Check returns the service health status.
|
||||
func (h *Health) Check(w http.ResponseWriter, r *http.Request) {
|
||||
httpresponse.OK(w, r, map[string]string{
|
||||
"service": "chat-svc",
|
||||
"status": "healthy",
|
||||
})
|
||||
}
|
||||
54
services/chat-svc/internal/api/routes.go
Normal file
54
services/chat-svc/internal/api/routes.go
Normal file
@ -0,0 +1,54 @@
|
||||
// Package api provides HTTP routing and handlers for the chat-svc service.
|
||||
package api
|
||||
|
||||
import (
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/app"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/auth"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/api/handlers"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/config"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/service"
|
||||
)
|
||||
|
||||
// RegisterRoutes registers all HTTP routes for the service.
|
||||
// Routes are mounted under /api/chat-svc to match the ingress path routing.
|
||||
// This allows the monorepo to expose multiple services under a single domain:
|
||||
// - https://domain/api/chat-svc/health
|
||||
// - https://domain/api/chat-svc/examples
|
||||
func RegisterRoutes(application *app.App, exampleService *service.ExampleService) {
|
||||
logger := application.Logger()
|
||||
cfg := config.Load()
|
||||
|
||||
// Initialize handlers with injected services
|
||||
healthHandler := handlers.NewHealth(logger)
|
||||
exampleHandler := handlers.NewExample(exampleService, logger)
|
||||
|
||||
// Build and mount OpenAPI spec
|
||||
spec := NewServiceSpec()
|
||||
application.EnableDocs(spec)
|
||||
|
||||
// Register API routes under /api/{service-name} to match ingress path routing.
|
||||
// The ingress routes /api/chat-svc/* to this service.
|
||||
application.Route("/api/chat-svc", func(r app.Router) {
|
||||
r.Get("/health", healthHandler.Check)
|
||||
|
||||
// Public routes (no auth required)
|
||||
r.Get("/examples", app.Wrap(exampleHandler.List))
|
||||
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
|
||||
|
||||
// Protected routes (auth required when enabled)
|
||||
r.Group(func(r app.Router) {
|
||||
if cfg.AuthEnabled {
|
||||
r.Use(auth.Middleware(auth.MiddlewareConfig{
|
||||
Validator: auth.NewJWTValidator(auth.JWTConfig{
|
||||
Secret: []byte(cfg.JWTSecret),
|
||||
Issuer: "sp4-verify-1770325799",
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
r.Post("/examples", app.Wrap(exampleHandler.Create))
|
||||
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
|
||||
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
|
||||
})
|
||||
})
|
||||
}
|
||||
112
services/chat-svc/internal/api/spec.go
Normal file
112
services/chat-svc/internal/api/spec.go
Normal file
@ -0,0 +1,112 @@
|
||||
package api
|
||||
|
||||
import "git.threesix.ai/jordan/sp4-verify-1770325799/pkg/openapi"
|
||||
|
||||
// NewServiceSpec builds the OpenAPI specification for the chat-svc service.
|
||||
func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
spec := openapi.NewOpenAPISpec("chat-svc API", "1.0.0").
|
||||
WithDescription("REST API for the chat-svc service").
|
||||
WithBearerSecurity("bearer", "JWT authentication token").
|
||||
WithTag("Health", "Service health endpoints").
|
||||
WithTag("Examples", "Example CRUD endpoints")
|
||||
|
||||
// Define reusable schemas
|
||||
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{
|
||||
"id": openapi.UUID().WithDescription("Unique identifier"),
|
||||
"name": openapi.String().WithDescription("Name of the example").WithExample("My Example"),
|
||||
"description": openapi.String().WithDescription("Optional description").WithExample("A description"),
|
||||
"created_at": openapi.DateTime().WithDescription("Creation timestamp"),
|
||||
"updated_at": openapi.DateTime().WithDescription("Last update timestamp"),
|
||||
}, "id", "name"))
|
||||
|
||||
spec.WithSchema("CreateExampleRequest", openapi.Object(map[string]openapi.Schema{
|
||||
"name": openapi.StringWithMinMax(1, 100).WithDescription("Name of the example"),
|
||||
"description": openapi.StringWithMinMax(0, 500).WithDescription("Optional description"),
|
||||
}, "name"))
|
||||
|
||||
spec.WithSchema("UpdateExampleRequest", openapi.Object(map[string]openapi.Schema{
|
||||
"name": openapi.StringWithMinMax(1, 100).WithDescription("Updated name"),
|
||||
"description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"),
|
||||
}))
|
||||
|
||||
// Health
|
||||
spec.AddPath("/api/chat-svc/health", "get", map[string]any{
|
||||
"summary": "Health check",
|
||||
"tags": []string{"Health"},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Service is healthy", openapi.Object(map[string]openapi.Schema{
|
||||
"service": openapi.String(),
|
||||
"status": openapi.String(),
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
// List examples
|
||||
spec.AddPath("/api/chat-svc/examples", "get", map[string]any{
|
||||
"summary": "List examples",
|
||||
"description": "Returns a paginated list of examples.",
|
||||
"tags": []string{"Examples"},
|
||||
"parameters": []any{openapi.PageParam(), openapi.PerPageParam()},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.RefArray("Example"))),
|
||||
},
|
||||
})
|
||||
|
||||
// Get example
|
||||
spec.AddPath("/api/chat-svc/examples/{id}", "get", map[string]any{
|
||||
"summary": "Get example by ID",
|
||||
"tags": []string{"Examples"},
|
||||
"parameters": []any{openapi.IDParam()},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Example"))),
|
||||
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Create example
|
||||
spec.AddPath("/api/chat-svc/examples", "post", map[string]any{
|
||||
"summary": "Create example",
|
||||
"description": "Creates a new example. Requires authentication.",
|
||||
"tags": []string{"Examples"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"requestBody": openapi.RequestBody(openapi.Ref("CreateExampleRequest"), true),
|
||||
"responses": map[string]any{
|
||||
"201": openapi.OpResponse("Created", openapi.ResponseSchema(openapi.Ref("Example"))),
|
||||
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
"422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Update example
|
||||
spec.AddPath("/api/chat-svc/examples/{id}", "put", map[string]any{
|
||||
"summary": "Update example",
|
||||
"description": "Updates an existing example. Requires authentication.",
|
||||
"tags": []string{"Examples"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"parameters": []any{openapi.IDParam()},
|
||||
"requestBody": openapi.RequestBody(openapi.Ref("UpdateExampleRequest"), true),
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("Example"))),
|
||||
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Delete example
|
||||
spec.AddPath("/api/chat-svc/examples/{id}", "delete", map[string]any{
|
||||
"summary": "Delete example",
|
||||
"description": "Deletes an example by ID. Requires authentication.",
|
||||
"tags": []string{"Examples"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"parameters": []any{openapi.IDParam()},
|
||||
"responses": map[string]any{
|
||||
"204": openapi.OpResponseNoContent(),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
return spec
|
||||
}
|
||||
34
services/chat-svc/internal/config/config.go
Normal file
34
services/chat-svc/internal/config/config.go
Normal file
@ -0,0 +1,34 @@
|
||||
// Package config provides service-specific configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/config"
|
||||
)
|
||||
|
||||
// Config extends the base config with chat-svc-specific settings.
|
||||
type Config struct {
|
||||
config.AppConfig
|
||||
Server config.ServerConfig
|
||||
Database config.DatabaseConfig
|
||||
Logging config.LoggingConfig
|
||||
|
||||
// Auth
|
||||
AuthEnabled bool
|
||||
JWTSecret string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables.
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
AppConfig: config.ReadAppConfig(),
|
||||
Server: config.ReadServerConfig(),
|
||||
Database: config.ReadDatabaseConfig(),
|
||||
Logging: config.ReadLoggingConfig(),
|
||||
|
||||
AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
}
|
||||
}
|
||||
21
services/chat-svc/internal/domain/errors.go
Normal file
21
services/chat-svc/internal/domain/errors.go
Normal file
@ -0,0 +1,21 @@
|
||||
// Package domain contains pure domain models with no external dependencies.
|
||||
// These types represent the core business concepts of the service.
|
||||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// Domain errors - these are business-level errors that should be translated
|
||||
// to appropriate HTTP status codes by the handler layer.
|
||||
var (
|
||||
// ErrNotFound indicates a requested resource does not exist.
|
||||
ErrNotFound = errors.New("not found")
|
||||
|
||||
// ErrExampleNotFound indicates the requested example does not exist.
|
||||
ErrExampleNotFound = errors.New("example not found")
|
||||
|
||||
// ErrDuplicateExample indicates an example with the same name already exists.
|
||||
ErrDuplicateExample = errors.New("example with this name already exists")
|
||||
|
||||
// ErrInvalidExampleName indicates the example name is invalid.
|
||||
ErrInvalidExampleName = errors.New("invalid example name")
|
||||
)
|
||||
89
services/chat-svc/internal/domain/example.go
Normal file
89
services/chat-svc/internal/domain/example.go
Normal file
@ -0,0 +1,89 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ExampleID is a strongly-typed identifier for examples.
|
||||
type ExampleID string
|
||||
|
||||
// String returns the string representation of the ID.
|
||||
func (id ExampleID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero returns true if the ID is empty.
|
||||
func (id ExampleID) IsZero() bool {
|
||||
return id == ""
|
||||
}
|
||||
|
||||
// Example name constraints.
|
||||
const (
|
||||
MinExampleNameLen = 1
|
||||
MaxExampleNameLen = 100
|
||||
MaxDescriptionLen = 500
|
||||
)
|
||||
|
||||
// Example represents an example domain entity.
|
||||
// This is a pure domain model with no external dependencies.
|
||||
type Example struct {
|
||||
ID ExampleID
|
||||
Name string
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewExample creates a new Example with validation.
|
||||
// Returns ErrInvalidExampleName if the name is invalid.
|
||||
func NewExample(id ExampleID, name, description string) (*Example, error) {
|
||||
if err := validateExampleName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDescription(description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
return &Example{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Description: description,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Update modifies the example's mutable fields with validation.
|
||||
// Returns ErrInvalidExampleName if the name is invalid.
|
||||
func (e *Example) Update(name, description string) error {
|
||||
if err := validateExampleName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDescription(description); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.Name = name
|
||||
e.Description = description
|
||||
e.UpdatedAt = time.Now().UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateExampleName validates an example name.
|
||||
func validateExampleName(name string) error {
|
||||
length := utf8.RuneCountInString(name)
|
||||
if length < MinExampleNameLen || length > MaxExampleNameLen {
|
||||
return ErrInvalidExampleName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDescription validates a description.
|
||||
func validateDescription(desc string) error {
|
||||
if utf8.RuneCountInString(desc) > MaxDescriptionLen {
|
||||
return ErrInvalidExampleName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
37
services/chat-svc/internal/port/example.go
Normal file
37
services/chat-svc/internal/port/example.go
Normal file
@ -0,0 +1,37 @@
|
||||
// Package port defines interfaces (ports) for external dependencies.
|
||||
// These interfaces define the contracts between the application core and
|
||||
// infrastructure adapters, enabling testability and flexibility.
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/domain"
|
||||
)
|
||||
|
||||
// ExampleRepository defines the interface for example persistence operations.
|
||||
// Implementations may use databases, in-memory storage, or external services.
|
||||
type ExampleRepository interface {
|
||||
// List returns all examples.
|
||||
List(ctx context.Context) ([]domain.Example, error)
|
||||
|
||||
// Get returns an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error)
|
||||
|
||||
// Create stores a new example.
|
||||
// The example must have a valid ID set.
|
||||
Create(ctx context.Context, example *domain.Example) error
|
||||
|
||||
// Update modifies an existing example.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
Update(ctx context.Context, example *domain.Example) error
|
||||
|
||||
// Delete removes an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
Delete(ctx context.Context, id domain.ExampleID) error
|
||||
|
||||
// ExistsByName checks if an example with the given name exists.
|
||||
// Used for duplicate detection.
|
||||
ExistsByName(ctx context.Context, name string) (bool, error)
|
||||
}
|
||||
137
services/chat-svc/internal/service/example.go
Normal file
137
services/chat-svc/internal/service/example.go
Normal file
@ -0,0 +1,137 @@
|
||||
// Package service provides business logic / use cases for the application.
|
||||
// Services orchestrate domain operations using port interfaces.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/domain"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/port"
|
||||
)
|
||||
|
||||
// ExampleService handles example-related business logic.
|
||||
type ExampleService struct {
|
||||
repo port.ExampleRepository
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewExampleService creates a new example service.
|
||||
func NewExampleService(repo port.ExampleRepository, logger *logging.Logger) *ExampleService {
|
||||
return &ExampleService{
|
||||
repo: repo,
|
||||
logger: logger.WithService("ExampleService"),
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all examples.
|
||||
func (s *ExampleService) List(ctx context.Context) ([]domain.Example, error) {
|
||||
return s.repo.List(ctx)
|
||||
}
|
||||
|
||||
// Get returns an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
func (s *ExampleService) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
|
||||
return s.repo.Get(ctx, id)
|
||||
}
|
||||
|
||||
// CreateInput contains the data needed to create an example.
|
||||
type CreateInput struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Create creates a new example with duplicate detection.
|
||||
// Returns domain.ErrDuplicateExample if name already exists.
|
||||
// Returns domain.ErrInvalidExampleName if name is invalid.
|
||||
func (s *ExampleService) Create(ctx context.Context, input CreateInput) (*domain.Example, error) {
|
||||
// Check for duplicates
|
||||
exists, err := s.repo.ExistsByName(ctx, input.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
return nil, domain.ErrDuplicateExample
|
||||
}
|
||||
|
||||
// Generate new ID
|
||||
id := domain.ExampleID(uuid.New().String())
|
||||
|
||||
// Create domain entity (validates name)
|
||||
example, err := domain.NewExample(id, input.Name, input.Description)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Persist
|
||||
if err := s.repo.Create(ctx, example); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("example created", "id", id, "name", input.Name)
|
||||
return example, nil
|
||||
}
|
||||
|
||||
// UpdateInput contains the data needed to update an example.
|
||||
type UpdateInput struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Update modifies an existing example.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
// Returns domain.ErrDuplicateExample if new name conflicts with another example.
|
||||
// Returns domain.ErrInvalidExampleName if name is invalid.
|
||||
func (s *ExampleService) Update(ctx context.Context, id domain.ExampleID, input UpdateInput) (*domain.Example, error) {
|
||||
// Fetch existing
|
||||
example, err := s.repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check for name conflicts (only if name changed)
|
||||
if example.Name != input.Name {
|
||||
exists, err := s.repo.ExistsByName(ctx, input.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
return nil, domain.ErrDuplicateExample
|
||||
}
|
||||
}
|
||||
|
||||
// Update domain entity (validates name)
|
||||
if err := example.Update(input.Name, input.Description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Persist
|
||||
if err := s.repo.Update(ctx, example); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("example updated", "id", id, "name", input.Name)
|
||||
return example, nil
|
||||
}
|
||||
|
||||
// Delete removes an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
func (s *ExampleService) Delete(ctx context.Context, id domain.ExampleID) error {
|
||||
// Verify exists before delete
|
||||
if _, err := s.repo.Get(ctx, id); err != nil {
|
||||
if errors.Is(err, domain.ErrExampleNotFound) {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.repo.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("example deleted", "id", id)
|
||||
return nil
|
||||
}
|
||||
282
services/chat-svc/internal/service/example_test.go
Normal file
282
services/chat-svc/internal/service/example_test.go
Normal file
@ -0,0 +1,282 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/domain"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/services/chat-svc/internal/port"
|
||||
)
|
||||
|
||||
// mockExampleRepository implements port.ExampleRepository for testing.
|
||||
type mockExampleRepository struct {
|
||||
mu sync.RWMutex
|
||||
examples map[domain.ExampleID]*domain.Example
|
||||
}
|
||||
|
||||
var _ port.ExampleRepository = (*mockExampleRepository)(nil)
|
||||
|
||||
func newMockExampleRepository() *mockExampleRepository {
|
||||
return &mockExampleRepository{
|
||||
examples: make(map[domain.ExampleID]*domain.Example),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make([]domain.Example, 0, len(m.examples))
|
||||
for _, e := range m.examples {
|
||||
result = append(result, *e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
e, ok := m.examples[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrExampleNotFound
|
||||
}
|
||||
// Return a copy to avoid mutation
|
||||
copy := *e
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Store a copy
|
||||
copy := *example
|
||||
m.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.examples[example.ID]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
// Store a copy
|
||||
copy := *example
|
||||
m.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.examples[id]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
delete(m.examples, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, e := range m.examples {
|
||||
if e.Name == name {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func TestExampleService_Create(t *testing.T) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := NewExampleService(repo, logging.Nop())
|
||||
|
||||
t.Run("creates example successfully", func(t *testing.T) {
|
||||
example, err := svc.Create(context.Background(), CreateInput{
|
||||
Name: "Test Example",
|
||||
Description: "A test description",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if example.Name != "Test Example" {
|
||||
t.Errorf("expected name 'Test Example', got '%s'", example.Name)
|
||||
}
|
||||
if example.ID.IsZero() {
|
||||
t.Error("expected non-empty ID")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects duplicate name", func(t *testing.T) {
|
||||
_, err := svc.Create(context.Background(), CreateInput{
|
||||
Name: "Test Example",
|
||||
Description: "Another description",
|
||||
})
|
||||
if err != domain.ErrDuplicateExample {
|
||||
t.Errorf("expected ErrDuplicateExample, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects empty name", func(t *testing.T) {
|
||||
_, err := svc.Create(context.Background(), CreateInput{
|
||||
Name: "",
|
||||
Description: "Description",
|
||||
})
|
||||
if err != domain.ErrInvalidExampleName {
|
||||
t.Errorf("expected ErrInvalidExampleName, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExampleService_Get(t *testing.T) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := NewExampleService(repo, logging.Nop())
|
||||
|
||||
// Create an example first
|
||||
created, _ := svc.Create(context.Background(), CreateInput{
|
||||
Name: "Get Test",
|
||||
Description: "Description",
|
||||
})
|
||||
|
||||
t.Run("returns existing example", func(t *testing.T) {
|
||||
example, err := svc.Get(context.Background(), created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if example.Name != "Get Test" {
|
||||
t.Errorf("expected name 'Get Test', got '%s'", example.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns not found for missing example", func(t *testing.T) {
|
||||
_, err := svc.Get(context.Background(), "nonexistent-id")
|
||||
if err != domain.ErrExampleNotFound {
|
||||
t.Errorf("expected ErrExampleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExampleService_Update(t *testing.T) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := NewExampleService(repo, logging.Nop())
|
||||
|
||||
// Create examples
|
||||
example1, _ := svc.Create(context.Background(), CreateInput{
|
||||
Name: "Update Test 1",
|
||||
Description: "Original",
|
||||
})
|
||||
_, _ = svc.Create(context.Background(), CreateInput{
|
||||
Name: "Update Test 2",
|
||||
Description: "Other",
|
||||
})
|
||||
|
||||
t.Run("updates example successfully", func(t *testing.T) {
|
||||
updated, err := svc.Update(context.Background(), example1.ID, UpdateInput{
|
||||
Name: "Updated Name",
|
||||
Description: "Updated description",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if updated.Name != "Updated Name" {
|
||||
t.Errorf("expected name 'Updated Name', got '%s'", updated.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allows same name on same example", func(t *testing.T) {
|
||||
_, err := svc.Update(context.Background(), example1.ID, UpdateInput{
|
||||
Name: "Updated Name",
|
||||
Description: "Same name",
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error updating with same name: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects name conflict", func(t *testing.T) {
|
||||
_, err := svc.Update(context.Background(), example1.ID, UpdateInput{
|
||||
Name: "Update Test 2",
|
||||
Description: "Conflict",
|
||||
})
|
||||
if err != domain.ErrDuplicateExample {
|
||||
t.Errorf("expected ErrDuplicateExample, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns not found for missing example", func(t *testing.T) {
|
||||
_, err := svc.Update(context.Background(), "nonexistent-id", UpdateInput{
|
||||
Name: "Anything",
|
||||
Description: "",
|
||||
})
|
||||
if err != domain.ErrExampleNotFound {
|
||||
t.Errorf("expected ErrExampleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExampleService_Delete(t *testing.T) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := NewExampleService(repo, logging.Nop())
|
||||
|
||||
// Create an example first
|
||||
created, _ := svc.Create(context.Background(), CreateInput{
|
||||
Name: "Delete Test",
|
||||
Description: "To be deleted",
|
||||
})
|
||||
|
||||
t.Run("deletes example successfully", func(t *testing.T) {
|
||||
err := svc.Delete(context.Background(), created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify deleted
|
||||
_, err = svc.Get(context.Background(), created.ID)
|
||||
if err != domain.ErrExampleNotFound {
|
||||
t.Errorf("expected ErrExampleNotFound after delete, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns not found for missing example", func(t *testing.T) {
|
||||
err := svc.Delete(context.Background(), "nonexistent-id")
|
||||
if err != domain.ErrExampleNotFound {
|
||||
t.Errorf("expected ErrExampleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExampleService_List(t *testing.T) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := NewExampleService(repo, logging.Nop())
|
||||
|
||||
t.Run("returns empty list initially", func(t *testing.T) {
|
||||
examples, err := svc.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(examples) != 0 {
|
||||
t.Errorf("expected 0 examples, got %d", len(examples))
|
||||
}
|
||||
})
|
||||
|
||||
// Create some examples
|
||||
_, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 1", Description: ""})
|
||||
_, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 2", Description: ""})
|
||||
|
||||
t.Run("returns all examples", func(t *testing.T) {
|
||||
examples, err := svc.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(examples) != 2 {
|
||||
t.Errorf("expected 2 examples, got %d", len(examples))
|
||||
}
|
||||
})
|
||||
}
|
||||
0
services/chat-svc/migrations/.gitkeep
Normal file
0
services/chat-svc/migrations/.gitkeep
Normal file
23
workers/worker-svc/.env.example
Normal file
23
workers/worker-svc/.env.example
Normal file
@ -0,0 +1,23 @@
|
||||
# worker-svc Worker Configuration
|
||||
|
||||
# App
|
||||
APP_NAME=worker-svc
|
||||
APP_ENVIRONMENT=development
|
||||
APP_DEBUG=true
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=debug
|
||||
LOG_FORMAT=text
|
||||
|
||||
# Database (required for job queue)
|
||||
DATABASE_URL=postgres://dev:dev@localhost:5432/sp4-verify-1770325799?sslmode=disable
|
||||
|
||||
# Worker
|
||||
WORKER_POLL_INTERVAL=10s
|
||||
WORKER_BATCH_SIZE=10
|
||||
WORKER_MAX_RETRIES=3
|
||||
WORKER_STALE_JOB_TIMEOUT=5m
|
||||
WORKER_JOB_TIMEOUT=5m
|
||||
|
||||
# Redis (optional, for cache)
|
||||
# REDIS_URL=redis://localhost:6379/0
|
||||
29
workers/worker-svc/Dockerfile
Normal file
29
workers/worker-svc/Dockerfile
Normal file
@ -0,0 +1,29 @@
|
||||
# Build stage
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Configure Go private modules
|
||||
# Disable workspace mode - each component builds independently with replace directives
|
||||
ENV GOPRIVATE=git.threesix.ai/*
|
||||
ENV GOWORK=off
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy shared pkg and this worker only
|
||||
COPY pkg/ ./pkg/
|
||||
COPY workers/worker-svc/ ./workers/worker-svc/
|
||||
|
||||
# Build from the worker directory (uses replace directive for ../pkg)
|
||||
RUN cd workers/worker-svc && CGO_ENABLED=0 go build -o /worker-svc ./cmd/worker
|
||||
|
||||
# Production stage
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /
|
||||
|
||||
COPY --from=builder /worker-svc /worker-svc
|
||||
|
||||
ENTRYPOINT ["/worker-svc"]
|
||||
34
workers/worker-svc/Makefile
Normal file
34
workers/worker-svc/Makefile
Normal file
@ -0,0 +1,34 @@
|
||||
.PHONY: build run test lint fmt docker-build clean
|
||||
|
||||
WORKER := worker-svc
|
||||
BINARY := bin/$(WORKER)
|
||||
GO_MODULE := git.threesix.ai/jordan/sp4-verify-1770325799
|
||||
|
||||
# Build the worker binary
|
||||
build:
|
||||
go build -o $(BINARY) ./cmd/worker
|
||||
|
||||
# Run the worker locally
|
||||
run:
|
||||
go run ./cmd/worker
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
# Run linter
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
# Format code
|
||||
fmt:
|
||||
gofmt -w .
|
||||
goimports -w -local $(GO_MODULE) .
|
||||
|
||||
# Build Docker image (run from monorepo root)
|
||||
docker-build:
|
||||
docker build -t $(WORKER):latest -f Dockerfile ../..
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf bin/
|
||||
127
workers/worker-svc/cmd/worker/main.go
Normal file
127
workers/worker-svc/cmd/worker/main.go
Normal file
@ -0,0 +1,127 @@
|
||||
// Package main is the entry point for the worker-svc worker.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/database"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/queue"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/workers/worker-svc/internal/config"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/workers/worker-svc/internal/handlers"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
func main() {
|
||||
// Initialize logger first (with defaults) so we can log config errors
|
||||
logger := logging.New(logging.Config{
|
||||
Level: logging.LevelInfo,
|
||||
Format: logging.FormatJSON,
|
||||
}).WithService("worker-svc")
|
||||
|
||||
// Initialize configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
logger.Error("failed to load config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Reconfigure logger with loaded config
|
||||
logger = logging.New(logging.Config{
|
||||
Level: logging.ParseLevel(cfg.Logging.Level),
|
||||
Format: logging.ParseFormat(cfg.Logging.Format),
|
||||
Environment: cfg.AppConfig.Environment,
|
||||
AddSource: cfg.AppConfig.IsDevelopment(),
|
||||
}).WithService("worker-svc")
|
||||
|
||||
logger.Info("starting worker-svc worker")
|
||||
|
||||
// Setup graceful shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Connect to database
|
||||
pool, err := database.Connect(ctx, cfg.Database.URL, database.Options{
|
||||
MaxOpenConns: cfg.Database.MaxOpenConns,
|
||||
MaxIdleConns: cfg.Database.MaxIdleConns,
|
||||
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("failed to connect to database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
logger.Info("connected to database", "url", pool.URL)
|
||||
|
||||
// Run migrations
|
||||
database.MustRunMigrations(ctx, pool, migrationsFS, "migrations")
|
||||
logger.Info("migrations complete")
|
||||
|
||||
// Initialize queue
|
||||
jobQueue := queue.NewPostgresQueue(pool.DB, logger)
|
||||
|
||||
// Initialize and start handler
|
||||
handler := handlers.New(logger, jobQueue, handlers.Config{
|
||||
PollInterval: cfg.Worker.PollInterval,
|
||||
StaleJobTimeout: cfg.Worker.StaleJobTimeout,
|
||||
JobTimeout: cfg.Worker.JobTimeout,
|
||||
})
|
||||
|
||||
// Register job handlers
|
||||
// TODO: Register your job handlers here
|
||||
// handler.RegisterHandler("send_email", emailHandler)
|
||||
// handler.RegisterHandler("process_image", imageHandler)
|
||||
|
||||
// Setup signal handling
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// Start worker in goroutine
|
||||
go handler.Run(ctx)
|
||||
|
||||
// Start stale job recovery in goroutine
|
||||
go runStaleJobRecovery(ctx, jobQueue, cfg.Worker.StaleJobTimeout, logger)
|
||||
|
||||
// Wait for shutdown signal
|
||||
sig := <-sigCh
|
||||
logger.Info("received shutdown signal", "signal", sig.String())
|
||||
|
||||
// Trigger graceful shutdown with grace period
|
||||
logger.Info("initiating graceful shutdown")
|
||||
cancel()
|
||||
|
||||
// Give in-flight jobs time to complete (grace period)
|
||||
// This allows handlers to notice context cancellation and finish cleanly.
|
||||
const shutdownGracePeriod = 5 * time.Second
|
||||
time.Sleep(shutdownGracePeriod)
|
||||
|
||||
logger.Info("worker-svc worker stopped")
|
||||
}
|
||||
|
||||
// runStaleJobRecovery periodically requeues jobs that have been running too long.
|
||||
func runStaleJobRecovery(ctx context.Context, q *queue.PostgresQueue, timeout time.Duration, logger *logging.Logger) {
|
||||
const staleCheckInterval = time.Minute
|
||||
ticker := time.NewTicker(staleCheckInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
count, err := q.RequeueStale(ctx, timeout)
|
||||
if err != nil {
|
||||
logger.Error("failed to requeue stale jobs", "error", err)
|
||||
} else if count > 0 {
|
||||
logger.Info("requeued stale jobs", "count", count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
workers/worker-svc/cmd/worker/migrations/001_create_jobs.sql
Normal file
32
workers/worker-svc/cmd/worker/migrations/001_create_jobs.sql
Normal file
@ -0,0 +1,32 @@
|
||||
-- Jobs queue table for async job processing.
|
||||
-- Used by pkg/queue for producer/consumer patterns.
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id UUID PRIMARY KEY,
|
||||
job_type VARCHAR(255) NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}',
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
max_retries INT NOT NULL DEFAULT 3,
|
||||
error TEXT,
|
||||
worker_id VARCHAR(255)
|
||||
);
|
||||
|
||||
-- Index for efficient dequeue: pending jobs ordered by priority (desc) and age (asc).
|
||||
-- Partial index only includes pending jobs for efficiency.
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_dequeue ON jobs (priority DESC, created_at ASC)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- Index for finding stale running jobs that need requeue.
|
||||
-- Used by RequeueStale to recover from crashed workers.
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_stale ON jobs (started_at)
|
||||
WHERE status = 'running';
|
||||
|
||||
-- Index for listing/filtering jobs by type.
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_type ON jobs (job_type, created_at DESC);
|
||||
|
||||
-- Index for listing jobs by status (useful for monitoring dashboards).
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status, created_at DESC);
|
||||
8
workers/worker-svc/component.yaml
Normal file
8
workers/worker-svc/component.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
name: worker-svc
|
||||
type: worker
|
||||
path: workers/worker-svc
|
||||
dependencies: []
|
||||
# Add dependencies as needed:
|
||||
# - postgres
|
||||
# - redis
|
||||
# - rabbitmq
|
||||
11
workers/worker-svc/go.mod
Normal file
11
workers/worker-svc/go.mod
Normal file
@ -0,0 +1,11 @@
|
||||
module git.threesix.ai/jordan/sp4-verify-1770325799/workers/worker-svc
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
git.threesix.ai/jordan/sp4-verify-1770325799/pkg v0.0.0
|
||||
github.com/google/uuid v1.6.0
|
||||
)
|
||||
|
||||
// Use local workspace modules (for Docker builds without go.work)
|
||||
replace git.threesix.ai/jordan/sp4-verify-1770325799/pkg => ../../pkg
|
||||
0
workers/worker-svc/go.sum
Normal file
0
workers/worker-svc/go.sum
Normal file
66
workers/worker-svc/internal/config/config.go
Normal file
66
workers/worker-svc/internal/config/config.go
Normal file
@ -0,0 +1,66 @@
|
||||
// Package config provides worker-specific configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/config"
|
||||
)
|
||||
|
||||
// Config holds worker-svc worker configuration.
|
||||
type Config struct {
|
||||
config.AppConfig
|
||||
Database config.DatabaseConfig
|
||||
Logging config.LoggingConfig
|
||||
Worker WorkerConfig
|
||||
}
|
||||
|
||||
// WorkerConfig holds worker-specific settings.
|
||||
type WorkerConfig struct {
|
||||
// PollInterval is how often to check for new jobs when queue is empty.
|
||||
PollInterval time.Duration
|
||||
|
||||
// BatchSize is the max number of jobs to process per poll (for batch workers).
|
||||
BatchSize int
|
||||
|
||||
// MaxRetries is the default maximum retry attempts for failed jobs.
|
||||
MaxRetries int
|
||||
|
||||
// StaleJobTimeout is how long a job can run before being considered stale.
|
||||
// Jobs running longer than this without heartbeat will be requeued.
|
||||
StaleJobTimeout time.Duration
|
||||
|
||||
// JobTimeout is the maximum time a single job handler can run.
|
||||
JobTimeout time.Duration
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables.
|
||||
func Load() (*Config, error) {
|
||||
if err := config.Init(config.Options{
|
||||
AppName: "worker-svc",
|
||||
SetDefaults: func() {
|
||||
viper.SetDefault("WORKER_POLL_INTERVAL", "10s")
|
||||
viper.SetDefault("WORKER_BATCH_SIZE", 10)
|
||||
viper.SetDefault("WORKER_MAX_RETRIES", 3)
|
||||
viper.SetDefault("WORKER_STALE_JOB_TIMEOUT", "5m")
|
||||
viper.SetDefault("WORKER_JOB_TIMEOUT", "5m")
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Config{
|
||||
AppConfig: config.ReadAppConfig(),
|
||||
Database: config.ReadDatabaseConfig(),
|
||||
Logging: config.ReadLoggingConfig(),
|
||||
Worker: WorkerConfig{
|
||||
PollInterval: viper.GetDuration("WORKER_POLL_INTERVAL"),
|
||||
BatchSize: viper.GetInt("WORKER_BATCH_SIZE"),
|
||||
MaxRetries: viper.GetInt("WORKER_MAX_RETRIES"),
|
||||
StaleJobTimeout: viper.GetDuration("WORKER_STALE_JOB_TIMEOUT"),
|
||||
JobTimeout: viper.GetDuration("WORKER_JOB_TIMEOUT"),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
147
workers/worker-svc/internal/handlers/handler.go
Normal file
147
workers/worker-svc/internal/handlers/handler.go
Normal file
@ -0,0 +1,147 @@
|
||||
// Package handlers provides the worker's job processing logic.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/queue"
|
||||
)
|
||||
|
||||
// Config holds handler configuration.
|
||||
type Config struct {
|
||||
// PollInterval is how often to check for new jobs when queue is empty.
|
||||
PollInterval time.Duration
|
||||
|
||||
// StaleJobTimeout is how long a job can run before being considered stale.
|
||||
StaleJobTimeout time.Duration
|
||||
|
||||
// JobTimeout is the maximum time a job handler can run.
|
||||
JobTimeout time.Duration
|
||||
}
|
||||
|
||||
// Handler processes background jobs from the queue.
|
||||
type Handler struct {
|
||||
logger *logging.Logger
|
||||
queue queue.Consumer
|
||||
handlers map[string]queue.Handler
|
||||
config Config
|
||||
workerID string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// New creates a new Handler.
|
||||
func New(logger *logging.Logger, q queue.Consumer, cfg Config) *Handler {
|
||||
// Apply defaults
|
||||
if cfg.PollInterval == 0 {
|
||||
cfg.PollInterval = 10 * time.Second
|
||||
}
|
||||
if cfg.StaleJobTimeout == 0 {
|
||||
cfg.StaleJobTimeout = 5 * time.Minute
|
||||
}
|
||||
if cfg.JobTimeout == 0 {
|
||||
cfg.JobTimeout = 5 * time.Minute
|
||||
}
|
||||
|
||||
return &Handler{
|
||||
logger: logger.WithComponent("handler"),
|
||||
queue: q,
|
||||
handlers: make(map[string]queue.Handler),
|
||||
config: cfg,
|
||||
workerID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterHandler registers a handler for a specific job type.
|
||||
// Call this before Run() to set up job processing.
|
||||
func (h *Handler) RegisterHandler(jobType string, handler queue.Handler) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.handlers[jobType] = handler
|
||||
h.logger.Info("registered job handler", "type", jobType)
|
||||
}
|
||||
|
||||
// Run starts the worker loop and processes jobs until context is cancelled.
|
||||
func (h *Handler) Run(ctx context.Context) {
|
||||
h.logger.Info("worker loop started", "worker_id", h.workerID)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
h.logger.Info("worker loop stopping", "worker_id", h.workerID)
|
||||
return
|
||||
default:
|
||||
if err := h.processNextJob(ctx); err != nil {
|
||||
if errors.Is(err, queue.ErrNoJob) {
|
||||
// Queue is empty, wait before polling again
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(h.config.PollInterval):
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Log error and continue
|
||||
h.logger.Error("error processing job", "error", err)
|
||||
time.Sleep(time.Second) // Brief pause on error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processNextJob dequeues and processes a single job.
|
||||
func (h *Handler) processNextJob(ctx context.Context) error {
|
||||
job, err := h.queue.Dequeue(ctx, h.workerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get handler for job type
|
||||
h.mu.RLock()
|
||||
handler, ok := h.handlers[job.Type]
|
||||
h.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
h.logger.Error("no handler for job type", "job_id", job.ID, "type", job.Type)
|
||||
return h.queue.Fail(ctx, job.ID, fmt.Sprintf("unknown job type: %s", job.Type))
|
||||
}
|
||||
|
||||
// Apply middleware and process (TimeoutMiddleware handles the deadline)
|
||||
wrappedHandler := queue.Chain(
|
||||
queue.RecoveryMiddleware(h.logger),
|
||||
queue.LoggingMiddleware(h.logger),
|
||||
queue.TimeoutMiddleware(h.config.JobTimeout),
|
||||
)(handler)
|
||||
|
||||
// Use parent context - TimeoutMiddleware applies the job timeout
|
||||
jobCtx := ctx
|
||||
_ = jobCtx // jobCtx used below
|
||||
|
||||
if err := wrappedHandler(jobCtx, job); err != nil {
|
||||
// Truncate error message to prevent log bloat and potential data leakage
|
||||
errMsg := truncateErrorMessage(err.Error(), 1000)
|
||||
h.logger.Debug("job handler failed", "job_id", job.ID, "error", errMsg)
|
||||
return h.queue.Fail(ctx, job.ID, errMsg)
|
||||
}
|
||||
|
||||
return h.queue.Ack(ctx, job.ID)
|
||||
}
|
||||
|
||||
// WorkerID returns this handler's unique worker identifier.
|
||||
func (h *Handler) WorkerID() string {
|
||||
return h.workerID
|
||||
}
|
||||
|
||||
// truncateErrorMessage limits error message length to prevent log bloat.
|
||||
func truncateErrorMessage(msg string, maxLen int) string {
|
||||
if len(msg) <= maxLen {
|
||||
return msg
|
||||
}
|
||||
return msg[:maxLen-3] + "..."
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user