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:
parent
fb498f0b88
commit
8796c405c2
15 changed files with 1140 additions and 36 deletions
10
api.go
10
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)
|
||||
|
|
|
|||
70
bridge.go
70
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
|
||||
|
|
|
|||
186
bridge_test.go
186
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()
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
290
openapi.go
290
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: <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 {
|
||||
|
|
|
|||
184
openapi_test.go
184
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",
|
||||
|
|
|
|||
39
options.go
39
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
51
swagger.go
51
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)
|
||||
}
|
||||
|
|
|
|||
161
swagger_test.go
161
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"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue