From 8796c405c2a0bf9762c34a1b0995d8a6cd5f65e3 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 17:51:16 +0100 Subject: [PATCH] =?UTF-8?q?feat(api):=20framework=20routes=20=E2=80=94=20G?= =?UTF-8?q?ET=20/v1/tools=20+=20/v1/openapi.json=20+=20chat=20path=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the RFC framework routes listed in RFC.endpoints.md that were missing from the Go engine: - GET {basePath}/ on ToolBridge — returns the registered tool catalogue (RFC.endpoints.md — "GET /v1/tools List available tools"). The listing uses the standard OK envelope so clients can enumerate tools without reading the OpenAPI document. - WithOpenAPISpec / WithOpenAPISpecPath options + GET /v1/openapi.json default mount (RFC.endpoints.md — "GET /v1/openapi.json Generated OpenAPI spec"). The spec is generated once and served application/json so SDK generators can fetch it without loading the Swagger UI bundle. - OpenAPI path items for /v1/chat/completions and /v1/openapi.json so SDK generators can bind to them directly instead of relying solely on the x-chat-completions-path / x-openapi-spec-path vendor extensions. Side effects: - TransportConfig surfaces the new OpenAPISpecEnabled/OpenAPISpecPath fields so callers can discover the endpoint without rebuilding the engine. - SpecBuilder gains OpenAPISpecEnabled / OpenAPISpecPath fields and emits the matching x-openapi-spec-* extensions. - core api spec CLI accepts --openapi-spec, --openapi-spec-path, --chat-completions, --chat-completions-path flags so generated specs describe the endpoints ahead of runtime activation. - ToolBridge.Describe / DescribeIter now emit the GET listing as the first RouteDescription; existing tests were updated to match. Co-Authored-By: Virgil --- api.go | 10 ++ bridge.go | 70 ++++++++- bridge_test.go | 186 +++++++++++++++++++---- cmd/api/cmd_spec.go | 4 + cmd/api/cmd_spec_test.go | 82 ++++++++++ cmd/api/spec_builder.go | 10 ++ modernization_test.go | 21 ++- openapi.go | 290 ++++++++++++++++++++++++++++++++++++ openapi_test.go | 184 +++++++++++++++++++++++ options.go | 39 +++++ spec_builder_helper.go | 2 + spec_builder_helper_test.go | 60 ++++++++ swagger.go | 51 +++++++ swagger_test.go | 161 ++++++++++++++++++++ transport.go | 6 + 15 files changed, 1140 insertions(+), 36 deletions(-) diff --git a/api.go b/api.go index e26c271..43081fe 100644 --- a/api.go +++ b/api.go @@ -69,6 +69,8 @@ type Engine struct { ssePath string graphql *graphqlConfig i18nConfig I18nConfig + openAPISpecEnabled bool + openAPISpecPath string } // New creates an Engine with the given options. @@ -288,6 +290,14 @@ func (e *Engine) build() *gin.Engine { registerSwagger(r, e, e.groups) } + // Mount the standalone OpenAPI JSON endpoint (RFC.endpoints.md — "GET + // /v1/openapi.json") when explicitly enabled. Unlike Swagger UI the spec + // document is served directly so ToolBridge consumers and SDK generators + // can fetch the latest description without loading the UI bundle. + if e.openAPISpecEnabled { + registerOpenAPISpec(r, e) + } + // Mount pprof profiling endpoints if enabled. if e.pprofEnabled { pprof.Register(r) diff --git a/bridge.go b/bridge.go index c5f6117..155adbb 100644 --- a/bridge.go +++ b/bridge.go @@ -95,32 +95,54 @@ func (b *ToolBridge) Name() string { return b.name } // path := bridge.BasePath() func (b *ToolBridge) BasePath() string { return b.basePath } -// RegisterRoutes mounts POST /{tool_name} for each registered tool. +// RegisterRoutes mounts GET / (tool listing) and POST /{tool_name} for each +// registered tool. The listing endpoint returns a JSON envelope containing the +// registered tool descriptors and mirrors RFC.endpoints.md §"ToolBridge" so +// clients can discover every tool exposed on the bridge in a single call. // // Example: // // bridge.RegisterRoutes(rg) +// // GET /{basePath}/ -> tool catalogue +// // POST /{basePath}/{name} -> dispatch tool func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("", b.listHandler()) + rg.GET("/", b.listHandler()) for _, t := range b.tools { rg.POST("/"+t.descriptor.Name, t.handler) } } -// Describe returns OpenAPI route descriptions for all registered tools. +// listHandler returns a Gin handler that serves the tool catalogue at the +// bridge's base path. The response envelope matches RFC.endpoints.md — an +// array of tool descriptors with their name, description, group, and the +// declared input/output JSON schemas. +// +// GET /v1/tools -> {"success": true, "data": [{"name": "ping", ...}]} +func (b *ToolBridge) listHandler() gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, OK(b.Tools())) + } +} + +// Describe returns OpenAPI route descriptions for all registered tools plus a +// GET entry describing the tool listing endpoint at the bridge's base path. // // Example: // // descs := bridge.Describe() func (b *ToolBridge) Describe() []RouteDescription { tools := b.snapshotTools() - descs := make([]RouteDescription, 0, len(tools)) + descs := make([]RouteDescription, 0, len(tools)+1) + descs = append(descs, describeToolList(b.name)) for _, tool := range tools { descs = append(descs, describeTool(tool.descriptor, b.name)) } return descs } -// DescribeIter returns an iterator over OpenAPI route descriptions for all registered tools. +// DescribeIter returns an iterator over OpenAPI route descriptions for all +// registered tools plus a leading GET entry for the tool listing endpoint. // // Example: // @@ -129,9 +151,13 @@ func (b *ToolBridge) Describe() []RouteDescription { // } func (b *ToolBridge) DescribeIter() iter.Seq[RouteDescription] { tools := b.snapshotTools() + defaultTag := b.name return func(yield func(RouteDescription) bool) { + if !yield(describeToolList(defaultTag)) { + return + } for _, tool := range tools { - if !yield(describeTool(tool.descriptor, b.name)) { + if !yield(describeTool(tool.descriptor, defaultTag)) { return } } @@ -193,6 +219,40 @@ func describeTool(desc ToolDescriptor, defaultTag string) RouteDescription { } } +// describeToolList returns the RouteDescription for GET {basePath}/ — +// the tool catalogue listing documented in RFC.endpoints.md. +// +// rd := describeToolList("tools") +// // rd.Method == "GET" && rd.Path == "/" +func describeToolList(defaultTag string) RouteDescription { + tags := cleanTags([]string{defaultTag}) + if len(tags) == 0 { + tags = []string{"tools"} + } + return RouteDescription{ + Method: "GET", + Path: "/", + Summary: "List available tools", + Description: "Returns every tool registered on the bridge, including its declared input and output JSON schemas.", + Tags: tags, + StatusCode: http.StatusOK, + Response: map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + "description": map[string]any{"type": "string"}, + "group": map[string]any{"type": "string"}, + "inputSchema": map[string]any{"type": "object", "additionalProperties": true}, + "outputSchema": map[string]any{"type": "object", "additionalProperties": true}, + }, + "required": []string{"name"}, + }, + }, + } +} + // maxToolRequestBodyBytes is the maximum request body size accepted by the // tool bridge handler. Requests larger than this are rejected with 413. const maxToolRequestBodyBytes = 10 << 20 // 10 MiB diff --git a/bridge_test.go b/bridge_test.go index 3b26b1f..bc94d3d 100644 --- a/bridge_test.go +++ b/bridge_test.go @@ -118,39 +118,48 @@ func TestToolBridge_Good_Describe(t *testing.T) { var dg api.DescribableGroup = bridge descs := dg.Describe() - if len(descs) != 2 { - t.Fatalf("expected 2 descriptions, got %d", len(descs)) + // Describe() returns the GET tool listing entry followed by every tool. + if len(descs) != 3 { + t.Fatalf("expected 3 descriptions (listing + 2 tools), got %d", len(descs)) + } + + // Listing entry mirrors RFC.endpoints.md — GET /v1/tools returns the catalogue. + if descs[0].Method != "GET" { + t.Fatalf("expected descs[0].Method=%q, got %q", "GET", descs[0].Method) + } + if descs[0].Path != "/" { + t.Fatalf("expected descs[0].Path=%q, got %q", "/", descs[0].Path) } // First tool. - if descs[0].Method != "POST" { - t.Fatalf("expected descs[0].Method=%q, got %q", "POST", descs[0].Method) + if descs[1].Method != "POST" { + t.Fatalf("expected descs[1].Method=%q, got %q", "POST", descs[1].Method) } - if descs[0].Path != "/file_read" { - t.Fatalf("expected descs[0].Path=%q, got %q", "/file_read", descs[0].Path) + if descs[1].Path != "/file_read" { + t.Fatalf("expected descs[1].Path=%q, got %q", "/file_read", descs[1].Path) } - if descs[0].Summary != "Read a file from disk" { - t.Fatalf("expected descs[0].Summary=%q, got %q", "Read a file from disk", descs[0].Summary) + if descs[1].Summary != "Read a file from disk" { + t.Fatalf("expected descs[1].Summary=%q, got %q", "Read a file from disk", descs[1].Summary) } - if len(descs[0].Tags) != 1 || descs[0].Tags[0] != "files" { - t.Fatalf("expected descs[0].Tags=[files], got %v", descs[0].Tags) + if len(descs[1].Tags) != 1 || descs[1].Tags[0] != "files" { + t.Fatalf("expected descs[1].Tags=[files], got %v", descs[1].Tags) } - if descs[0].RequestBody == nil { - t.Fatal("expected descs[0].RequestBody to be non-nil") + if descs[1].RequestBody == nil { + t.Fatal("expected descs[1].RequestBody to be non-nil") } - if descs[0].Response == nil { - t.Fatal("expected descs[0].Response to be non-nil") + if descs[1].Response == nil { + t.Fatal("expected descs[1].Response to be non-nil") } // Second tool. - if descs[1].Path != "/metrics_query" { - t.Fatalf("expected descs[1].Path=%q, got %q", "/metrics_query", descs[1].Path) + if descs[2].Path != "/metrics_query" { + t.Fatalf("expected descs[2].Path=%q, got %q", "/metrics_query", descs[2].Path) } - if len(descs[1].Tags) != 1 || descs[1].Tags[0] != "metrics" { - t.Fatalf("expected descs[1].Tags=[metrics], got %v", descs[1].Tags) + if len(descs[2].Tags) != 1 || descs[2].Tags[0] != "metrics" { + t.Fatalf("expected descs[2].Tags=[metrics], got %v", descs[2].Tags) } - if descs[1].Response != nil { - t.Fatalf("expected descs[1].Response to be nil, got %v", descs[1].Response) + if descs[2].Response != nil { + t.Fatalf("expected descs[2].Response to be nil, got %v", descs[2].Response) } } @@ -163,11 +172,12 @@ func TestToolBridge_Good_DescribeTrimsBlankGroup(t *testing.T) { }, func(c *gin.Context) {}) descs := bridge.Describe() - if len(descs) != 1 { - t.Fatalf("expected 1 description, got %d", len(descs)) + // Describe() returns the GET listing plus one tool description. + if len(descs) != 2 { + t.Fatalf("expected 2 descriptions (listing + tool), got %d", len(descs)) } - if len(descs[0].Tags) != 1 || descs[0].Tags[0] != "tools" { - t.Fatalf("expected blank group to fall back to bridge tag, got %v", descs[0].Tags) + if len(descs[1].Tags) != 1 || descs[1].Tags[0] != "tools" { + t.Fatalf("expected blank group to fall back to bridge tag, got %v", descs[1].Tags) } } @@ -698,10 +708,13 @@ func TestToolBridge_Bad_EmptyBridge(t *testing.T) { rg := engine.Group(bridge.BasePath()) bridge.RegisterRoutes(rg) - // Describe should return empty slice. + // Describe should return only the GET listing entry when no tools are registered. descs := bridge.Describe() - if len(descs) != 0 { - t.Fatalf("expected 0 descriptions, got %d", len(descs)) + if len(descs) != 1 { + t.Fatalf("expected 1 description (tool listing), got %d", len(descs)) + } + if descs[0].Method != "GET" || descs[0].Path != "/" { + t.Fatalf("expected solitary description to be the tool listing, got %+v", descs[0]) } // Tools should return empty slice. @@ -711,6 +724,125 @@ func TestToolBridge_Bad_EmptyBridge(t *testing.T) { } } +// TestToolBridge_Good_ListsRegisteredTools verifies that GET on the bridge's +// base path returns the catalogue of registered tools per RFC.endpoints.md — +// "GET /v1/tools List available tools". +func TestToolBridge_Good_ListsRegisteredTools(t *testing.T) { + gin.SetMode(gin.TestMode) + + bridge := api.NewToolBridge("/v1/tools") + bridge.Add(api.ToolDescriptor{ + Name: "file_read", + Description: "Read a file from disk", + Group: "files", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + }, + }, + }, func(c *gin.Context) {}) + bridge.Add(api.ToolDescriptor{ + Name: "metrics_query", + Description: "Query metrics data", + Group: "metrics", + }, func(c *gin.Context) {}) + + engine := gin.New() + rg := engine.Group(bridge.BasePath()) + bridge.RegisterRoutes(rg) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/tools", nil) + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body=%s)", w.Code, w.Body.String()) + } + + var resp api.Response[[]api.ToolDescriptor] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if !resp.Success { + t.Fatal("expected Success=true for tool listing") + } + if len(resp.Data) != 2 { + t.Fatalf("expected 2 tool descriptors, got %d", len(resp.Data)) + } + if resp.Data[0].Name != "file_read" { + t.Fatalf("expected Data[0].Name=%q, got %q", "file_read", resp.Data[0].Name) + } + if resp.Data[1].Name != "metrics_query" { + t.Fatalf("expected Data[1].Name=%q, got %q", "metrics_query", resp.Data[1].Name) + } +} + +// TestToolBridge_Bad_ListingRoutesWhenEmpty verifies the listing endpoint +// still serves an empty array when no tools are registered on the bridge. +func TestToolBridge_Bad_ListingRoutesWhenEmpty(t *testing.T) { + gin.SetMode(gin.TestMode) + + bridge := api.NewToolBridge("/tools") + engine := gin.New() + rg := engine.Group(bridge.BasePath()) + bridge.RegisterRoutes(rg) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/tools", nil) + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 from empty listing, got %d", w.Code) + } + + var resp api.Response[[]api.ToolDescriptor] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if !resp.Success { + t.Fatal("expected Success=true from empty listing") + } + if len(resp.Data) != 0 { + t.Fatalf("expected empty list, got %d entries", len(resp.Data)) + } +} + +// TestToolBridge_Ugly_ListingCoexistsWithToolEndpoint verifies that the GET +// listing and POST /{tool_name} endpoints register on the same base path +// without colliding. +func TestToolBridge_Ugly_ListingCoexistsWithToolEndpoint(t *testing.T) { + gin.SetMode(gin.TestMode) + + bridge := api.NewToolBridge("/v1/tools") + bridge.Add(api.ToolDescriptor{ + Name: "ping", + Description: "Ping tool", + }, func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("pong")) + }) + + engine := gin.New() + rg := engine.Group(bridge.BasePath()) + bridge.RegisterRoutes(rg) + + // Listing still answers at the base path. + listReq, _ := http.NewRequest(http.MethodGet, "/v1/tools", nil) + listW := httptest.NewRecorder() + engine.ServeHTTP(listW, listReq) + if listW.Code != http.StatusOK { + t.Fatalf("expected 200 from listing, got %d", listW.Code) + } + + // Tool dispatch still answers at POST {basePath}/{name}. + toolReq, _ := http.NewRequest(http.MethodPost, "/v1/tools/ping", nil) + toolW := httptest.NewRecorder() + engine.ServeHTTP(toolW, toolReq) + if toolW.Code != http.StatusOK { + t.Fatalf("expected 200 from tool dispatch, got %d", toolW.Code) + } +} + func TestToolBridge_Good_IntegrationWithEngine(t *testing.T) { gin.SetMode(gin.TestMode) e, err := api.New() diff --git a/cmd/api/cmd_spec.go b/cmd/api/cmd_spec.go index 8566961..b3f0876 100644 --- a/cmd/api/cmd_spec.go +++ b/cmd/api/cmd_spec.go @@ -83,6 +83,10 @@ func specConfigFromOptions(opts core.Options) specBuilderConfig { wsPath: opts.String("ws-path"), pprofEnabled: opts.Bool("pprof"), expvarEnabled: opts.Bool("expvar"), + openAPISpecEnabled: opts.Bool("openapi-spec"), + openAPISpecPath: opts.String("openapi-spec-path"), + chatCompletionsEnabled: opts.Bool("chat-completions"), + chatCompletionsPath: opts.String("chat-completions-path"), cacheEnabled: opts.Bool("cache"), cacheTTL: opts.String("cache-ttl"), cacheMaxEntries: opts.Int("cache-max-entries"), diff --git a/cmd/api/cmd_spec_test.go b/cmd/api/cmd_spec_test.go index e4cfb33..55a8b19 100644 --- a/cmd/api/cmd_spec_test.go +++ b/cmd/api/cmd_spec_test.go @@ -134,6 +134,88 @@ func TestCmdSpec_SpecConfigFromOptions_Good_FlagsArePreserved(t *testing.T) { } } +// TestCmdSpec_SpecConfigFromOptions_Good_OpenAPIAndChatFlagsPreserved +// verifies the new spec-level flags for the standalone OpenAPI JSON and +// chat completions endpoints round-trip through the CLI parser. +func TestCmdSpec_SpecConfigFromOptions_Good_OpenAPIAndChatFlagsPreserved(t *testing.T) { + opts := core.NewOptions( + core.Option{Key: "openapi-spec", Value: true}, + core.Option{Key: "openapi-spec-path", Value: "/api/v1/openapi.json"}, + core.Option{Key: "chat-completions", Value: true}, + core.Option{Key: "chat-completions-path", Value: "/api/v1/chat/completions"}, + ) + + cfg := specConfigFromOptions(opts) + + if !cfg.openAPISpecEnabled { + t.Fatal("expected openAPISpecEnabled=true") + } + if cfg.openAPISpecPath != "/api/v1/openapi.json" { + t.Fatalf("expected openAPISpecPath=%q, got %q", "/api/v1/openapi.json", cfg.openAPISpecPath) + } + if !cfg.chatCompletionsEnabled { + t.Fatal("expected chatCompletionsEnabled=true") + } + if cfg.chatCompletionsPath != "/api/v1/chat/completions" { + t.Fatalf("expected chatCompletionsPath=%q, got %q", "/api/v1/chat/completions", cfg.chatCompletionsPath) + } +} + +// TestCmdSpec_NewSpecBuilder_Good_PropagatesNewFlags verifies that the +// spec builder respects the new OpenAPI and ChatCompletions flags. +func TestCmdSpec_NewSpecBuilder_Good_PropagatesNewFlags(t *testing.T) { + cfg := specBuilderConfig{ + title: "Test", + version: "1.0.0", + openAPISpecEnabled: true, + openAPISpecPath: "/api/v1/openapi.json", + chatCompletionsEnabled: true, + chatCompletionsPath: "/api/v1/chat/completions", + } + + builder, err := newSpecBuilder(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !builder.OpenAPISpecEnabled { + t.Fatal("expected OpenAPISpecEnabled=true on builder") + } + if builder.OpenAPISpecPath != "/api/v1/openapi.json" { + t.Fatalf("expected OpenAPISpecPath=%q, got %q", "/api/v1/openapi.json", builder.OpenAPISpecPath) + } + if !builder.ChatCompletionsEnabled { + t.Fatal("expected ChatCompletionsEnabled=true on builder") + } + if builder.ChatCompletionsPath != "/api/v1/chat/completions" { + t.Fatalf("expected ChatCompletionsPath=%q, got %q", "/api/v1/chat/completions", builder.ChatCompletionsPath) + } +} + +// TestCmdSpec_NewSpecBuilder_Ugly_PathImpliesEnabled verifies that supplying +// only a path override turns the endpoint on automatically so callers need +// not pass both flags in CI scripts. +func TestCmdSpec_NewSpecBuilder_Ugly_PathImpliesEnabled(t *testing.T) { + cfg := specBuilderConfig{ + title: "Test", + version: "1.0.0", + openAPISpecPath: "/api/v1/openapi.json", + chatCompletionsPath: "/api/v1/chat/completions", + } + + builder, err := newSpecBuilder(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !builder.OpenAPISpecEnabled { + t.Fatal("expected OpenAPISpecEnabled to be inferred from path override") + } + if !builder.ChatCompletionsEnabled { + t.Fatal("expected ChatCompletionsEnabled to be inferred from path override") + } +} + // TestCmdSpec_SpecConfigFromOptions_Bad_DefaultsApplied ensures empty values // do not blank out required defaults like title, description, version. func TestCmdSpec_SpecConfigFromOptions_Bad_DefaultsApplied(t *testing.T) { diff --git a/cmd/api/spec_builder.go b/cmd/api/spec_builder.go index 497feca..ab3c2d8 100644 --- a/cmd/api/spec_builder.go +++ b/cmd/api/spec_builder.go @@ -23,6 +23,10 @@ type specBuilderConfig struct { wsPath string pprofEnabled bool expvarEnabled bool + openAPISpecEnabled bool + openAPISpecPath string + chatCompletionsEnabled bool + chatCompletionsPath string cacheEnabled bool cacheTTL string cacheMaxEntries int @@ -53,6 +57,8 @@ func newSpecBuilder(cfg specBuilderConfig) (*goapi.SpecBuilder, error) { cacheTTL := core.Trim(cfg.cacheTTL) cacheTTLValid := parsePositiveDuration(cacheTTL) + openAPISpecPath := core.Trim(cfg.openAPISpecPath) + chatCompletionsPath := core.Trim(cfg.chatCompletionsPath) builder := &goapi.SpecBuilder{ Title: core.Trim(cfg.title), Summary: core.Trim(cfg.summary), @@ -70,6 +76,10 @@ func newSpecBuilder(cfg specBuilderConfig) (*goapi.SpecBuilder, error) { WSPath: wsPath, PprofEnabled: cfg.pprofEnabled, ExpvarEnabled: cfg.expvarEnabled, + ChatCompletionsEnabled: cfg.chatCompletionsEnabled || chatCompletionsPath != "", + ChatCompletionsPath: chatCompletionsPath, + OpenAPISpecEnabled: cfg.openAPISpecEnabled || openAPISpecPath != "", + OpenAPISpecPath: openAPISpecPath, CacheEnabled: cfg.cacheEnabled || cacheTTLValid, CacheTTL: cacheTTL, CacheMaxEntries: cfg.cacheMaxEntries, diff --git a/modernization_test.go b/modernization_test.go index b6e3a7d..73a7224 100644 --- a/modernization_test.go +++ b/modernization_test.go @@ -290,13 +290,19 @@ func TestToolBridge_Iterators(t *testing.T) { t.Errorf("ToolsIter failed, got %v", tools) } - // Test DescribeIter + // Test DescribeIter — emits the GET listing entry followed by each tool. var descs []api.RouteDescription for d := range b.DescribeIter() { descs = append(descs, d) } - if len(descs) != 1 || descs[0].Path != "/test" { - t.Errorf("DescribeIter failed, got %v", descs) + if len(descs) != 2 { + t.Errorf("DescribeIter failed: expected listing + 1 tool, got %v", descs) + } + if descs[0].Method != "GET" || descs[0].Path != "/" { + t.Errorf("DescribeIter failed: expected first entry to be GET / listing, got %+v", descs[0]) + } + if descs[1].Path != "/test" { + t.Errorf("DescribeIter failed: expected tool entry at /test, got %+v", descs[1]) } } @@ -322,7 +328,14 @@ func TestToolBridge_Iterators_Good_SnapshotCurrentTools(t *testing.T) { if len(tools) != 1 || tools[0].Name != "first" { t.Fatalf("expected ToolsIter snapshot to contain the original tool, got %v", tools) } - if len(descs) != 1 || descs[0].Path != "/first" { + // DescribeIter snapshots the listing entry plus the tool registered at call time. + if len(descs) != 2 { + t.Fatalf("expected DescribeIter snapshot to contain listing + original tool, got %v", descs) + } + if descs[0].Method != "GET" || descs[0].Path != "/" { + t.Fatalf("expected DescribeIter snapshot to start with the GET listing entry, got %+v", descs[0]) + } + if descs[1].Path != "/first" { t.Fatalf("expected DescribeIter snapshot to contain the original tool, got %v", descs) } } diff --git a/openapi.go b/openapi.go index efd3777..65ac9b4 100644 --- a/openapi.go +++ b/openapi.go @@ -53,6 +53,8 @@ type SpecBuilder struct { ExpvarEnabled bool ChatCompletionsEnabled bool ChatCompletionsPath string + OpenAPISpecEnabled bool + OpenAPISpecPath string CacheEnabled bool CacheTTL string CacheMaxEntries int @@ -158,6 +160,12 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) { if path := sb.effectiveChatCompletionsPath(); path != "" { spec["x-chat-completions-path"] = normaliseOpenAPIPath(path) } + if sb.OpenAPISpecEnabled { + spec["x-openapi-spec-enabled"] = true + } + if path := sb.effectiveOpenAPISpecPath(); path != "" { + spec["x-openapi-spec-path"] = normaliseOpenAPIPath(path) + } if sb.CacheEnabled { spec["x-cache-enabled"] = true } @@ -358,6 +366,24 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any { paths["/debug/vars"] = item } + if specPath := sb.effectiveOpenAPISpecPath(); specPath != "" { + specPath = normaliseOpenAPIPath(specPath) + item := openAPISpecPathItem(specPath, operationIDs) + if isPublicPathForList(specPath, publicPaths) { + makePathItemPublic(item) + } + paths[specPath] = item + } + + if chatPath := sb.effectiveChatCompletionsPath(); chatPath != "" { + chatPath = normaliseOpenAPIPath(chatPath) + item := chatCompletionsPathItem(chatPath, operationIDs) + if isPublicPathForList(chatPath, publicPaths) { + makePathItemPublic(item) + } + paths[chatPath] = item + } + for _, g := range groups { for _, rd := range g.descs { fullPath := joinOpenAPIPath(g.basePath, rd.Path) @@ -909,6 +935,14 @@ func (sb *SpecBuilder) buildTags(groups []preparedRouteGroup) []map[string]any { seen["debug"] = true } + if sb.effectiveChatCompletionsPath() != "" && !seen["inference"] { + tags = append(tags, map[string]any{ + "name": "inference", + "description": "Local inference endpoints (OpenAI-compatible)", + }) + seen["inference"] = true + } + for _, g := range groups { name := core.Trim(g.name) if name != "" && !seen[name] { @@ -1277,6 +1311,245 @@ func expvarPathItem(operationIDs map[string]int) map[string]any { } } +// openAPISpecPathItem returns the OpenAPI path item describing the standalone +// JSON document endpoint (RFC.endpoints.md — "GET /v1/openapi.json"). The +// endpoint is flagged public so SDK generators can fetch the description +// without credentials when Authentik is configured. +// +// paths["/v1/openapi.json"] = openAPISpecPathItem("/v1/openapi.json", ids) +func openAPISpecPathItem(path string, operationIDs map[string]int) map[string]any { + successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()) + errorHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()) + + return map[string]any{ + "get": map[string]any{ + "summary": "OpenAPI specification", + "description": "Returns the generated OpenAPI 3.1 JSON document for this API.", + "tags": []string{"system"}, + "operationId": operationID("get", path, operationIDs), + "security": []any{}, + "responses": map[string]any{ + "200": map[string]any{ + "description": "OpenAPI 3.1 JSON document", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "additionalProperties": true, + }, + }, + }, + "headers": successHeaders, + }, + "500": map[string]any{ + "description": "Failed to render specification", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "additionalProperties": true, + }, + }, + }, + "headers": errorHeaders, + }, + }, + }, + } +} + +// chatCompletionsPathItem returns the OpenAPI path item describing the +// OpenAI-compatible chat completions endpoint (RFC §11). The path documents +// the streaming and non-streaming response shapes, the Gemma 4 calibrated +// sampling defaults, and the OpenAI-compatible error envelope so SDK +// generators can bind to the same surface as the hand-written client. +// +// paths["/v1/chat/completions"] = chatCompletionsPathItem("/v1/chat/completions", ids) +func chatCompletionsPathItem(path string, operationIDs map[string]int) map[string]any { + successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()) + errorHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()) + + return map[string]any{ + "post": map[string]any{ + "summary": "Chat completions", + "description": "OpenAI-compatible chat completion endpoint. Defaults to temperature=1.0, top_p=0.95, top_k=64, max_tokens=2048 (Gemma 4 calibrated). Set stream=true to receive Server-Sent Events matching OpenAI's streaming format.", + "tags": []string{"inference"}, + "operationId": operationID("post", path, operationIDs), + "security": []any{}, + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "application/json": map[string]any{ + "schema": chatCompletionsRequestSchema(), + }, + }, + }, + "responses": map[string]any{ + "200": map[string]any{ + "description": "Chat completion response", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": chatCompletionsResponseSchema(), + }, + "text/event-stream": map[string]any{ + "schema": chatCompletionsStreamSchema(), + }, + }, + "headers": successHeaders, + }, + "400": map[string]any{ + "description": "Invalid request", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": chatCompletionsErrorSchema(), + }, + }, + "headers": errorHeaders, + }, + "404": map[string]any{ + "description": "Model not found", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": chatCompletionsErrorSchema(), + }, + }, + "headers": errorHeaders, + }, + "503": map[string]any{ + "description": "Model loading or unavailable", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": chatCompletionsErrorSchema(), + }, + }, + "headers": errorHeaders, + }, + "500": map[string]any{ + "description": "Inference error", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": chatCompletionsErrorSchema(), + }, + }, + "headers": errorHeaders, + }, + }, + }, + } +} + +// chatCompletionsRequestSchema is the OpenAPI schema for +// ChatCompletionRequest. Gemma 4 calibrated defaults (temperature=1.0, +// top_p=0.95, top_k=64, max_tokens=2048) are documented in the example. +// +// schema := chatCompletionsRequestSchema() +func chatCompletionsRequestSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "model": map[string]any{ + "type": "string", + "description": "Model name (lemer, lemma, lemmy, lemrd, or any identifier resolvable via ~/.core/models.yaml)", + }, + "messages": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "role": map[string]any{"type": "string", "enum": []string{"system", "user", "assistant"}}, + "content": map[string]any{"type": "string"}, + }, + "required": []string{"role", "content"}, + }, + }, + "temperature": map[string]any{"type": "number", "description": "Sampling temperature (default 1.0 for Gemma 4)"}, + "top_p": map[string]any{"type": "number", "description": "Nucleus sampling (default 0.95)"}, + "top_k": map[string]any{"type": "integer", "description": "Top-K sampling (default 64)"}, + "max_tokens": map[string]any{"type": "integer", "description": "Output token cap (default 2048)"}, + "stream": map[string]any{"type": "boolean", "description": "Enable SSE streaming"}, + "stop": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "user": map[string]any{"type": "string", "description": "Opaque end-user identifier"}, + }, + "required": []string{"model", "messages"}, + } +} + +// chatCompletionsResponseSchema is the OpenAPI schema for a non-streaming +// ChatCompletionResponse. See RFC §11.3. +// +// schema := chatCompletionsResponseSchema() +func chatCompletionsResponseSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "object": map[string]any{"type": "string"}, + "created": map[string]any{"type": "integer"}, + "model": map[string]any{"type": "string"}, + "choices": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "index": map[string]any{"type": "integer"}, + "message": map[string]any{ + "type": "object", + "properties": map[string]any{ + "role": map[string]any{"type": "string"}, + "content": map[string]any{"type": "string"}, + }, + }, + "finish_reason": map[string]any{"type": "string", "enum": []string{"stop", "length", "error"}}, + }, + }, + }, + "usage": map[string]any{ + "type": "object", + "properties": map[string]any{ + "prompt_tokens": map[string]any{"type": "integer"}, + "completion_tokens": map[string]any{"type": "integer"}, + "total_tokens": map[string]any{"type": "integer"}, + }, + }, + "thought": map[string]any{"type": "string", "description": "Thinking channel content when the model emits <|channel>thought tokens"}, + }, + } +} + +// chatCompletionsStreamSchema documents the text/event-stream chunk shape for +// Server-Sent Events responses. See RFC §11.4. +// +// schema := chatCompletionsStreamSchema() +func chatCompletionsStreamSchema() map[string]any { + return map[string]any{ + "type": "string", + "description": "data: events terminated by data: [DONE] per OpenAI's SSE format", + } +} + +// chatCompletionsErrorSchema is the OpenAI-compatible error envelope emitted +// by the chat completions endpoint. See RFC §11.7. +// +// schema := chatCompletionsErrorSchema() +func chatCompletionsErrorSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "error": map[string]any{ + "type": "object", + "properties": map[string]any{ + "message": map[string]any{"type": "string"}, + "type": map[string]any{"type": "string"}, + "param": map[string]any{"type": "string"}, + "code": map[string]any{"type": "string"}, + }, + "required": []string{"message", "type", "code"}, + }, + }, + "required": []string{"error"}, + } +} + func graphqlRequestSchema() map[string]any { return map[string]any{ "type": "object", @@ -2055,6 +2328,23 @@ func (sb *SpecBuilder) effectiveChatCompletionsPath() string { return "" } +// effectiveOpenAPISpecPath returns the configured standalone OpenAPI JSON +// endpoint path or the RFC default "/v1/openapi.json" when enabled without an +// explicit override. An explicit path also surfaces on its own so the spec +// reflects configuration authored ahead of runtime activation. +// +// sb.effectiveOpenAPISpecPath() // "/v1/openapi.json" when enabled +func (sb *SpecBuilder) effectiveOpenAPISpecPath() string { + path := core.Trim(sb.OpenAPISpecPath) + if path != "" { + return path + } + if sb.OpenAPISpecEnabled { + return defaultOpenAPISpecPath + } + return "" +} + // effectiveCacheTTL returns a normalised cache TTL when it parses to a // positive duration. func (sb *SpecBuilder) effectiveCacheTTL() string { diff --git a/openapi_test.go b/openapi_test.go index 71f5ab6..d27be84 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -789,6 +789,190 @@ func TestSpecBuilder_Good_ChatCompletionsOmittedWhenDisabled(t *testing.T) { } } +// TestSpecBuilder_Good_ChatCompletionsPathAppearsInPaths verifies the chat +// completions endpoint appears as a full OpenAPI path item so SDK generators +// can bind to it without relying on vendor extensions. See RFC §11.1. +func TestSpecBuilder_Good_ChatCompletionsPathAppearsInPaths(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + ChatCompletionsEnabled: true, + } + + data, err := sb.Build(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + paths, ok := spec["paths"].(map[string]any) + if !ok { + t.Fatalf("expected paths object, got %T", spec["paths"]) + } + item, ok := paths["/v1/chat/completions"].(map[string]any) + if !ok { + t.Fatalf("expected /v1/chat/completions path item, got %T", paths["/v1/chat/completions"]) + } + if _, ok := item["post"]; !ok { + t.Fatal("expected POST operation on /v1/chat/completions") + } +} + +// TestSpecBuilder_Bad_ChatCompletionsPathAbsentWhenDisabled verifies the +// path item does not appear when the endpoint is not configured. +func TestSpecBuilder_Bad_ChatCompletionsPathAbsentWhenDisabled(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + } + + data, err := sb.Build(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + paths := spec["paths"].(map[string]any) + if _, ok := paths["/v1/chat/completions"]; ok { + t.Fatal("expected /v1/chat/completions path item to be absent when disabled") + } +} + +// TestSpecBuilder_Ugly_ChatCompletionsPathCustomOverrideHonoured verifies +// that a custom mount path is reflected in the OpenAPI paths object. +func TestSpecBuilder_Ugly_ChatCompletionsPathCustomOverrideHonoured(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + ChatCompletionsEnabled: true, + ChatCompletionsPath: "/api/v1/chat", + } + + data, err := sb.Build(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + paths := spec["paths"].(map[string]any) + if _, ok := paths["/api/v1/chat"].(map[string]any); !ok { + t.Fatalf("expected custom chat completions path in paths object, got %v", paths) + } + if _, ok := paths["/v1/chat/completions"]; ok { + t.Fatal("expected default chat completions path to be absent when overridden") + } +} + +// TestSpecBuilder_Good_OpenAPISpecEndpointAppearsInPaths verifies the +// standalone OpenAPI JSON endpoint (RFC.endpoints.md — "GET /v1/openapi.json") +// is described as a public OpenAPI path so it is discoverable via SDK tooling. +func TestSpecBuilder_Good_OpenAPISpecEndpointAppearsInPaths(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + OpenAPISpecEnabled: true, + } + + data, err := sb.Build(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if got := spec["x-openapi-spec-enabled"]; got != true { + t.Fatalf("expected x-openapi-spec-enabled=true, got %v", got) + } + if got := spec["x-openapi-spec-path"]; got != "/v1/openapi.json" { + t.Fatalf("expected default openapi spec path, got %v", got) + } + + paths := spec["paths"].(map[string]any) + item, ok := paths["/v1/openapi.json"].(map[string]any) + if !ok { + t.Fatalf("expected /v1/openapi.json path item, got %T", paths["/v1/openapi.json"]) + } + get, ok := item["get"].(map[string]any) + if !ok { + t.Fatal("expected GET operation on /v1/openapi.json") + } + // Public endpoint — no security requirement. + if sec, ok := get["security"].([]any); !ok || len(sec) != 0 { + t.Fatalf("expected public security on spec endpoint, got %v", get["security"]) + } +} + +// TestSpecBuilder_Bad_OpenAPISpecEndpointAbsentWhenDisabled verifies the path +// item is absent when the caller has not enabled the standalone endpoint. +func TestSpecBuilder_Bad_OpenAPISpecEndpointAbsentWhenDisabled(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + } + + data, err := sb.Build(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + paths := spec["paths"].(map[string]any) + if _, ok := paths["/v1/openapi.json"]; ok { + t.Fatal("expected /v1/openapi.json path item to be absent when disabled") + } + if _, ok := spec["x-openapi-spec-enabled"]; ok { + t.Fatal("expected x-openapi-spec-enabled extension to be absent when disabled") + } +} + +// TestSpecBuilder_Ugly_OpenAPISpecPathCustomOverrideHonoured verifies a +// custom mount path is reflected in the OpenAPI paths object. +func TestSpecBuilder_Ugly_OpenAPISpecPathCustomOverrideHonoured(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + OpenAPISpecEnabled: true, + OpenAPISpecPath: "/api/v1/openapi.json", + } + + data, err := sb.Build(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + paths := spec["paths"].(map[string]any) + if _, ok := paths["/api/v1/openapi.json"].(map[string]any); !ok { + t.Fatalf("expected custom openapi spec path in paths object, got %v", paths) + } + if _, ok := paths["/v1/openapi.json"]; ok { + t.Fatal("expected default openapi spec path to be absent when overridden") + } +} + func TestSpecBuilder_Good_EnabledTransportsUseDefaultPaths(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test", diff --git a/options.go b/options.go index 8299281..30435ce 100644 --- a/options.go +++ b/options.go @@ -749,3 +749,42 @@ func WithChatCompletionsPath(path string) Option { e.chatCompletionsPath = path } } + +// WithOpenAPISpec mounts a standalone JSON document endpoint at +// "/v1/openapi.json" (RFC.endpoints.md — "GET /v1/openapi.json"). The generated +// spec mirrors the document surfaced by the Swagger UI but is served +// application/json directly so SDK generators and ToolBridge consumers can +// fetch it without loading the UI bundle. +// +// Example: +// +// engine, _ := api.New(api.WithOpenAPISpec()) +func WithOpenAPISpec() Option { + return func(e *Engine) { + e.openAPISpecEnabled = true + } +} + +// WithOpenAPISpecPath sets a custom URL path for the standalone OpenAPI JSON +// endpoint. An empty string falls back to the RFC default "/v1/openapi.json". +// The override also enables the endpoint so callers can configure the URL +// without an additional WithOpenAPISpec() call. +// +// Example: +// +// api.New(api.WithOpenAPISpecPath("/api/v1/openapi.json")) +func WithOpenAPISpecPath(path string) Option { + return func(e *Engine) { + path = core.Trim(path) + if path == "" { + e.openAPISpecPath = defaultOpenAPISpecPath + e.openAPISpecEnabled = true + return + } + if !core.HasPrefix(path, "/") { + path = "/" + path + } + e.openAPISpecPath = path + e.openAPISpecEnabled = true + } +} diff --git a/spec_builder_helper.go b/spec_builder_helper.go index f8a126e..2d9b46a 100644 --- a/spec_builder_helper.go +++ b/spec_builder_helper.go @@ -79,6 +79,8 @@ func (e *Engine) OpenAPISpecBuilder() *SpecBuilder { builder.ExpvarEnabled = runtime.Transport.ExpvarEnabled builder.ChatCompletionsEnabled = runtime.Transport.ChatCompletionsEnabled builder.ChatCompletionsPath = runtime.Transport.ChatCompletionsPath + builder.OpenAPISpecEnabled = runtime.Transport.OpenAPISpecEnabled + builder.OpenAPISpecPath = runtime.Transport.OpenAPISpecPath builder.CacheEnabled = runtime.Cache.Enabled if runtime.Cache.TTL > 0 { diff --git a/spec_builder_helper_test.go b/spec_builder_helper_test.go index bcaba36..7384866 100644 --- a/spec_builder_helper_test.go +++ b/spec_builder_helper_test.go @@ -538,6 +538,66 @@ func TestEngine_Good_TransportConfigHonoursChatCompletionsPathOverride(t *testin } } +// TestEngine_Good_TransportConfigReportsOpenAPISpec verifies that the +// WithOpenAPISpec option surfaces the standalone JSON endpoint (RFC +// /v1/openapi.json) through TransportConfig so callers can discover it +// alongside the other framework routes. +func TestEngine_Good_TransportConfigReportsOpenAPISpec(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithOpenAPISpec()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cfg := e.TransportConfig() + if !cfg.OpenAPISpecEnabled { + t.Fatal("expected OpenAPISpecEnabled=true") + } + if cfg.OpenAPISpecPath != "/v1/openapi.json" { + t.Fatalf("expected OpenAPISpecPath=/v1/openapi.json, got %q", cfg.OpenAPISpecPath) + } +} + +// TestEngine_Good_TransportConfigHonoursOpenAPISpecPathOverride verifies +// that WithOpenAPISpecPath surfaces through TransportConfig. +func TestEngine_Good_TransportConfigHonoursOpenAPISpecPathOverride(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithOpenAPISpecPath("/api/v1/openapi.json")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cfg := e.TransportConfig() + if !cfg.OpenAPISpecEnabled { + t.Fatal("expected OpenAPISpecEnabled inferred from path override") + } + if cfg.OpenAPISpecPath != "/api/v1/openapi.json" { + t.Fatalf("expected custom path, got %q", cfg.OpenAPISpecPath) + } +} + +// TestEngine_Bad_TransportConfigOmitsOpenAPISpecWhenDisabled confirms the +// standalone OpenAPI endpoint reports as disabled when neither WithOpenAPISpec +// nor WithOpenAPISpecPath has been invoked. +func TestEngine_Bad_TransportConfigOmitsOpenAPISpecWhenDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cfg := e.TransportConfig() + if cfg.OpenAPISpecEnabled { + t.Fatal("expected OpenAPISpecEnabled=false when not configured") + } + if cfg.OpenAPISpecPath != "" { + t.Fatalf("expected empty OpenAPISpecPath, got %q", cfg.OpenAPISpecPath) + } +} + func TestEngine_Good_OpenAPISpecBuilderExportsDefaultSwaggerPath(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/swagger.go b/swagger.go index 94879af..c0b7ee6 100644 --- a/swagger.go +++ b/swagger.go @@ -91,3 +91,54 @@ func resolveSwaggerPath(path string) string { } return normaliseSwaggerPath(path) } + +// defaultOpenAPISpecPath is the URL path where the raw OpenAPI 3.1 JSON +// document is served per RFC.endpoints.md — "GET /v1/openapi.json". +const defaultOpenAPISpecPath = "/v1/openapi.json" + +// registerOpenAPISpec mounts a GET handler at the configured spec path that +// serves the generated OpenAPI 3.1 JSON document. The document is built once +// and reused for every subsequent request so callers pay the generation cost +// a single time. +// +// registerOpenAPISpec(r, engine) +// // GET /v1/openapi.json -> application/json openapi document +func registerOpenAPISpec(g *gin.Engine, e *Engine) { + path := resolveOpenAPISpecPath(e.openAPISpecPath) + spec := newSwaggerSpec(e.OpenAPISpecBuilder(), e.Groups()) + g.GET(path, func(c *gin.Context) { + doc := spec.ReadDoc() + c.Header("Content-Type", "application/json; charset=utf-8") + c.String(http.StatusOK, doc) + }) +} + +// normaliseOpenAPISpecPath coerces custom spec URL overrides into a stable +// form. The returned path always begins with a single slash and never ends +// with one, matching the shape of the other transport path helpers. +// +// normaliseOpenAPISpecPath("openapi.json") // "/openapi.json" +func normaliseOpenAPISpecPath(path string) string { + path = core.Trim(path) + if path == "" { + return defaultOpenAPISpecPath + } + + path = "/" + strings.Trim(path, "/") + if path == "/" { + return defaultOpenAPISpecPath + } + + return path +} + +// resolveOpenAPISpecPath returns the configured OpenAPI spec URL or the +// RFC default when no override is provided. +// +// resolveOpenAPISpecPath("") // "/v1/openapi.json" +func resolveOpenAPISpecPath(path string) string { + if core.Trim(path) == "" { + return defaultOpenAPISpecPath + } + return normaliseOpenAPISpecPath(path) +} diff --git a/swagger_test.go b/swagger_test.go index 77c820b..ae6e352 100644 --- a/swagger_test.go +++ b/swagger_test.go @@ -335,6 +335,16 @@ func TestSwagger_Good_WithToolBridge(t *testing.T) { if postOp["summary"] != "Query metrics data" { t.Fatalf("expected summary=%q, got %v", "Query metrics data", postOp["summary"]) } + + // RFC.endpoints.md — GET /v1/tools listing must appear on the bridge's + // base path so SDK generators can discover it without iterating tools. + listingPath, ok := paths["/api/tools"].(map[string]any) + if !ok { + t.Fatalf("expected bridge base path /api/tools in spec, got %v", paths["/api/tools"]) + } + if _, ok := listingPath["get"]; !ok { + t.Fatalf("expected GET listing on bridge base path, got %v", listingPath) + } } func TestSwagger_Good_IncludesSSEEndpoint(t *testing.T) { @@ -918,3 +928,154 @@ func (h *swaggerSpecHelper) ReadDoc() string { h.cache = string(data) return h.cache } + +// TestOpenAPISpecEndpoint_Good verifies WithOpenAPISpec mounts a public +// GET /v1/openapi.json that returns the generated document. RFC.endpoints.md +// lists this as a framework route alongside /health and /swagger. +func TestOpenAPISpecEndpoint_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New( + api.WithSwagger("Test API", "A test API service", "1.0.0"), + api.WithOpenAPISpec(), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/v1/openapi.json") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + contentType := resp.Header.Get("Content-Type") + if contentType == "" || contentType[:len("application/json")] != "application/json" { + t.Fatalf("expected application/json content type, got %q", contentType) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + var doc map[string]any + if err := json.Unmarshal(body, &doc); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if doc["openapi"] != "3.1.0" { + t.Fatalf("expected openapi=3.1.0, got %v", doc["openapi"]) + } + paths, ok := doc["paths"].(map[string]any) + if !ok { + t.Fatalf("expected paths map, got %T", doc["paths"]) + } + if _, ok := paths["/v1/openapi.json"]; !ok { + t.Fatal("expected the spec endpoint to describe itself in paths") + } +} + +// TestOpenAPISpecEndpoint_Good_CustomPath verifies an explicit path override +// is honoured by the router. +func TestOpenAPISpecEndpoint_Good_CustomPath(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New( + api.WithSwagger("Test API", "A test API service", "1.0.0"), + api.WithOpenAPISpecPath("/api/v1/openapi.json"), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/api/v1/openapi.json") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 on custom spec path, got %d", resp.StatusCode) + } + + // Default path should 404 when overridden. + defaultResp, err := http.Get(srv.URL + "/v1/openapi.json") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer defaultResp.Body.Close() + if defaultResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 on default spec path when overridden, got %d", defaultResp.StatusCode) + } +} + +// TestOpenAPISpecEndpoint_Bad_DisabledByDefault verifies the endpoint is not +// mounted unless opted in with WithOpenAPISpec(). +func TestOpenAPISpecEndpoint_Bad_DisabledByDefault(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithSwagger("Test API", "A test API service", "1.0.0")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/v1/openapi.json") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 when endpoint is disabled, got %d", resp.StatusCode) + } +} + +// TestOpenAPISpecEndpoint_Ugly_WorksWithoutSwagger confirms the endpoint +// serves the spec even when the Swagger UI is not mounted — the standalone +// JSON document is independent of the UI bundle. +func TestOpenAPISpecEndpoint_Ugly_WorksWithoutSwagger(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithOpenAPISpec()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/v1/openapi.json") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 without swagger UI, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(body, &doc); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if doc["openapi"] != "3.1.0" { + t.Fatalf("expected openapi=3.1.0, got %v", doc["openapi"]) + } +} diff --git a/transport.go b/transport.go index 33cff4b..abdade0 100644 --- a/transport.go +++ b/transport.go @@ -27,6 +27,8 @@ type TransportConfig struct { ExpvarEnabled bool ChatCompletionsEnabled bool ChatCompletionsPath string + OpenAPISpecEnabled bool + OpenAPISpecPath string } // TransportConfig returns the currently configured transport metadata for the engine. @@ -49,6 +51,7 @@ func (e *Engine) TransportConfig() TransportConfig { PprofEnabled: e.pprofEnabled, ExpvarEnabled: e.expvarEnabled, ChatCompletionsEnabled: e.chatCompletionsResolver != nil, + OpenAPISpecEnabled: e.openAPISpecEnabled, } gql := e.GraphQLConfig() cfg.GraphQLEnabled = gql.Enabled @@ -70,6 +73,9 @@ func (e *Engine) TransportConfig() TransportConfig { if e.chatCompletionsResolver != nil || core.Trim(e.chatCompletionsPath) != "" { cfg.ChatCompletionsPath = resolveChatCompletionsPath(e.chatCompletionsPath) } + if e.openAPISpecEnabled || core.Trim(e.openAPISpecPath) != "" { + cfg.OpenAPISpecPath = resolveOpenAPISpecPath(e.openAPISpecPath) + } return cfg }