go-api/docs/plans/valiant-hatching-wave.md
Snider 1c19499bf0 refactor: apply go fix modernizers for Go 1.26
Automated fixes: interface{} → any, range-over-int, t.Context(),
wg.Go(), strings.SplitSeq, strings.Builder, slices.Contains,
maps helpers, min/max builtins.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-22 21:00:16 +00:00

16 KiB

go-api Phase 3: OpenAPI Spec Generation + SDK Codegen

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Turn MCP tool definitions into REST endpoints with auto-generated OpenAPI 3.1 specs and multi-language SDK codegen.

Architecture: Runtime OpenAPI generation (NOT swaggo annotations) — routes are dynamic via RouteGroup, Response[T] generics break swaggo, and MCP tools already carry JSON Schema at runtime. A ToolBridge converts tool descriptors into RouteGroup + OpenAPI metadata. A SpecBuilder constructs the full OpenAPI 3.1 spec from all registered groups. SDK codegen wraps openapi-generator-cli.

Tech Stack: go-api (Gin), OpenAPI 3.1, openapi-generator-cli, go-ai MCP SDK


Context

Phase 1+2 complete (143 tests, 21 With*() options). The "missing link" is: MCP tools are only accessible via JSON-RPC. This phase creates a REST bridge so any HTTP client (or generated SDK) can call the same services. The pipeline: MCP tool → REST endpoint → OpenAPI spec → SDK codegen.

Current swagger.go serves an empty spec (paths: {}). swaggo annotations don't exist anywhere and can't work with dynamic RouteGroups or generics. The solution is runtime spec generation.

Critical Files

  • /Users/snider/Code/go-api/swagger.go — Refactor from empty hardcoded spec to SpecBuilder
  • /Users/snider/Code/go-api/group.go — Add DescribableGroup interface
  • /Users/snider/Code/go-api/api.go — Pass groups to registerSwagger, Engine struct changes
  • /Users/snider/Code/go-api/response.go — Response[T], Error, Meta structs (read-only reference for envelope schema)
  • /Users/snider/Code/go-api/options.go — WithSwagger already exists, no changes needed
  • /Users/snider/Code/go-ai/mcp/mcp.go — Tool registration with typed Input/Output structs
  • /Users/snider/Code/go-ai/mcp/subsystem.go — Subsystem interface

Wave 1: OpenAPI Runtime Spec (go-api only)

Task 1: DescribableGroup interface + RouteDescription

Files:

  • Modify: /Users/snider/Code/go-api/group.go
  • Test: /Users/snider/Code/go-api/group_test.go (existing — add new tests)

Add a DescribableGroup interface that RouteGroups can optionally implement to provide OpenAPI metadata. This is the opt-in contract for spec generation.

// DescribableGroup extends RouteGroup with OpenAPI metadata.
// RouteGroups that implement this will have their endpoints
// included in the generated OpenAPI specification.
type DescribableGroup interface {
    RouteGroup
    Describe() []RouteDescription
}

// RouteDescription describes a single endpoint for OpenAPI generation.
type RouteDescription struct {
    Method      string         // HTTP method: GET, POST, PUT, DELETE, PATCH
    Path        string         // Path relative to BasePath, e.g. "/generate"
    Summary     string         // Short summary
    Description string         // Long description
    Tags        []string       // OpenAPI tags for grouping
    RequestBody map[string]any // JSON Schema for request body (nil for GET)
    Response    map[string]any // JSON Schema for success response data
}

Tests (5):

  • TestDescribableGroup_Good_ImplementsRouteGroup — A struct implementing both interfaces compiles and works
  • TestDescribableGroup_Good_DescribeReturnsRoutes — Verify Describe() returns correct RouteDescription list
  • TestDescribableGroup_Good_EmptyDescribe — Empty Describe() is valid (no endpoints documented)
  • TestDescribableGroup_Good_MultipleVerbs — GET, POST, DELETE on same base path
  • TestDescribableGroup_Bad_NilSchemas — RequestBody and Response can be nil

Task 2: ToolBridge — tool descriptors to REST endpoints

Files:

  • Create: /Users/snider/Code/go-api/bridge.go
  • Create: /Users/snider/Code/go-api/bridge_test.go

The ToolBridge converts tool descriptors into a RouteGroup that also implements DescribableGroup. It creates POST /{tool_name} endpoints for each registered tool.

