From c82c87cc2aa899732b2beee7d3ded305ad9b1b07 Mon Sep 17 00:00:00 2001 From: jordan Date: Thu, 5 Feb 2026 20:06:31 +0000 Subject: [PATCH] Add components: service/api, worker/background-processor --- .woodpecker.yml | 56 +++ CLAUDE.md | 6 +- Procfile | 2 + go.work | 2 + services/api/.env.example | 21 + services/api/Dockerfile | 33 ++ services/api/Makefile | 34 ++ services/api/cmd/server/main.go | 30 ++ services/api/component.yaml | 9 + services/api/go.mod | 8 + services/api/go.sum | 0 .../api/internal/adapter/memory/example.go | 106 +++++ services/api/internal/api/handlers/example.go | 170 ++++++++ .../api/internal/api/handlers/example_test.go | 402 ++++++++++++++++++ services/api/internal/api/handlers/health.go | 26 ++ services/api/internal/api/routes.go | 54 +++ services/api/internal/api/spec.go | 112 +++++ services/api/internal/config/config.go | 34 ++ services/api/internal/domain/errors.go | 21 + services/api/internal/domain/example.go | 89 ++++ services/api/internal/port/example.go | 37 ++ services/api/internal/service/example.go | 137 ++++++ services/api/internal/service/example_test.go | 282 ++++++++++++ services/api/migrations/.gitkeep | 0 workers/background-processor/.env.example | 23 + workers/background-processor/Dockerfile | 31 ++ workers/background-processor/Makefile | 34 ++ .../background-processor/cmd/worker/main.go | 128 ++++++ .../cmd/worker/migrations/001_create_jobs.sql | 32 ++ workers/background-processor/component.yaml | 8 + workers/background-processor/go.mod | 11 + workers/background-processor/go.sum | 0 .../internal/config/config.go | 66 +++ .../internal/handlers/handler.go | 147 +++++++ 34 files changed, 2150 insertions(+), 1 deletion(-) create mode 100644 services/api/.env.example create mode 100644 services/api/Dockerfile create mode 100644 services/api/Makefile create mode 100644 services/api/cmd/server/main.go create mode 100644 services/api/component.yaml create mode 100644 services/api/go.mod create mode 100644 services/api/go.sum create mode 100644 services/api/internal/adapter/memory/example.go create mode 100644 services/api/internal/api/handlers/example.go create mode 100644 services/api/internal/api/handlers/example_test.go create mode 100644 services/api/internal/api/handlers/health.go create mode 100644 services/api/internal/api/routes.go create mode 100644 services/api/internal/api/spec.go create mode 100644 services/api/internal/config/config.go create mode 100644 services/api/internal/domain/errors.go create mode 100644 services/api/internal/domain/example.go create mode 100644 services/api/internal/port/example.go create mode 100644 services/api/internal/service/example.go create mode 100644 services/api/internal/service/example_test.go create mode 100644 services/api/migrations/.gitkeep create mode 100644 workers/background-processor/.env.example create mode 100644 workers/background-processor/Dockerfile create mode 100644 workers/background-processor/Makefile create mode 100644 workers/background-processor/cmd/worker/main.go create mode 100644 workers/background-processor/cmd/worker/migrations/001_create_jobs.sql create mode 100644 workers/background-processor/component.yaml create mode 100644 workers/background-processor/go.mod create mode 100644 workers/background-processor/go.sum create mode 100644 workers/background-processor/internal/config/config.go create mode 100644 workers/background-processor/internal/handlers/handler.go diff --git a/.woodpecker.yml b/.woodpecker.yml index 0290020..ef36234 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -35,6 +35,62 @@ steps: event: push # COMPONENT_STEPS_BELOW + + # Woodpecker CI step for background-processor worker + # Add this step to your .woodpecker.yml + + build-background-processor: + depends_on: [deps] + image: woodpeckerci/plugin-kaniko + settings: + registry: registry.threesix.ai + repo: sp2-verify-1770321984/background-processor + tags: + - latest + - ${CI_COMMIT_SHA:0:8} + context: . + dockerfile: workers/background-processor/Dockerfile + cache: true + skip-tls-verify: true + when: + branch: main + event: push + + deploy-background-processor: + image: bitnami/kubectl:latest + commands: + - kubectl set image deployment/sp2-verify-1770321984-background-processor background-processor=registry.threesix.ai/sp2-verify-1770321984/background-processor:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping" + when: + branch: main + event: push + + # Woodpecker CI step for api service + # Add this step to your .woodpecker.yml + + build-api: + depends_on: [deps] + image: woodpeckerci/plugin-kaniko + settings: + registry: registry.threesix.ai + repo: sp2-verify-1770321984/api + tags: + - latest + - ${CI_COMMIT_SHA:0:8} + context: . + dockerfile: services/api/Dockerfile + cache: true + skip-tls-verify: true + when: + branch: main + event: push + + deploy-api: + image: bitnami/kubectl:latest + commands: + - kubectl set image deployment/sp2-verify-1770321984-api api=registry.threesix.ai/sp2-verify-1770321984/api:${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: diff --git a/CLAUDE.md b/CLAUDE.md index 2927afa..f9f458f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,4 +76,8 @@ sp2-verify-1770321984/ ## Components - +| Component | Type | Path | +|-----------|------|------| +| **api** | API service | `services/api/` | +| **background-processor** | Background worker | `workers/background-processor/` | + diff --git a/Procfile b/Procfile index 8e897c6..50d632e 100644 --- a/Procfile +++ b/Procfile @@ -1,2 +1,4 @@ # Local development processes # Components will be added below as they're created +api: cd services/api && make run +background-processor: cd workers/background-processor && make run diff --git a/go.work b/go.work index 9ffbefe..cc6d431 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,6 @@ go 1.23 use ./pkg +use ./services/api +use ./workers/background-processor // Component modules will be added below diff --git a/services/api/.env.example b/services/api/.env.example new file mode 100644 index 0000000..f6d5268 --- /dev/null +++ b/services/api/.env.example @@ -0,0 +1,21 @@ +# api Service Configuration + +# Server +SERVER_PORT=8001 +SERVER_HOST=0.0.0.0 + +# App +APP_NAME=api +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/sp2-verify-1770321984?sslmode=disable diff --git a/services/api/Dockerfile b/services/api/Dockerfile new file mode 100644 index 0000000..390d00c --- /dev/null +++ b/services/api/Dockerfile @@ -0,0 +1,33 @@ +# Build stage +FROM golang:1.23-alpine AS builder + +RUN apk add --no-cache git + +# Configure Go workspace and private modules +ENV GOPRIVATE=git.threesix.ai/* +ENV GOWORK=/app/go.work + +WORKDIR /app + +# Copy go workspace and all source (workspace deps are local) +# Note: go.work.sum may not exist if no external dependencies have been synced yet +COPY go.work ./ +COPY go.work.su[m] ./ +COPY pkg/ ./pkg/ +COPY services/api/ ./services/api/ + +# Build from workspace root +RUN CGO_ENABLED=0 go build -o /api ./services/api/cmd/server + +# Production stage +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR / + +COPY --from=builder /api /api + +EXPOSE 8001 + +ENTRYPOINT ["/api"] diff --git a/services/api/Makefile b/services/api/Makefile new file mode 100644 index 0000000..2ed3877 --- /dev/null +++ b/services/api/Makefile @@ -0,0 +1,34 @@ +.PHONY: build run test lint fmt docker-build clean + +SERVICE := api +BINARY := bin/$(SERVICE) +GO_MODULE := git.threesix.ai/jordan/sp2-verify-1770321984 + +# 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/ diff --git a/services/api/cmd/server/main.go b/services/api/cmd/server/main.go new file mode 100644 index 0000000..a3c97c6 --- /dev/null +++ b/services/api/cmd/server/main.go @@ -0,0 +1,30 @@ +// Package main is the entry point for the api service. +package main + +import ( + "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/app" + "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/logging" + "git.threesix.ai/jordan/sp2-verify-1770321984/services/api/internal/adapter/memory" + "git.threesix.ai/jordan/sp2-verify-1770321984/services/api/internal/api" + "git.threesix.ai/jordan/sp2-verify-1770321984/services/api/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("api", app.WithDefaultPort(8001)) + + // Register routes with dependency injection + api.RegisterRoutes(application, exampleService) + + // Start server + application.Run() +} diff --git a/services/api/component.yaml b/services/api/component.yaml new file mode 100644 index 0000000..95172bd --- /dev/null +++ b/services/api/component.yaml @@ -0,0 +1,9 @@ +name: api +type: service +port: 8001 +path: services/api +dependencies: [] +# Add dependencies as needed: +# - postgres +# - redis +# - other-service diff --git a/services/api/go.mod b/services/api/go.mod new file mode 100644 index 0000000..bc7dd6f --- /dev/null +++ b/services/api/go.mod @@ -0,0 +1,8 @@ +module git.threesix.ai/jordan/sp2-verify-1770321984/services/api + +go 1.23 + +require git.threesix.ai/jordan/sp2-verify-1770321984/pkg v0.0.0 + +// Use local workspace modules (for Docker builds without go.work) +replace git.threesix.ai/jordan/sp2-verify-1770321984/pkg => ../../pkg diff --git a/services/api/go.sum b/services/api/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/services/api/internal/adapter/memory/example.go b/services/api/internal/adapter/memory/example.go new file mode 100644 index 0000000..c45163f --- /dev/null +++ b/services/api/internal/adapter/memory/example.go @@ -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/sp2-verify-1770321984/services/api/internal/domain" + "git.threesix.ai/jordan/sp2-verify-1770321984/services/api/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 +} diff --git a/services/api/internal/api/handlers/example.go b/services/api/internal/api/handlers/example.go new file mode 100644 index 0000000..3c805ce --- /dev/null +++ b/services/api/internal/api/handlers/example.go @@ -0,0 +1,170 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/app" + "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/httperror" + "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/httpresponse" + "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/logging" + "git.threesix.ai/jordan/sp2-verify-1770321984/services/api/internal/domain" + "git.threesix.ai/jordan/sp2-verify-1770321984/services/api/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 + } +} diff --git a/services/api/internal/api/handlers/example_test.go b/services/api/internal/api/handlers/example_test.go new file mode 100644 index 0000000..3720c94 --- /dev/null +++ b/services/api/internal/api/handlers/example_test.go @@ -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/sp2-verify-1770321984/pkg/logging" + "git.threesix.ai/jordan/sp2-verify-1770321984/services/api/internal/domain" + "git.threesix.ai/jordan/sp2-verify-1770321984/services/api/internal/port" + "git.threesix.ai/jordan/sp2-verify-1770321984/services/api/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) + } + }) + } +} diff --git a/services/api/internal/api/handlers/health.go b/services/api/internal/api/handlers/health.go new file mode 100644 index 0000000..7d0bfb8 --- /dev/null +++ b/services/api/internal/api/handlers/health.go @@ -0,0 +1,26 @@ +package handlers + +import ( + "net/http" + + "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/httpresponse" + "git.threesix.ai/jordan/sp2-verify-1770321984/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": "api", + "status": "healthy", + }) +} diff --git a/services/api/internal/api/routes.go b/services/api/internal/api/routes.go new file mode 100644 index 0000000..91eb7d6 --- /dev/null +++ b/services/api/internal/api/routes.go @@ -0,0 +1,54 @@ +// Package api provides HTTP routing and handlers for the api service. +package api + +import ( + "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/app" + "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/auth" + "git.threesix.ai/jordan/sp2-verify-1770321984/services/api/internal/api/handlers" + "git.threesix.ai/jordan/sp2-verify-1770321984/services/api/internal/config" + "git.threesix.ai/jordan/sp2-verify-1770321984/services/api/internal/service" +) + +// RegisterRoutes registers all HTTP routes for the service. +// Routes are mounted under /api/api to match the ingress path routing. +// This allows the monorepo to expose multiple services under a single domain: +// - https://domain/api/api/health +// - https://domain/api/api/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/api/* to this service. + application.Route("/api/api", 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: "sp2-verify-1770321984", + }), + })) + } + + r.Post("/examples", app.Wrap(exampleHandler.Create)) + r.Put("/examples/{id}", app.Wrap(exampleHandler.Update)) + r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete)) + }) + }) +} diff --git a/services/api/internal/api/spec.go b/services/api/internal/api/spec.go new file mode 100644 index 0000000..a63d123 --- /dev/null +++ b/services/api/internal/api/spec.go @@ -0,0 +1,112 @@ +package api + +import "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/openapi" + +// NewServiceSpec builds the OpenAPI specification for the api service. +func NewServiceSpec() *openapi.OpenAPISpec { + spec := openapi.NewOpenAPISpec("api API", "1.0.0"). + WithDescription("REST API for the api 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/api/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/api/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/api/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/api/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/api/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/api/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 +} diff --git a/services/api/internal/config/config.go b/services/api/internal/config/config.go new file mode 100644 index 0000000..4ef0301 --- /dev/null +++ b/services/api/internal/config/config.go @@ -0,0 +1,34 @@ +// Package config provides service-specific configuration. +package config + +import ( + "os" + "strings" + + "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/config" +) + +// Config extends the base config with api-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"), + } +} diff --git a/services/api/internal/domain/errors.go b/services/api/internal/domain/errors.go new file mode 100644 index 0000000..d4ffe10 --- /dev/null +++ b/services/api/internal/domain/errors.go @@ -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") +) diff --git a/services/api/internal/domain/example.go b/services/api/internal/domain/example.go new file mode 100644 index 0000000..4ee48e9 --- /dev/null +++ b/services/api/internal/domain/example.go @@ -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 +} diff --git a/services/api/internal/port/example.go b/services/api/internal/port/example.go new file mode 100644 index 0000000..8c6bdb7 --- /dev/null +++ b/services/api/internal/port/example.go @@ -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/sp2-verify-1770321984/services/api/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) +} diff --git a/services/api/internal/service/example.go b/services/api/internal/service/example.go new file mode 100644 index 0000000..60d2e19 --- /dev/null +++ b/services/api/internal/service/example.go @@ -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/sp2-verify-1770321984/pkg/logging" + "git.threesix.ai/jordan/sp2-verify-1770321984/services/api/internal/domain" + "git.threesix.ai/jordan/sp2-verify-1770321984/services/api/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 +} diff --git a/services/api/internal/service/example_test.go b/services/api/internal/service/example_test.go new file mode 100644 index 0000000..348a5f5 --- /dev/null +++ b/services/api/internal/service/example_test.go @@ -0,0 +1,282 @@ +package service + +import ( + "context" + "sync" + "testing" + + "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/logging" + "git.threesix.ai/jordan/sp2-verify-1770321984/services/api/internal/domain" + "git.threesix.ai/jordan/sp2-verify-1770321984/services/api/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)) + } + }) +} diff --git a/services/api/migrations/.gitkeep b/services/api/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/workers/background-processor/.env.example b/workers/background-processor/.env.example new file mode 100644 index 0000000..c7b755d --- /dev/null +++ b/workers/background-processor/.env.example @@ -0,0 +1,23 @@ +# background-processor Worker Configuration + +# App +APP_NAME=background-processor +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/sp2-verify-1770321984?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 diff --git a/workers/background-processor/Dockerfile b/workers/background-processor/Dockerfile new file mode 100644 index 0000000..bbb4102 --- /dev/null +++ b/workers/background-processor/Dockerfile @@ -0,0 +1,31 @@ +# Build stage +FROM golang:1.23-alpine AS builder + +RUN apk add --no-cache git + +# Configure Go workspace and private modules +ENV GOPRIVATE=git.threesix.ai/* +ENV GOWORK=/app/go.work + +WORKDIR /app + +# Copy go workspace and all source (workspace deps are local) +# Note: go.work.sum may not exist if no external dependencies have been synced yet +COPY go.work ./ +COPY go.work.su[m] ./ +COPY pkg/ ./pkg/ +COPY workers/background-processor/ ./workers/background-processor/ + +# Build from workspace root +RUN CGO_ENABLED=0 go build -o /background-processor ./workers/background-processor/cmd/worker + +# Production stage +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR / + +COPY --from=builder /background-processor /background-processor + +ENTRYPOINT ["/background-processor"] diff --git a/workers/background-processor/Makefile b/workers/background-processor/Makefile new file mode 100644 index 0000000..4cfb279 --- /dev/null +++ b/workers/background-processor/Makefile @@ -0,0 +1,34 @@ +.PHONY: build run test lint fmt docker-build clean + +WORKER := background-processor +BINARY := bin/$(WORKER) +GO_MODULE := git.threesix.ai/jordan/sp2-verify-1770321984 + +# 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/ diff --git a/workers/background-processor/cmd/worker/main.go b/workers/background-processor/cmd/worker/main.go new file mode 100644 index 0000000..8d2b183 --- /dev/null +++ b/workers/background-processor/cmd/worker/main.go @@ -0,0 +1,128 @@ +// Package main is the entry point for the background-processor worker. +package main + +import ( + "context" + "embed" + "os" + "os/signal" + "syscall" + "time" + + "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/config" + "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/database" + "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/logging" + "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/queue" + "git.threesix.ai/jordan/sp2-verify-1770321984/workers/background-processor/internal/handlers" + workerconfig "git.threesix.ai/jordan/sp2-verify-1770321984/workers/background-processor/internal/config" +) + +//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("background-processor") + + // Initialize configuration + cfg, err := workerconfig.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("background-processor") + + logger.Info("starting background-processor 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("background-processor 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) + } + } + } +} diff --git a/workers/background-processor/cmd/worker/migrations/001_create_jobs.sql b/workers/background-processor/cmd/worker/migrations/001_create_jobs.sql new file mode 100644 index 0000000..5af8ef9 --- /dev/null +++ b/workers/background-processor/cmd/worker/migrations/001_create_jobs.sql @@ -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); diff --git a/workers/background-processor/component.yaml b/workers/background-processor/component.yaml new file mode 100644 index 0000000..d844662 --- /dev/null +++ b/workers/background-processor/component.yaml @@ -0,0 +1,8 @@ +name: background-processor +type: worker +path: workers/background-processor +dependencies: [] +# Add dependencies as needed: +# - postgres +# - redis +# - rabbitmq diff --git a/workers/background-processor/go.mod b/workers/background-processor/go.mod new file mode 100644 index 0000000..54d1057 --- /dev/null +++ b/workers/background-processor/go.mod @@ -0,0 +1,11 @@ +module git.threesix.ai/jordan/sp2-verify-1770321984/workers/background-processor + +go 1.23 + +require ( + git.threesix.ai/jordan/sp2-verify-1770321984/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/sp2-verify-1770321984/pkg => ../../pkg diff --git a/workers/background-processor/go.sum b/workers/background-processor/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/workers/background-processor/internal/config/config.go b/workers/background-processor/internal/config/config.go new file mode 100644 index 0000000..0adedeb --- /dev/null +++ b/workers/background-processor/internal/config/config.go @@ -0,0 +1,66 @@ +// Package config provides worker-specific configuration. +package config + +import ( + "time" + + "github.com/spf13/viper" + + "git.threesix.ai/jordan/sp2-verify-1770321984/pkg/config" +) + +// Config holds background-processor 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: "background-processor", + 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 +} diff --git a/workers/background-processor/internal/handlers/handler.go b/workers/background-processor/internal/handlers/handler.go new file mode 100644 index 0000000..8b80f28 --- /dev/null +++ b/workers/background-processor/internal/handlers/handler.go @@ -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/sp2-verify-1770321984/pkg/logging" + "git.threesix.ai/jordan/sp2-verify-1770321984/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] + "..." +}