diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/go-api.iml b/.idea/go-api.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/go-api.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml new file mode 100644 index 0000000..6725e8b --- /dev/null +++ b/.idea/material_theme_project_new.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..90dee70 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + {} + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..ddba8d8 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/authentik.go b/authentik.go index 7344bca..fa08217 100644 --- a/authentik.go +++ b/authentik.go @@ -5,6 +5,7 @@ package api import ( "context" "net/http" + "slices" "strings" "sync" @@ -43,12 +44,7 @@ type AuthentikUser struct { // HasGroup reports whether the user belongs to the named group. func (u *AuthentikUser) HasGroup(group string) bool { - for _, g := range u.Groups { - if g == group { - return true - } - } - return false + return slices.Contains(u.Groups, group) } // authentikUserKey is the Gin context key used to store the authenticated user. @@ -112,10 +108,10 @@ func validateJWT(ctx context.Context, cfg AuthentikConfig, rawToken string) (*Au var claims struct { PreferredUsername string `json:"preferred_username"` - Email string `json:"email"` - Name string `json:"name"` - Sub string `json:"sub"` - Groups []string `json:"groups"` + Email string `json:"email"` + Name string `json:"name"` + Sub string `json:"sub"` + Groups []string `json:"groups"` } if err := idToken.Claims(&claims); err != nil { return nil, err diff --git a/cache.go b/cache.go index 54b5511..d032346 100644 --- a/cache.go +++ b/cache.go @@ -4,6 +4,7 @@ package api import ( "bytes" + "maps" "net/http" "sync" "time" @@ -113,9 +114,7 @@ func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc { status := cw.ResponseWriter.Status() if status >= 200 && status < 300 { headers := make(http.Header) - for k, vals := range cw.ResponseWriter.Header() { - headers[k] = vals - } + maps.Copy(headers, cw.ResponseWriter.Header()) store.set(key, &cacheEntry{ status: status, headers: headers, diff --git a/docs/plans/2026-02-21-openapi-spec-gen.md b/docs/plans/2026-02-21-openapi-spec-gen.md new file mode 100644 index 0000000..17574de --- /dev/null +++ b/docs/plans/2026-02-21-openapi-spec-gen.md @@ -0,0 +1,366 @@ +# 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 + +- `swagger.go` — Refactor from empty hardcoded spec to SpecBuilder +- `group.go` — Add DescribableGroup interface +- `api.go` — Pass groups to registerSwagger, Engine struct changes +- `response.go` — Response[T], Error, Meta structs (read-only reference for envelope schema) +- `options.go` — WithSwagger already exists, no changes needed +- `go-ai/mcp/mcp.go` — Tool registration with typed Input/Output structs +- `go-ai/mcp/subsystem.go` — Subsystem interface + +## Wave 1: OpenAPI Runtime Spec (go-api only) + +### Task 1: DescribableGroup interface + RouteDescription + +**Files:** +- Modify: `group.go` +- Test: `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. + +```go +// 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: `bridge.go` +- Create: `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. + +```go +// 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: `openapi.go` +- Create: `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. + +```go +// 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: `swagger.go` +- Modify: `api.go` (pass groups to registerSwagger) +- Modify: `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. + +```go +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()`: +```go +// 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: `export.go` +- Create: `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. + +```go +// 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: `go-ai/mcp/mcp.go` — Add ToolRecord and registry +- Create: `go-ai/mcp/registry.go` — ToolRecord type and helpers +- Create: `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. + +```go +// 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: `go-ai/mcp/bridge.go` +- Create: `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()`. + +```go +// 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: `codegen.go` +- Create: `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. + +```go +// 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: `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**: `go test ./...` — all pass +2. **go-ai tests**: `cd ../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. diff --git a/docs/plans/valiant-hatching-wave.md b/docs/plans/valiant-hatching-wave.md new file mode 100644 index 0000000..dfe7b74 --- /dev/null +++ b/docs/plans/valiant-hatching-wave.md @@ -0,0 +1,366 @@ +# 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. + +```go +// 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. + +```go +// 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. + +```go +// 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. + +```go +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()`: +```go +// 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. + +```go +// 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. + +```go +// 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()`. + +```go +// 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. + +```go +// 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. diff --git a/options.go b/options.go index 820b6a3..bdf3f66 100644 --- a/options.go +++ b/options.go @@ -6,6 +6,7 @@ import ( "compress/gzip" "log/slog" "net/http" + "slices" "time" "github.com/99designs/gqlgen/graphql" @@ -63,11 +64,8 @@ func WithCORS(allowOrigins ...string) Option { MaxAge: 12 * time.Hour, } - for _, o := range allowOrigins { - if o == "*" { - cfg.AllowAllOrigins = true - break - } + if slices.Contains(allowOrigins, "*") { + cfg.AllowAllOrigins = true } if !cfg.AllowAllOrigins { cfg.AllowOrigins = allowOrigins @@ -156,12 +154,12 @@ func WithExpvar() Option { func WithSecure() Option { return func(e *Engine) { e.middlewares = append(e.middlewares, secure.New(secure.Config{ - STSSeconds: 31536000, - STSIncludeSubdomains: true, - FrameDeny: true, - ContentTypeNosniff: true, - ReferrerPolicy: "strict-origin-when-cross-origin", - IsDevelopment: false, + STSSeconds: 31536000, + STSIncludeSubdomains: true, + FrameDeny: true, + ContentTypeNosniff: true, + ReferrerPolicy: "strict-origin-when-cross-origin", + IsDevelopment: false, })) } } diff --git a/sse_test.go b/sse_test.go index 3cba62a..4618104 100644 --- a/sse_test.go +++ b/sse_test.go @@ -81,11 +81,11 @@ func TestWithSSE_Good_ReceivesPublishedEvent(t *testing.T) { defer close(done) for scanner.Scan() { line := scanner.Text() - if strings.HasPrefix(line, "event: ") { - eventLine = strings.TrimPrefix(line, "event: ") + if after, ok := strings.CutPrefix(line, "event: "); ok { + eventLine = after } - if strings.HasPrefix(line, "data: ") { - dataLine = strings.TrimPrefix(line, "data: ") + if after, ok := strings.CutPrefix(line, "data: "); ok { + dataLine = after return } } @@ -144,8 +144,8 @@ func TestWithSSE_Good_ChannelFiltering(t *testing.T) { defer close(done) for scanner.Scan() { line := scanner.Text() - if strings.HasPrefix(line, "event: ") { - eventLine = strings.TrimPrefix(line, "event: ") + if after, ok := strings.CutPrefix(line, "event: "); ok { + eventLine = after // Read past the data and blank line. scanner.Scan() // data line return @@ -246,8 +246,8 @@ func TestWithSSE_Good_MultipleClients(t *testing.T) { go func() { for scanner.Scan() { line := scanner.Text() - if strings.HasPrefix(line, "event: ") { - done <- strings.TrimPrefix(line, "event: ") + if after, ok := strings.CutPrefix(line, "event: "); ok { + done <- after return } }