// ToolDescriptor describes a tool that can be exposed as a REST endpoint.
type ToolDescriptor struct {
    Name        string         // Tool name, e.g. "file_read" (becomes POST path)
    Description string         // Human-readable description
    Group       string         // OpenAPI tag group, e.g. "files"
    InputSchema map[string]any // JSON Schema for request body
    OutputSchema map[string]any // JSON Schema for response data (optional)
}

// ToolBridge converts tool descriptors into REST endpoints and OpenAPI paths.
// It implements both RouteGroup and DescribableGroup.
type ToolBridge struct {
    basePath string
    tools    []boundTool
}

type boundTool struct {
    descriptor ToolDescriptor
    handler    gin.HandlerFunc
}

func NewToolBridge(basePath string) *ToolBridge

// Add registers a tool with its HTTP handler.
func (b *ToolBridge) Add(desc ToolDescriptor, handler gin.HandlerFunc)

// RouteGroup implementation
func (b *ToolBridge) Name() string
func (b *ToolBridge) BasePath() string
func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup)

// DescribableGroup implementation
func (b *ToolBridge) Describe() []RouteDescription

// Tools returns registered tool descriptors.
func (b *ToolBridge) Tools() []ToolDescriptor

Tests (6):

  • TestToolBridge_Good_RegisterAndServe — Add 2 tools, verify POST endpoints respond
  • TestToolBridge_Good_BasePath — Custom base path /api/v1/tools works
  • TestToolBridge_Good_Describe — Describe() returns correct RouteDescription for each tool
  • TestToolBridge_Good_ToolsAccessor — Tools() returns all registered descriptors
  • TestToolBridge_Bad_EmptyBridge — Bridge with no tools is valid, RegisterRoutes is no-op
  • TestToolBridge_Good_IntegrationWithEngine — Register bridge with Engine, verify routes accessible via Handler()

Task 3: SpecBuilder — OpenAPI 3.1 spec generation

Files:

  • Create: /Users/snider/Code/go-api/openapi.go
  • Create: /Users/snider/Code/go-api/openapi_test.go

Builds a complete OpenAPI 3.1 JSON document from registered RouteGroups. Groups implementing DescribableGroup contribute their endpoint metadata. All responses are wrapped in the Response[T] envelope.

// SpecBuilder constructs an OpenAPI 3.1 spec from registered RouteGroups.
type SpecBuilder struct {
    Title       string
    Description string
    Version     string
}

// Build generates the complete OpenAPI 3.1 JSON spec.
// Groups implementing DescribableGroup contribute endpoint documentation.
// Other groups are listed as tags only.
func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error)

Key behaviours:

  • Always includes GET /health (built-in)
  • For each DescribableGroup, generates path items from Describe()
  • Wraps all response schemas in the Response[T] envelope (success/data/error/meta)
  • Uses OpenAPI 3.1 (compatible with JSON Schema 2020-12 from MCP SDK)
  • Tags derived from RouteGroup.Name()
  • Output is valid, parseable OpenAPI 3.1 JSON

Tests (6):

  • TestSpecBuilder_Good_EmptyGroups — Produces valid spec with just /health
  • TestSpecBuilder_Good_WithDescribableGroup — Paths populated from Describe()
  • TestSpecBuilder_Good_EnvelopeWrapping — Response schema wraps data in Response[T] envelope
  • TestSpecBuilder_Good_NonDescribableGroup — Non-describable groups appear as tags only
  • TestSpecBuilder_Good_ToolBridgeIntegration — ToolBridge + SpecBuilder produces correct tool endpoints
  • TestSpecBuilder_Bad_InfoFields — Title, description, version appear in output

Task 4: Refactor swagger.go to use SpecBuilder

Files:

  • Modify: /Users/snider/Code/go-api/swagger.go
  • Modify: /Users/snider/Code/go-api/api.go (pass groups to registerSwagger)
  • Modify: /Users/snider/Code/go-api/swagger_test.go (update tests)

Replace the hardcoded empty JSON spec with SpecBuilder-generated spec. The swaggerSpec struct now wraps SpecBuilder and caches the result via sync.Once. registerSwagger receives the engine's groups.

type swaggerSpec struct {
    builder *SpecBuilder
    groups  []RouteGroup
    once    sync.Once
    doc     string
}

