feat(api): framework routes — GET /v1/tools + /v1/openapi.json + chat path items

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 <virgil@lethean.io>
This commit is contained in:
Snider 2026-04-14 17:51:16 +01:00
parent fb498f0b88
commit 8796c405c2
15 changed files with 1140 additions and 36 deletions

10
api.go
View file

@ -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)

View file

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

View file

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

View file

@ -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"),

View file

@ -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) {

View file

@ -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,

View file

@ -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)
}
}

View file

@ -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: <json> 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 {

View file

@ -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",

View file

@ -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
}
}

View file

@ -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 {

View file

@ -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)

View file

@ -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)
}

View file

@ -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"])
}
}

View file

@ -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
}