diff --git a/docs/plans/2026-02-20-go-api-plan.md b/docs/plans/2026-02-20-go-api-plan.md new file mode 100644 index 0000000..11d164d --- /dev/null +++ b/docs/plans/2026-02-20-go-api-plan.md @@ -0,0 +1,1503 @@ +# go-api Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build `forge.lthn.ai/core/go-api`, a Gin-based REST framework with OpenAPI generation that subsystems plug into via a RouteGroup interface. + +**Architecture:** go-api provides the HTTP engine, middleware stack, response envelope, and OpenAPI tooling. Each ecosystem package (go-ml, go-rag, etc.) imports go-api and registers its own route group. WebSocket support via go-ws Hub runs alongside REST. + +**Tech Stack:** Go 1.25, Gin, swaggo/swag, gin-swagger, gin-contrib/cors, go-ws + +**Design doc:** `docs/plans/2026-02-20-go-api-design.md` + +**Repo location:** `/Users/snider/Code/go-api` (module: `forge.lthn.ai/core/go-api`) + +**Licence:** EUPL-1.2 + +**Convention:** UK English in comments and user-facing strings. Test naming: `_Good`, `_Bad`, `_Ugly`. + +--- + +### Task 1: Scaffold Repository + +**Files:** +- Create: `/Users/snider/Code/go-api/go.mod` +- Create: `/Users/snider/Code/go-api/response.go` +- Create: `/Users/snider/Code/go-api/response_test.go` +- Create: `/Users/snider/Code/go-api/LICENCE` + +**Step 1: Create repo and go.mod** + +```bash +mkdir -p /Users/snider/Code/go-api +cd /Users/snider/Code/go-api +git init +``` + +Create `go.mod`: +``` +module forge.lthn.ai/core/go-api + +go 1.25.5 + +require github.com/gin-gonic/gin v1.10.0 +``` + +Then run: +```bash +go mod tidy +``` + +**Step 2: Create LICENCE file** + +Copy the EUPL-1.2 licence text. Use the same LICENCE file as other ecosystem repos: +```bash +cp /Users/snider/Code/go-ai/LICENCE /Users/snider/Code/go-api/LICENCE +``` + +**Step 3: Commit scaffold** + +```bash +git add go.mod go.sum LICENCE +git commit -m "chore: scaffold go-api module with Gin dependency" +``` + +--- + +### Task 2: Response Envelope (TDD) + +**Files:** +- Create: `/Users/snider/Code/go-api/response.go` +- Create: `/Users/snider/Code/go-api/response_test.go` + +**Step 1: Write the failing tests** + +Create `response_test.go`: +```go +package api_test + +import ( + "encoding/json" + "testing" + + api "forge.lthn.ai/core/go-api" +) + +func TestOK_Good(t *testing.T) { + type Payload struct { + Name string `json:"name"` + } + resp := api.OK(Payload{Name: "test"}) + + if !resp.Success { + t.Fatal("expected Success to be true") + } + if resp.Data.Name != "test" { + t.Fatalf("expected Data.Name = test, got %s", resp.Data.Name) + } + if resp.Error != nil { + t.Fatal("expected Error to be nil") + } +} + +func TestFail_Good(t *testing.T) { + resp := api.Fail("not_found", "Resource not found") + + if resp.Success { + t.Fatal("expected Success to be false") + } + if resp.Error == nil { + t.Fatal("expected Error to be non-nil") + } + if resp.Error.Code != "not_found" { + t.Fatalf("expected Code = not_found, got %s", resp.Error.Code) + } + if resp.Error.Message != "Resource not found" { + t.Fatalf("expected Message = Resource not found, got %s", resp.Error.Message) + } +} + +func TestFailWithDetails_Good(t *testing.T) { + details := map[string]string{"field": "email"} + resp := api.FailWithDetails("validation_error", "Invalid input", details) + + if resp.Error.Details == nil { + t.Fatal("expected Details to be non-nil") + } +} + +func TestPaginated_Good(t *testing.T) { + items := []string{"a", "b", "c"} + resp := api.Paginated(items, 1, 10, 42) + + if !resp.Success { + t.Fatal("expected Success to be true") + } + if resp.Meta == nil { + t.Fatal("expected Meta to be non-nil") + } + if resp.Meta.Page != 1 { + t.Fatalf("expected Page = 1, got %d", resp.Meta.Page) + } + if resp.Meta.PerPage != 10 { + t.Fatalf("expected PerPage = 10, got %d", resp.Meta.PerPage) + } + if resp.Meta.Total != 42 { + t.Fatalf("expected Total = 42, got %d", resp.Meta.Total) + } +} + +func TestOK_JSON_Good(t *testing.T) { + resp := api.OK("hello") + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if raw["success"] != true { + t.Fatal("expected success = true in JSON") + } + if raw["data"] != "hello" { + t.Fatalf("expected data = hello, got %v", raw["data"]) + } + // error and meta should be omitted + if _, ok := raw["error"]; ok { + t.Fatal("expected error to be omitted from JSON") + } + if _, ok := raw["meta"]; ok { + t.Fatal("expected meta to be omitted from JSON") + } +} + +func TestFail_JSON_Good(t *testing.T) { + resp := api.Fail("err", "msg") + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if raw["success"] != false { + t.Fatal("expected success = false in JSON") + } + // data should be omitted + if _, ok := raw["data"]; ok { + t.Fatal("expected data to be omitted from JSON") + } +} +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd /Users/snider/Code/go-api +go test ./... -v +``` + +Expected: Compilation errors — `api.OK`, `api.Fail`, etc. not defined. + +**Step 3: Implement response.go** + +Create `response.go`: +```go +// Package api provides a Gin-based REST framework with OpenAPI generation. +// Subsystems implement RouteGroup to register their own endpoints. +package api + +// Response is the standard envelope for all API responses. +type Response[T any] struct { + Success bool `json:"success"` + Data T `json:"data,omitempty"` + Error *Error `json:"error,omitempty"` + Meta *Meta `json:"meta,omitempty"` +} + +// Error describes a failed API request. +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details any `json:"details,omitempty"` +} + +// Meta carries pagination and request metadata. +type Meta struct { + RequestID string `json:"request_id,omitempty"` + Duration string `json:"duration,omitempty"` + Page int `json:"page,omitempty"` + PerPage int `json:"per_page,omitempty"` + Total int `json:"total,omitempty"` +} + +// OK returns a successful response wrapping data. +func OK[T any](data T) Response[T] { + return Response[T]{Success: true, Data: data} +} + +// Fail returns an error response with code and message. +func Fail(code, message string) Response[any] { + return Response[any]{ + Success: false, + Error: &Error{Code: code, Message: message}, + } +} + +// FailWithDetails returns an error response with additional detail payload. +func FailWithDetails(code, message string, details any) Response[any] { + return Response[any]{ + Success: false, + Error: &Error{Code: code, Message: message, Details: details}, + } +} + +// Paginated returns a successful response with pagination metadata. +func Paginated[T any](data T, page, perPage, total int) Response[T] { + return Response[T]{ + Success: true, + Data: data, + Meta: &Meta{Page: page, PerPage: perPage, Total: total}, + } +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd /Users/snider/Code/go-api +go test ./... -v +``` + +Expected: All 6 tests PASS. + +**Step 5: Commit** + +```bash +cd /Users/snider/Code/go-api +git add response.go response_test.go +git commit -m "feat: add response envelope with OK, Fail, Paginated helpers" +``` + +--- + +### Task 3: RouteGroup Interface + +**Files:** +- Create: `/Users/snider/Code/go-api/group.go` +- Create: `/Users/snider/Code/go-api/group_test.go` + +**Step 1: Write the failing test** + +Create `group_test.go`: +```go +package api_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + api "forge.lthn.ai/core/go-api" + "github.com/gin-gonic/gin" +) + +// stubGroup is a minimal RouteGroup for testing. +type stubGroup struct{} + +func (s *stubGroup) Name() string { return "stub" } +func (s *stubGroup) BasePath() string { return "/v1/stub" } + +func (s *stubGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/ping", func(c *gin.Context) { + c.JSON(200, api.OK("pong")) + }) +} + +// stubStreamGroup implements both RouteGroup and StreamGroup. +type stubStreamGroup struct { + stubGroup +} + +func (s *stubStreamGroup) Channels() []string { + return []string{"stub.events", "stub.updates"} +} + +func TestRouteGroup_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + g := gin.New() + group := &stubGroup{} + + rg := g.Group(group.BasePath()) + group.RegisterRoutes(rg) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/v1/stub/ping", nil) + g.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestStreamGroup_Good(t *testing.T) { + group := &stubStreamGroup{} + + // Verify it satisfies StreamGroup + var sg api.StreamGroup = group + channels := sg.Channels() + + if len(channels) != 2 { + t.Fatalf("expected 2 channels, got %d", len(channels)) + } + if channels[0] != "stub.events" { + t.Fatalf("expected stub.events, got %s", channels[0]) + } +} + +func TestRouteGroupName_Good(t *testing.T) { + group := &stubGroup{} + + var rg api.RouteGroup = group + if rg.Name() != "stub" { + t.Fatalf("expected name stub, got %s", rg.Name()) + } + if rg.BasePath() != "/v1/stub" { + t.Fatalf("expected basepath /v1/stub, got %s", rg.BasePath()) + } +} +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd /Users/snider/Code/go-api +go test ./... -v +``` + +Expected: Compilation errors — `api.RouteGroup`, `api.StreamGroup` not defined. + +**Step 3: Implement group.go** + +Create `group.go`: +```go +package api + +import "github.com/gin-gonic/gin" + +// RouteGroup registers API routes onto a Gin router group. +// Subsystems implement this to expose their REST endpoints. +type RouteGroup interface { + // Name returns the route group identifier (e.g. "ml", "rag", "tasks"). + Name() string + // BasePath returns the URL prefix (e.g. "/v1/ml"). + BasePath() string + // RegisterRoutes adds handlers to the provided router group. + RegisterRoutes(rg *gin.RouterGroup) +} + +// StreamGroup optionally declares WebSocket channels a subsystem publishes to. +// Subsystems implementing both RouteGroup and StreamGroup expose both REST +// endpoints and real-time event channels. +type StreamGroup interface { + // Channels returns the WebSocket channel names this group publishes to. + Channels() []string +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd /Users/snider/Code/go-api +go test ./... -v +``` + +Expected: All tests PASS (previous 6 + new 3). + +**Step 5: Commit** + +```bash +cd /Users/snider/Code/go-api +git add group.go group_test.go +git commit -m "feat: add RouteGroup and StreamGroup interfaces" +``` + +--- + +### Task 4: Engine + Options (TDD) + +**Files:** +- Create: `/Users/snider/Code/go-api/api.go` +- Create: `/Users/snider/Code/go-api/options.go` +- Create: `/Users/snider/Code/go-api/api_test.go` + +**Step 1: Write the failing tests** + +Create `api_test.go`: +```go +package api_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + api "forge.lthn.ai/core/go-api" + "github.com/gin-gonic/gin" +) + +func TestNew_Good(t *testing.T) { + engine, err := api.New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + if engine == nil { + t.Fatal("expected non-nil engine") + } +} + +func TestNewWithAddr_Good(t *testing.T) { + engine, err := api.New(api.WithAddr(":9090")) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + if engine.Addr() != ":9090" { + t.Fatalf("expected addr :9090, got %s", engine.Addr()) + } +} + +func TestDefaultAddr_Good(t *testing.T) { + engine, _ := api.New() + if engine.Addr() != ":8080" { + t.Fatalf("expected default addr :8080, got %s", engine.Addr()) + } +} + +func TestRegister_Good(t *testing.T) { + engine, _ := api.New() + group := &stubGroup{} + + engine.Register(group) + + if len(engine.Groups()) != 1 { + t.Fatalf("expected 1 group, got %d", len(engine.Groups())) + } + if engine.Groups()[0].Name() != "stub" { + t.Fatalf("expected group name stub, got %s", engine.Groups()[0].Name()) + } +} + +func TestRegisterMultiple_Good(t *testing.T) { + engine, _ := api.New() + engine.Register(&stubGroup{}) + engine.Register(&stubStreamGroup{}) + + if len(engine.Groups()) != 2 { + t.Fatalf("expected 2 groups, got %d", len(engine.Groups())) + } +} + +func TestHandler_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + engine, _ := api.New() + engine.Register(&stubGroup{}) + + handler := engine.Handler() + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/v1/stub/ping", nil) + handler.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["success"] != true { + t.Fatal("expected success = true") + } +} + +func TestHealthEndpoint_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + engine, _ := api.New() + handler := engine.Handler() + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/health", nil) + handler.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestServeAndShutdown_Good(t *testing.T) { + engine, _ := api.New(api.WithAddr(":0")) + engine.Register(&stubGroup{}) + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + errCh := make(chan error, 1) + go func() { + errCh <- engine.Serve(ctx) + }() + + // Wait for context cancellation to trigger shutdown + <-ctx.Done() + + select { + case err := <-errCh: + if err != nil && err != http.ErrServerClosed && err != context.DeadlineExceeded { + t.Fatalf("Serve() returned unexpected error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Serve() did not return after context cancellation") + } +} +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd /Users/snider/Code/go-api +go test ./... -v +``` + +Expected: Compilation errors — `api.New`, `api.WithAddr`, `api.Engine` not defined. + +**Step 3: Implement options.go** + +Create `options.go`: +```go +package api + +// Option configures the Engine. +type Option func(*Engine) error + +// WithAddr sets the listen address (default ":8080"). +func WithAddr(addr string) Option { + return func(e *Engine) error { + e.addr = addr + return nil + } +} +``` + +**Step 4: Implement api.go** + +Create `api.go`: +```go +package api + +import ( + "context" + "fmt" + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" +) + +// Engine is the central REST API server. +// Register RouteGroups to add endpoints, then call Serve to start. +type Engine struct { + gin *gin.Engine + addr string + groups []RouteGroup + logger *slog.Logger + built bool +} + +// New creates an Engine with the given options. +func New(opts ...Option) (*Engine, error) { + e := &Engine{ + addr: ":8080", + logger: slog.Default(), + } + + for _, opt := range opts { + if err := opt(e); err != nil { + return nil, fmt.Errorf("apply option: %w", err) + } + } + + return e, nil +} + +// Addr returns the configured listen address. +func (e *Engine) Addr() string { + return e.addr +} + +// Groups returns all registered route groups. +func (e *Engine) Groups() []RouteGroup { + return e.groups +} + +// Register adds a RouteGroup to the engine. +// Routes are mounted when Handler() or Serve() is called. +func (e *Engine) Register(group RouteGroup) { + e.groups = append(e.groups, group) + e.built = false +} + +// build constructs the Gin engine with all registered groups. +func (e *Engine) build() { + if e.built && e.gin != nil { + return + } + + e.gin = gin.New() + e.gin.Use(gin.Recovery()) + + // Health endpoint + e.gin.GET("/health", func(c *gin.Context) { + c.JSON(200, OK("healthy")) + }) + + // Mount each route group + for _, group := range e.groups { + rg := e.gin.Group(group.BasePath()) + group.RegisterRoutes(rg) + e.logger.Info("registered route group", "name", group.Name(), "path", group.BasePath()) + } + + e.built = true +} + +// Handler returns the http.Handler for testing or custom server usage. +func (e *Engine) Handler() http.Handler { + e.build() + return e.gin +} + +// Serve starts the HTTP server and blocks until the context is cancelled. +// Performs graceful shutdown on context cancellation. +func (e *Engine) Serve(ctx context.Context) error { + e.build() + + srv := &http.Server{ + Addr: e.addr, + Handler: e.gin, + } + + errCh := make(chan error, 1) + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- err + } + close(errCh) + }() + + <-ctx.Done() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5_000_000_000) // 5s + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("shutdown: %w", err) + } + + if err, ok := <-errCh; ok { + return err + } + + return nil +} +``` + +**Step 5: Run tests to verify they pass** + +```bash +cd /Users/snider/Code/go-api +go test ./... -v -count=1 +``` + +Expected: All tests PASS. + +**Step 6: Commit** + +```bash +cd /Users/snider/Code/go-api +git add api.go options.go api_test.go +git commit -m "feat: add Engine with Register, Handler, Serve, and graceful shutdown" +``` + +--- + +### Task 5: Middleware (TDD) + +**Files:** +- Create: `/Users/snider/Code/go-api/middleware.go` +- Create: `/Users/snider/Code/go-api/middleware_test.go` +- Modify: `/Users/snider/Code/go-api/options.go` — add middleware options + +**Step 1: Write the failing tests** + +Create `middleware_test.go`: +```go +package api_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + api "forge.lthn.ai/core/go-api" + "github.com/gin-gonic/gin" +) + +func TestBearerAuth_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + engine, _ := api.New(api.WithBearerAuth("secret-token")) + engine.Register(&stubGroup{}) + handler := engine.Handler() + + // Request without token → 401 + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/v1/stub/ping", nil) + handler.ServeHTTP(w, req) + + if w.Code != 401 { + t.Fatalf("expected 401 without token, got %d", w.Code) + } + + // Request with correct token → 200 + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/v1/stub/ping", nil) + req.Header.Set("Authorization", "Bearer secret-token") + handler.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200 with correct token, got %d", w.Code) + } +} + +func TestBearerAuth_Bad(t *testing.T) { + gin.SetMode(gin.TestMode) + engine, _ := api.New(api.WithBearerAuth("secret-token")) + engine.Register(&stubGroup{}) + handler := engine.Handler() + + // Wrong token → 401 + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/v1/stub/ping", nil) + req.Header.Set("Authorization", "Bearer wrong-token") + handler.ServeHTTP(w, req) + + if w.Code != 401 { + t.Fatalf("expected 401 with wrong token, got %d", w.Code) + } +} + +func TestHealthBypassesAuth_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + engine, _ := api.New(api.WithBearerAuth("secret-token")) + handler := engine.Handler() + + // Health endpoint should not require auth + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/health", nil) + handler.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200 for /health without auth, got %d", w.Code) + } +} + +func TestRequestID_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + engine, _ := api.New(api.WithRequestID()) + engine.Register(&stubGroup{}) + handler := engine.Handler() + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/v1/stub/ping", nil) + handler.ServeHTTP(w, req) + + rid := w.Header().Get("X-Request-ID") + if rid == "" { + t.Fatal("expected X-Request-ID header to be set") + } +} + +func TestRequestIDPreserved_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + engine, _ := api.New(api.WithRequestID()) + engine.Register(&stubGroup{}) + handler := engine.Handler() + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/v1/stub/ping", nil) + req.Header.Set("X-Request-ID", "my-custom-id") + handler.ServeHTTP(w, req) + + rid := w.Header().Get("X-Request-ID") + if rid != "my-custom-id" { + t.Fatalf("expected X-Request-ID = my-custom-id, got %s", rid) + } +} + +func TestCORS_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + engine, _ := api.New(api.WithCORS("https://example.com")) + engine.Register(&stubGroup{}) + handler := engine.Handler() + + // Preflight request + w := httptest.NewRecorder() + req, _ := http.NewRequest("OPTIONS", "/v1/stub/ping", nil) + req.Header.Set("Origin", "https://example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + handler.ServeHTTP(w, req) + + origin := w.Header().Get("Access-Control-Allow-Origin") + if origin != "https://example.com" { + t.Fatalf("expected CORS origin https://example.com, got %s", origin) + } +} +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd /Users/snider/Code/go-api +go test ./... -v +``` + +Expected: Compilation errors — `WithBearerAuth`, `WithRequestID`, `WithCORS` not defined. + +**Step 3: Implement middleware.go** + +Create `middleware.go`: +```go +package api + +import ( + "crypto/rand" + "encoding/hex" + "strings" + + "github.com/gin-gonic/gin" +) + +// bearerAuthMiddleware validates Bearer tokens. +// Skips paths listed in skip (e.g. /health, /swagger). +func bearerAuthMiddleware(token string, skip []string) gin.HandlerFunc { + return func(c *gin.Context) { + path := c.Request.URL.Path + for _, s := range skip { + if strings.HasPrefix(path, s) { + c.Next() + return + } + } + + header := c.GetHeader("Authorization") + if header == "" { + c.JSON(401, Fail("unauthorised", "Missing Authorization header")) + c.Abort() + return + } + + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] != token { + c.JSON(401, Fail("unauthorised", "Invalid bearer token")) + c.Abort() + return + } + + c.Next() + } +} + +// requestIDMiddleware sets X-Request-ID on every response. +// If the client sends one, it is preserved; otherwise a random ID is generated. +func requestIDMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + rid := c.GetHeader("X-Request-ID") + if rid == "" { + b := make([]byte, 16) + rand.Read(b) + rid = hex.EncodeToString(b) + } + c.Header("X-Request-ID", rid) + c.Set("request_id", rid) + c.Next() + } +} +``` + +**Step 4: Add middleware options to options.go** + +Append to `options.go`: +```go +import "github.com/gin-contrib/cors" + +// WithBearerAuth adds bearer token authentication middleware. +// The /health and /swagger paths are excluded from authentication. +func WithBearerAuth(token string) Option { + return func(e *Engine) error { + e.middlewares = append(e.middlewares, bearerAuthMiddleware(token, []string{"/health", "/swagger"})) + return nil + } +} + +// WithRequestID adds a middleware that sets X-Request-ID on every response. +func WithRequestID() Option { + return func(e *Engine) error { + e.middlewares = append(e.middlewares, requestIDMiddleware()) + return nil + } +} + +// WithCORS configures Cross-Origin Resource Sharing. +// Pass "*" to allow all origins, or specific origins. +func WithCORS(allowOrigins ...string) Option { + return func(e *Engine) error { + config := cors.DefaultConfig() + if len(allowOrigins) == 1 && allowOrigins[0] == "*" { + config.AllowAllOrigins = true + } else { + config.AllowOrigins = allowOrigins + } + config.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"} + config.AllowHeaders = []string{"Authorization", "Content-Type", "X-Request-ID"} + e.middlewares = append(e.middlewares, cors.New(config)) + return nil + } +} +``` + +Update `Engine` struct in `api.go` to include `middlewares []gin.HandlerFunc` field, and apply them in `build()`: +```go +// Add to Engine struct: +middlewares []gin.HandlerFunc + +// In build(), after gin.New() and gin.Recovery(), before health endpoint: +for _, mw := range e.middlewares { + e.gin.Use(mw) +} +``` + +**Step 5: Run tests to verify they pass** + +```bash +cd /Users/snider/Code/go-api +go test ./... -v -count=1 +``` + +Expected: All tests PASS. + +**Step 6: Commit** + +```bash +cd /Users/snider/Code/go-api +git add middleware.go middleware_test.go options.go api.go +git commit -m "feat: add bearer auth, request ID, and CORS middleware" +``` + +--- + +### Task 6: WebSocket Integration (TDD) + +**Files:** +- Create: `/Users/snider/Code/go-api/websocket.go` +- Create: `/Users/snider/Code/go-api/websocket_test.go` +- Modify: `/Users/snider/Code/go-api/options.go` — add WithWSHub +- Modify: `/Users/snider/Code/go-api/api.go` — mount /ws route + +**Step 1: Write the failing test** + +Create `websocket_test.go`: +```go +package api_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + api "forge.lthn.ai/core/go-api" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func TestWSEndpoint_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + engine, _ := api.New(api.WithWSHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + conn.WriteMessage(websocket.TextMessage, []byte("hello")) + }))) + + srv := httptest.NewServer(engine.Handler()) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial failed: %v", err) + } + defer conn.Close() + + _, msg, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read failed: %v", err) + } + if string(msg) != "hello" { + t.Fatalf("expected hello, got %s", string(msg)) + } +} + +func TestNoWSHandler_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + engine, _ := api.New() + handler := engine.Handler() + + // /ws should 404 when no handler configured + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/ws", nil) + handler.ServeHTTP(w, req) + + if w.Code != 404 { + t.Fatalf("expected 404 without WS handler, got %d", w.Code) + } +} + +func TestChannelListing_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + engine, _ := api.New() + engine.Register(&stubStreamGroup{}) + + channels := engine.Channels() + if len(channels) != 2 { + t.Fatalf("expected 2 channels, got %d", len(channels)) + } +} +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd /Users/snider/Code/go-api +go test ./... -v +``` + +Expected: Compilation errors. + +**Step 3: Implement websocket.go + option + engine changes** + +Create `websocket.go`: +```go +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// wrapWSHandler adapts a standard http.Handler to a Gin handler for the /ws route. +func wrapWSHandler(h http.Handler) gin.HandlerFunc { + return func(c *gin.Context) { + h.ServeHTTP(c.Writer, c.Request) + } +} +``` + +Add to `options.go`: +```go +// WithWSHandler registers a WebSocket handler at GET /ws. +// Typically this wraps a go-ws Hub.Handler(). +func WithWSHandler(h http.Handler) Option { + return func(e *Engine) error { + e.wsHandler = h + return nil + } +} +``` + +Add to `Engine` struct in `api.go`: +```go +wsHandler http.Handler +``` + +Add to `build()` after mounting route groups: +```go +// WebSocket endpoint +if e.wsHandler != nil { + e.gin.GET("/ws", wrapWSHandler(e.wsHandler)) +} +``` + +Add `Channels()` method to `Engine`: +```go +// Channels returns all WebSocket channel names from registered StreamGroups. +func (e *Engine) Channels() []string { + var channels []string + for _, g := range e.groups { + if sg, ok := g.(StreamGroup); ok { + channels = append(channels, sg.Channels()...) + } + } + return channels +} +``` + +**Step 4: Run go mod tidy to pick up gorilla/websocket** + +```bash +cd /Users/snider/Code/go-api +go mod tidy +``` + +**Step 5: Run tests to verify they pass** + +```bash +cd /Users/snider/Code/go-api +go test ./... -v -count=1 +``` + +Expected: All tests PASS. + +**Step 6: Commit** + +```bash +cd /Users/snider/Code/go-api +git add websocket.go websocket_test.go options.go api.go go.mod go.sum +git commit -m "feat: add WebSocket endpoint and channel listing from StreamGroups" +``` + +--- + +### Task 7: Swagger/OpenAPI Integration + +**Files:** +- Create: `/Users/snider/Code/go-api/swagger.go` +- Create: `/Users/snider/Code/go-api/swagger_test.go` +- Modify: `/Users/snider/Code/go-api/options.go` — add WithSwagger +- Modify: `/Users/snider/Code/go-api/api.go` — mount swagger routes + +**Step 1: Write the failing test** + +Create `swagger_test.go`: +```go +package api_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + api "forge.lthn.ai/core/go-api" + "github.com/gin-gonic/gin" +) + +func TestSwaggerEndpoint_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + engine, _ := api.New(api.WithSwagger("Core API", "REST API for the Lethean ecosystem", "0.1.0")) + engine.Register(&stubGroup{}) + handler := engine.Handler() + + // Swagger JSON endpoint + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/swagger/doc.json", nil) + handler.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200 for swagger doc.json, got %d", w.Code) + } + + body := w.Body.String() + if len(body) == 0 { + t.Fatal("expected non-empty swagger doc") + } +} + +func TestSwaggerDisabledByDefault_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + engine, _ := api.New() + handler := engine.Handler() + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/swagger/doc.json", nil) + handler.ServeHTTP(w, req) + + if w.Code != 404 { + t.Fatalf("expected 404 when swagger disabled, got %d", w.Code) + } +} +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd /Users/snider/Code/go-api +go test ./... -v +``` + +Expected: Compilation errors. + +**Step 3: Implement swagger.go + option** + +Create `swagger.go`: +```go +package api + +import ( + "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" + "github.com/swaggo/swag" +) + +// swaggerSpec holds a minimal OpenAPI spec for runtime serving. +type swaggerSpec struct { + title string + description string + version string +} + +func (s *swaggerSpec) ReadDoc() string { + // Minimal OpenAPI 3.0 document — swaggo generates the full one at build time. + // This serves as the runtime fallback and base template. + return `{ + "swagger": "2.0", + "info": { + "title": "` + s.title + `", + "description": "` + s.description + `", + "version": "` + s.version + `" + }, + "basePath": "/", + "paths": {} +}` +} + +// registerSwagger mounts the swagger UI and doc.json endpoint. +func registerSwagger(g *gin.Engine, title, description, version string) { + spec := &swaggerSpec{title: title, description: description, version: version} + swag.Register(swag.Name, spec) + + g.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) +} +``` + +Add to `options.go`: +```go +// WithSwagger enables the Swagger UI at /swagger/. +func WithSwagger(title, description, version string) Option { + return func(e *Engine) error { + e.swaggerTitle = title + e.swaggerDesc = description + e.swaggerVersion = version + e.swaggerEnabled = true + return nil + } +} +``` + +Add fields to `Engine` struct: +```go +swaggerEnabled bool +swaggerTitle string +swaggerDesc string +swaggerVersion string +``` + +Add to `build()` after WebSocket: +```go +// Swagger UI +if e.swaggerEnabled { + registerSwagger(e.gin, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion) +} +``` + +**Step 4: Run go mod tidy** + +```bash +cd /Users/snider/Code/go-api +go get github.com/swaggo/gin-swagger github.com/swaggo/files github.com/swaggo/swag +go mod tidy +``` + +**Step 5: Run tests to verify they pass** + +```bash +cd /Users/snider/Code/go-api +go test ./... -v -count=1 +``` + +Expected: All tests PASS. + +**Step 6: Commit** + +```bash +cd /Users/snider/Code/go-api +git add swagger.go swagger_test.go options.go api.go go.mod go.sum +git commit -m "feat: add Swagger UI endpoint with runtime spec serving" +``` + +--- + +### Task 8: CLAUDE.md + README.md + +**Files:** +- Create: `/Users/snider/Code/go-api/CLAUDE.md` +- Create: `/Users/snider/Code/go-api/README.md` + +**Step 1: Write CLAUDE.md** + +```markdown +# CLAUDE.md + +This file provides guidance to Claude Code when working with the go-api repository. + +## Project Overview + +**go-api** is the REST framework for the Lethean Go ecosystem. It provides a Gin-based HTTP engine with middleware, response envelopes, WebSocket integration, and OpenAPI generation. Subsystems implement the `RouteGroup` interface to register their own endpoints. + +- **Module path**: `forge.lthn.ai/core/go-api` +- **Language**: Go 1.25 +- **Licence**: EUPL-1.2 + +## Build & Test Commands + +```bash +go test ./... # Run all tests +go test -run TestName ./... # Run a single test +go test -v -race ./... # Verbose with race detector +go build ./... # Build (library — no main package) +go vet ./... # Vet +``` + +## Coding Standards + +- **UK English** in comments and user-facing strings (colour, organisation, unauthorised) +- **Conventional commits**: `type(scope): description` +- **Co-Author**: `Co-Authored-By: Virgil ` +- **Error handling**: Return wrapped errors with context, never panic +- **Test naming**: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (panics/edge cases) +- **Licence**: EUPL-1.2 +``` + +**Step 2: Write README.md** + +Brief README with quick start and links to design doc. + +**Step 3: Commit** + +```bash +cd /Users/snider/Code/go-api +git add CLAUDE.md README.md +git commit -m "docs: add CLAUDE.md and README.md" +``` + +--- + +### Task 9: Create Forge Repo + Push + +**Step 1: Create repo on Forge** + +```bash +curl -s -X POST "https://forge.lthn.ai/api/v1/orgs/core/repos" \ + -H "Authorization: token 375068d101922dd1cf269e8b8cb77a0f99d1b486" \ + -H "Content-Type: application/json" \ + -d '{"name":"go-api","description":"REST framework + OpenAPI SDK generation for the Lethean Go ecosystem","default_branch":"main","auto_init":false,"license":"EUPL-1.2"}' +``` + +**Step 2: Add remote and push** + +```bash +cd /Users/snider/Code/go-api +git remote add forge ssh://git@forge.lthn.ai:2223/core/go-api.git +git branch -M main +git push -u forge main +``` + +**Step 3: Verify on Forge** + +```bash +curl -s "https://forge.lthn.ai/api/v1/repos/core/go-api" \ + -H "Authorization: token 375068d101922dd1cf269e8b8cb77a0f99d1b486" | jq .name +``` + +Expected: `"go-api"` + +--- + +### Task 10: Integration Test — First Subsystem (go-ml/api) + +This task validates the framework by building the first real subsystem integration. It lives in go-ml, not go-api. + +**Files:** +- Create: `/Users/snider/Code/go-ml/api/routes.go` +- Create: `/Users/snider/Code/go-ml/api/routes_test.go` + +**Step 1: Write the failing test in go-ml** + +Create `api/routes_test.go` in go-ml that: +1. Creates a `Routes` with a mock `ml.Service` +2. Registers it on an `api.Engine` +3. Sends `POST /v1/ml/backends` and asserts a 200 response with the response envelope + +**Step 2: Implement api/routes.go** + +Implement `Routes` struct that wraps `*ml.Service` and exposes: +- `POST /v1/ml/generate` +- `POST /v1/ml/score` +- `GET /v1/ml/backends` +- `GET /v1/ml/status` + +Each handler uses `c.ShouldBindJSON()` for input and `api.OK()` / `api.Fail()` for responses. + +**Step 3: Run tests** + +```bash +cd /Users/snider/Code/go-ml +go test ./api/... -v +``` + +**Step 4: Commit in go-ml** + +```bash +cd /Users/snider/Code/go-ml +git add api/ +git commit -m "feat(api): add REST route group for ML endpoints via go-api" +``` + +--- + +## Dependency Summary + +``` +Task 1 (scaffold) → Task 2 (response) → Task 3 (group) → Task 4 (engine) + → Task 5 (middleware) → Task 6 (websocket) → Task 7 (swagger) + → Task 8 (docs) → Task 9 (forge) → Task 10 (integration) +``` + +All tasks are sequential — each builds on the previous. + +## Estimated Timeline + +- Tasks 1-7: Core go-api package (~820 LOC) +- Task 8: Documentation +- Task 9: Forge deployment +- Task 10: First subsystem integration proof