func (s *swaggerSpec) ReadDoc() string {
    s.once.Do(func() {
        data, _ := s.builder.Build(s.groups)
        s.doc = string(data)
    })
    return s.doc
}

func registerSwagger(g *gin.Engine, title, description, version string, groups []RouteGroup)

In api.go, change build():

// Mount Swagger UI if enabled.
if e.swaggerEnabled {
    registerSwagger(r, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, e.groups)
}

Tests (5):

  • TestSwagger_Good_SpecNotEmpty — GET /swagger/doc.json returns spec with /health path
  • TestSwagger_Good_WithToolBridge — Register ToolBridge, verify tool endpoints appear in spec
  • TestSwagger_Good_CachesSpec — Multiple calls return same cached spec
  • TestSwagger_Good_InfoFromOptions — Title/desc/version from WithSwagger() appear in spec
  • TestSwagger_Good_ValidOpenAPI — Output parses as valid JSON with correct openapi version field

Task 5: SpecExporter — export spec to file/stdout

Files:

  • Create: /Users/snider/Code/go-api/export.go
  • Create: /Users/snider/Code/go-api/export_test.go

A helper that exports the generated spec to a file or io.Writer. Supports JSON and YAML output. This is what core api spec will call.

// ExportSpec generates the OpenAPI spec and writes it to w.
// Format can be "json" or "yaml".
func ExportSpec(w io.Writer, format string, builder *SpecBuilder, groups []RouteGroup) error

// ExportSpecToFile writes the spec to the given path.
func ExportSpecToFile(path, format string, builder *SpecBuilder, groups []RouteGroup) error

Tests (5):

  • TestExportSpec_Good_JSON — Writes valid JSON to buffer
  • TestExportSpec_Good_YAML — Writes valid YAML to buffer
  • TestExportSpec_Bad_InvalidFormat — Returns error for unknown format
  • TestExportSpecToFile_Good_CreatesFile — Writes file to temp dir
  • TestExportSpec_Good_WithToolBridge — Full pipeline: tools → bridge → spec → export

Wave 2: MCP-to-REST Bridge (go-ai)

Task 6: Tool registry in go-ai Service

Files:

  • Modify: /Users/snider/Code/go-ai/mcp/mcp.go — Add ToolRecord and registry
  • Create: /Users/snider/Code/go-ai/mcp/registry.go — ToolRecord type and helpers
  • Create: /Users/snider/Code/go-ai/mcp/registry_test.go

The MCP SDK's mcp.Server.tools is unexported. We maintain a parallel registry in Service that records tool metadata (name, description, input/output Go types) as tools are registered. This becomes the source of truth for both MCP and REST.

// ToolRecord captures metadata about a registered MCP tool.
type ToolRecord struct {
    Name        string
    Description string
    Group       string         // Subsystem group name
    InputSchema map[string]any // JSON Schema from Go struct tags
    OutputSchema map[string]any
    Handler     any            // The original handler func for type assertion
}

// Tools returns all recorded tool metadata.
func (s *Service) Tools() []ToolRecord

Modify registerTools and subsystem registration to also record tools. Use jsonschema-go (already a transitive dep from MCP SDK) to extract schemas from the typed Input/Output parameters.

Tests (5):

  • TestToolRegistry_Good_RecordsTools — After New(), Tools() returns all registered tools
  • TestToolRegistry_Good_IncludesSubsystems — Subsystem tools included in registry
  • TestToolRegistry_Good_SchemaExtraction — InputSchema contains correct properties from struct tags
  • TestToolRegistry_Good_ToolCount — Count matches expected (10 built-in + subsystem tools)
  • TestToolRegistry_Bad_EmptyService — Service with no subsystems has only built-in tools

Task 7: BridgeToAPI — MCP tools to go-api ToolBridge

Files:

  • Create: /Users/snider/Code/go-ai/mcp/bridge.go
  • Create: /Users/snider/Code/go-ai/mcp/bridge_test.go

Converts MCP tool records into go-api ToolBridge entries. Each tool becomes a POST endpoint. The handler unmarshals JSON from request body, calls the business logic, and wraps the response in api.OK()/api.Fail().

// BridgeToAPI populates a go-api ToolBridge from recorded MCP tools.
// Each tool becomes a POST endpoint at /{tool_name}.
func BridgeToAPI(svc *Service, bridge *api.ToolBridge) error

