cli/docs/plans/completed/2026-02-20-go-api-plan-original.md
Snider aa83cf77cc
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 19s
docs: add ecosystem overview and historical design plans
Moved from core/go during docs cleanup — these belong with the CLI
that orchestrates the ecosystem, not the DI framework.

- ecosystem.md: full module inventory and dependency graph
- 3 active plans (authentik-traefik, core-help design/plan)
- 13 completed design plans (MCP, go-api, cli-meta, go-forge, etc.)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-06 14:24:38 +00:00

34 KiB

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

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:

go mod tidy

Step 2: Create LICENCE file

Copy the EUPL-1.2 licence text. Use the same LICENCE file as other ecosystem repos:

cp /Users/snider/Code/go-ai/LICENCE /Users/snider/Code/go-api/LICENCE

Step 3: Commit scaffold

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:

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

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:

// 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

cd /Users/snider/Code/go-api
go test ./... -v

Expected: All 6 tests PASS.

Step 5: Commit

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:

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

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:

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

cd /Users/snider/Code/go-api
go test ./... -v

Expected: All tests PASS (previous 6 + new 3).

Step 5: Commit

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:

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

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:

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:

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

cd /Users/snider/Code/go-api
go test ./... -v -count=1

Expected: All tests PASS.

Step 6: Commit

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:

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

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:

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:

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():

// 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

cd /Users/snider/Code/go-api
go test ./... -v -count=1

Expected: All tests PASS.

Step 6: Commit

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:

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

cd /Users/snider/Code/go-api
go test ./... -v

Expected: Compilation errors.

Step 3: Implement websocket.go + option + engine changes

Create websocket.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:

// 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:

wsHandler http.Handler

Add to build() after mounting route groups:

// WebSocket endpoint
if e.wsHandler != nil {
    e.gin.GET("/ws", wrapWSHandler(e.wsHandler))
}

Add Channels() method to Engine:

// 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

cd /Users/snider/Code/go-api
go mod tidy

Step 5: Run tests to verify they pass

cd /Users/snider/Code/go-api
go test ./... -v -count=1

Expected: All tests PASS.

Step 6: Commit

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:

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

cd /Users/snider/Code/go-api
go test ./... -v

Expected: Compilation errors.

Step 3: Implement swagger.go + option

Create swagger.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:

// 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:

swaggerEnabled bool
swaggerTitle   string
swaggerDesc    string
swaggerVersion string

Add to build() after WebSocket:

// Swagger UI
if e.swaggerEnabled {
    registerSwagger(e.gin, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion)
}

Step 4: Run go mod tidy

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

cd /Users/snider/Code/go-api
go test ./... -v -count=1

Expected: All tests PASS.

Step 6: Commit

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

# 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 <virgil@lethean.io>
  • 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

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

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

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

cd /Users/snider/Code/go-ml
go test ./api/... -v

Step 4: Commit in go-ml

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