1504 lines
34 KiB
Markdown
1504 lines
34 KiB
Markdown
|
|
# 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 <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**
|
||
|
|
|
||
|
|
```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
|