Tests (5):

  • TestBridgeToAPI_Good_FileRead — Bridge file_read, POST JSON body, get Response[ReadFileOutput]
  • TestBridgeToAPI_Good_AllTools — All tools from registry appear in bridge
  • TestBridgeToAPI_Good_DescribableGroup — Bridge implements DescribableGroup, Describe() returns correct metadata
  • TestBridgeToAPI_Bad_InvalidJSON — POST malformed JSON returns 400 with error envelope
  • TestBridgeToAPI_Good_EndToEnd — New service → BridgeToAPI → register with Engine → GET /swagger/doc.json shows all tools

Wave 3: SDK Codegen + CLI

Task 8: Codegen wrapper

Files:

  • Create: /Users/snider/Code/go-api/codegen.go
  • Create: /Users/snider/Code/go-api/codegen_test.go

Wraps openapi-generator-cli for multi-language SDK generation. Checks if the tool is available and provides clear installation instructions if missing.

// SDKGenerator wraps openapi-generator-cli for SDK generation.
type SDKGenerator struct {
    SpecPath    string
    OutputDir   string
    PackageName string
}

// Generate creates an SDK for the given language.
// Supported: "go", "typescript-fetch", "python", "java", "csharp"
func (g *SDKGenerator) Generate(ctx context.Context, language string) error

// Available checks if openapi-generator-cli is installed.
func (g *SDKGenerator) Available() bool

// SupportedLanguages returns the list of supported SDK languages.
func SupportedLanguages() []string

Tests (5):

  • TestSDKGenerator_Good_CommandConstruction — Verify correct CLI arguments without executing
  • TestSDKGenerator_Good_SupportedLanguages — Returns expected language list
  • TestSDKGenerator_Bad_UnsupportedLanguage — Returns error for "brainfuck"
  • TestSDKGenerator_Bad_MissingSpec — Returns error when spec file doesn't exist
  • TestSDKGenerator_Good_OutputDirCreated — Creates output directory if missing

Task 9: CLI commands — core api spec and core api sdk

Files:

  • Create: /Users/snider/Code/host-uk/core/cmd/core-cli/api.go (or appropriate location in CLI tree)
  • Create test file alongside

New api command group in the core CLI:

core api spec                    # Export OpenAPI spec JSON to stdout
core api spec --output spec.json # Write to file
core api spec --format yaml      # YAML format

core api sdk --lang go           # Generate Go SDK
core api sdk --lang typescript-fetch,python --output ./sdk/
core api sdk --spec spec.json --lang go --output ./sdk/go

This depends on understanding the CLI registration pattern. The commands create an MCP service, bridge tools, build spec, and either export or generate SDKs.

Tests (4):

  • TestAPISpecCmd_Good_JSON — Outputs valid JSON to stdout
  • TestAPISpecCmd_Good_YAML — Outputs valid YAML with --format yaml
  • TestAPISDKCmd_Bad_NoLang — Returns error without --lang flag
  • TestAPISDKCmd_Good_ValidatesLanguage — Rejects unsupported language with helpful message

Verification

After all tasks are complete:

  1. Unit tests: cd /Users/snider/Code/go-api && go test ./... — all pass
  2. go-ai tests: cd /Users/snider/Code/go-ai && go test ./... — all pass
  3. Spec roundtrip: Register a ToolBridge with sample tools, build Engine with WithSwagger, GET /swagger/doc.json → verify paths are populated, not empty
  4. Export: ExportSpec to temp file in JSON and YAML, verify both parse correctly
  5. SDK smoke test (if openapi-generator-cli installed): Generate Go client from exported spec, verify it compiles

Dependency Sequencing

Task 1 (DescribableGroup) ← Task 2 (ToolBridge) ← Task 3 (SpecBuilder)
Task 3 ← Task 4 (swagger refactor)
Task 3 ← Task 5 (ExportSpec)
Task 2 ← Task 6 (Tool registry) ← Task 7 (BridgeToAPI)
Task 5 ← Task 8 (Codegen)
Task 5 + Task 7 ← Task 9 (CLI commands)

Wave 1 (Tasks 1-5) is self-contained in go-api. Wave 2 (Tasks 6-7) bridges go-ai. Wave 3 (Tasks 8-9) adds codegen and CLI.