api/openapi_test.go
Virgil ec268c8100 feat(api): default enabled transport paths in specs
Treat enabled built-in transports as having their default paths when callers omit an explicit override. This keeps manual SpecBuilder usage aligned with the engine defaults and prevents Swagger, GraphQL, WebSocket, and SSE metadata from disappearing from generated documents.\n\nCo-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:07:05 +00:00

2854 lines
76 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"iter"
"net/http"
"testing"
"time"
"github.com/gin-gonic/gin"
api "dappco.re/go/core/api"
)
// ── Test helpers ──────────────────────────────────────────────────────────
type specStubGroup struct {
name string
basePath string
hidden bool
descs []api.RouteDescription
}
func (s *specStubGroup) Name() string { return s.name }
func (s *specStubGroup) BasePath() string { return s.basePath }
func (s *specStubGroup) RegisterRoutes(rg *gin.RouterGroup) {}
func (s *specStubGroup) Describe() []api.RouteDescription { return s.descs }
func (s *specStubGroup) Hidden() bool { return s.hidden }
type plainStubGroup struct{}
func (plainStubGroup) Name() string { return "plain" }
func (plainStubGroup) BasePath() string { return "/plain" }
func (plainStubGroup) RegisterRoutes(rg *gin.RouterGroup) {}
type iterStubGroup struct {
name string
basePath string
descs []api.RouteDescription
}
func (s *iterStubGroup) Name() string { return s.name }
func (s *iterStubGroup) BasePath() string { return s.basePath }
func (s *iterStubGroup) RegisterRoutes(rg *gin.RouterGroup) {}
func (s *iterStubGroup) Describe() []api.RouteDescription { return nil }
func (s *iterStubGroup) DescribeIter() iter.Seq[api.RouteDescription] {
return func(yield func(api.RouteDescription) bool) {
for _, rd := range s.descs {
if !yield(rd) {
return
}
}
}
}
type iterNilFallbackGroup struct {
name string
basePath string
descs []api.RouteDescription
}
func (s *iterNilFallbackGroup) Name() string { return s.name }
func (s *iterNilFallbackGroup) BasePath() string { return s.basePath }
func (s *iterNilFallbackGroup) RegisterRoutes(rg *gin.RouterGroup) {}
func (s *iterNilFallbackGroup) Describe() []api.RouteDescription { return s.descs }
func (s *iterNilFallbackGroup) DescribeIter() iter.Seq[api.RouteDescription] {
return nil
}
type countingIterGroup struct {
name string
basePath string
descs []api.RouteDescription
describeCalls int
}
func (s *countingIterGroup) Name() string { return s.name }
func (s *countingIterGroup) BasePath() string { return s.basePath }
func (s *countingIterGroup) RegisterRoutes(rg *gin.RouterGroup) {}
func (s *countingIterGroup) Describe() []api.RouteDescription { return nil }
func (s *countingIterGroup) DescribeIter() iter.Seq[api.RouteDescription] {
s.describeCalls++
return func(yield func(api.RouteDescription) bool) {
for _, rd := range s.descs {
if !yield(rd) {
return
}
}
}
}
type snapshottingGroup struct {
nameCalls int
basePathCalls int
descs []api.RouteDescription
}
func (s *snapshottingGroup) Name() string {
s.nameCalls++
if s.nameCalls == 1 {
return "alpha"
}
return "beta"
}
func (s *snapshottingGroup) BasePath() string {
s.basePathCalls++
if s.basePathCalls == 1 {
return "/alpha"
}
return "/beta"
}
func (s *snapshottingGroup) RegisterRoutes(rg *gin.RouterGroup) {}
func (s *snapshottingGroup) Describe() []api.RouteDescription { return s.descs }
// ── SpecBuilder tests ─────────────────────────────────────────────────────
func TestSpecBuilder_Good_EmptyGroups(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "Empty test",
Version: "0.0.1",
}
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)
}
// Verify OpenAPI version.
if spec["openapi"] != "3.1.0" {
t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"])
}
if spec["jsonSchemaDialect"] != "https://spec.openapis.org/oas/3.1/dialect/base" {
t.Fatalf("expected jsonSchemaDialect to use the OpenAPI 3.1 base dialect, got %v", spec["jsonSchemaDialect"])
}
// Verify /health path exists.
paths := spec["paths"].(map[string]any)
if _, ok := paths["/health"]; !ok {
t.Fatal("expected /health path in spec")
}
health := paths["/health"].(map[string]any)["get"].(map[string]any)
healthResponses := health["responses"].(map[string]any)
if _, ok := healthResponses["429"]; !ok {
t.Fatal("expected 429 response on /health")
}
if _, ok := healthResponses["504"]; !ok {
t.Fatal("expected 504 response on /health")
}
if _, ok := healthResponses["500"]; !ok {
t.Fatal("expected 500 response on /health")
}
rateLimit429 := healthResponses["429"].(map[string]any)
headers := rateLimit429["headers"].(map[string]any)
if _, ok := headers["Retry-After"]; !ok {
t.Fatal("expected Retry-After header on /health 429 response")
}
if _, ok := headers["X-Request-ID"]; !ok {
t.Fatal("expected X-Request-ID header on /health 429 response")
}
if _, ok := headers["X-RateLimit-Limit"]; !ok {
t.Fatal("expected X-RateLimit-Limit header on /health 429 response")
}
if _, ok := headers["X-RateLimit-Remaining"]; !ok {
t.Fatal("expected X-RateLimit-Remaining header on /health 429 response")
}
if _, ok := headers["X-RateLimit-Reset"]; !ok {
t.Fatal("expected X-RateLimit-Reset header on /health 429 response")
}
health504 := healthResponses["504"].(map[string]any)
health504Headers := health504["headers"].(map[string]any)
if _, ok := health504Headers["X-Request-ID"]; !ok {
t.Fatal("expected X-Request-ID header on /health 504 response")
}
if _, ok := health504Headers["X-RateLimit-Limit"]; !ok {
t.Fatal("expected X-RateLimit-Limit header on /health 504 response")
}
if _, ok := health504Headers["X-RateLimit-Remaining"]; !ok {
t.Fatal("expected X-RateLimit-Remaining header on /health 504 response")
}
if _, ok := health504Headers["X-RateLimit-Reset"]; !ok {
t.Fatal("expected X-RateLimit-Reset header on /health 504 response")
}
health200 := health["responses"].(map[string]any)["200"].(map[string]any)
health200Headers := health200["headers"].(map[string]any)
if _, ok := health200Headers["X-Cache"]; !ok {
t.Fatal("expected X-Cache header on /health 200 response")
}
// Verify system tag exists.
tags := spec["tags"].([]any)
found := false
for _, tag := range tags {
tm := tag.(map[string]any)
if tm["name"] == "system" {
found = true
break
}
}
if !found {
t.Fatal("expected system tag in spec")
}
components := spec["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
if _, ok := schemas["Response"]; !ok {
t.Fatal("expected Response component schema in spec")
}
securitySchemes := components["securitySchemes"].(map[string]any)
bearerAuth := securitySchemes["bearerAuth"].(map[string]any)
if bearerAuth["type"] != "http" {
t.Fatalf("expected bearerAuth.type=http, got %v", bearerAuth["type"])
}
if bearerAuth["scheme"] != "bearer" {
t.Fatalf("expected bearerAuth.scheme=bearer, got %v", bearerAuth["scheme"])
}
if _, ok := spec["security"]; ok {
t.Fatal("expected no global security requirement in the document")
}
if _, ok := spec["x-swagger-enabled"]; ok {
t.Fatal("expected no swagger enabled flag in the document when swagger is disabled")
}
if _, ok := spec["x-graphql-enabled"]; ok {
t.Fatal("expected no graphql enabled flag in the document when graphql is disabled")
}
}
func TestSpecBuilder_Good_NilReceiverIsZeroValueSafe(t *testing.T) {
var sb *api.SpecBuilder
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 spec["openapi"] != "3.1.0" {
t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"])
}
paths, ok := spec["paths"].(map[string]any)
if !ok {
t.Fatalf("expected paths object, got %T", spec["paths"])
}
if _, ok := paths["/health"]; !ok {
t.Fatal("expected /health path to be present")
}
}
func TestSpecBuilder_Good_CustomSecuritySchemesAreMerged(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
SecuritySchemes: map[string]any{
"apiKeyAuth": map[string]any{
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
},
},
}
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)
}
components := spec["components"].(map[string]any)
schemes := components["securitySchemes"].(map[string]any)
bearerAuth, ok := schemes["bearerAuth"].(map[string]any)
if !ok {
t.Fatal("expected default bearerAuth security scheme to remain present")
}
if bearerAuth["scheme"] != "bearer" {
t.Fatalf("expected bearerAuth scheme to stay bearer, got %v", bearerAuth["scheme"])
}
apiKeyAuth, ok := schemes["apiKeyAuth"].(map[string]any)
if !ok {
t.Fatal("expected custom apiKeyAuth security scheme to be merged")
}
if apiKeyAuth["type"] != "apiKey" {
t.Fatalf("expected apiKeyAuth.type=apiKey, got %v", apiKeyAuth["type"])
}
if apiKeyAuth["in"] != "header" {
t.Fatalf("expected apiKeyAuth.in=header, got %v", apiKeyAuth["in"])
}
if apiKeyAuth["name"] != "X-API-Key" {
t.Fatalf("expected apiKeyAuth.name=X-API-Key, got %v", apiKeyAuth["name"])
}
}
func TestSpecBuilder_Good_CommonResponseComponentsArePublished(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)
}
components := spec["components"].(map[string]any)
responses := components["responses"].(map[string]any)
for _, name := range []string{
"BadRequest",
"Unauthorized",
"Forbidden",
"RateLimitExceeded",
"GatewayTimeout",
"InternalServerError",
"Gone",
} {
if _, ok := responses[name]; !ok {
t.Fatalf("expected %s response component in spec", name)
}
}
}
func TestSpecBuilder_Good_SwaggerUIPathExtension(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "Swagger path test",
Version: "1.0.0",
SwaggerPath: "/docs/",
}
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-swagger-ui-path"]; got != "/docs" {
t.Fatalf("expected x-swagger-ui-path=/docs, got %v", got)
}
}
func TestSpecBuilder_Good_CacheAndI18nExtensions(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "Runtime config test",
Version: "1.0.0",
CacheEnabled: true,
CacheTTL: (5 * time.Minute).String(),
CacheMaxEntries: 42,
CacheMaxBytes: 8192,
I18nDefaultLocale: "en-GB",
I18nSupportedLocales: []string{"en-GB", "fr"},
}
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-cache-enabled"]; got != true {
t.Fatalf("expected x-cache-enabled=true, got %v", got)
}
if got := spec["x-cache-ttl"]; got != "5m0s" {
t.Fatalf("expected x-cache-ttl=5m0s, got %v", got)
}
if got := spec["x-cache-max-entries"]; got != float64(42) {
t.Fatalf("expected x-cache-max-entries=42, got %v", got)
}
if got := spec["x-cache-max-bytes"]; got != float64(8192) {
t.Fatalf("expected x-cache-max-bytes=8192, got %v", got)
}
if got := spec["x-i18n-default-locale"]; got != "en-GB" {
t.Fatalf("expected x-i18n-default-locale=en-GB, got %v", got)
}
locales, ok := spec["x-i18n-supported-locales"].([]any)
if !ok {
t.Fatalf("expected x-i18n-supported-locales array, got %T", spec["x-i18n-supported-locales"])
}
if len(locales) != 2 || locales[0] != "en-GB" || locales[1] != "fr" {
t.Fatalf("expected supported locales [en-GB fr], got %v", locales)
}
}
func TestSpecBuilder_Good_GraphQLEndpoint(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "GraphQL test",
Version: "1.0.0",
GraphQLPath: "/graphql",
}
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)
}
tags := spec["tags"].([]any)
found := false
for _, tag := range tags {
tm := tag.(map[string]any)
if tm["name"] == "graphql" {
found = true
break
}
}
if !found {
t.Fatal("expected graphql tag in spec")
}
paths := spec["paths"].(map[string]any)
pathItem, ok := paths["/graphql"].(map[string]any)
if !ok {
t.Fatal("expected /graphql path in spec")
}
getOp := pathItem["get"].(map[string]any)
if getOp["operationId"] != "get_graphql" {
t.Fatalf("expected GraphQL GET operationId to be get_graphql, got %v", getOp["operationId"])
}
getParams := getOp["parameters"].([]any)
if len(getParams) != 3 {
t.Fatalf("expected 3 GraphQL GET query parameters, got %d", len(getParams))
}
if getParams[0].(map[string]any)["name"] != "query" {
t.Fatalf("expected first GraphQL GET parameter to be query, got %v", getParams[0])
}
if getParams[0].(map[string]any)["required"] != true {
t.Fatal("expected GraphQL GET query parameter to be required")
}
postOp := pathItem["post"].(map[string]any)
if postOp["operationId"] != "post_graphql" {
t.Fatalf("expected GraphQL operationId to be post_graphql, got %v", postOp["operationId"])
}
responses := postOp["responses"].(map[string]any)
successHeaders := responses["200"].(map[string]any)["headers"].(map[string]any)
if _, ok := successHeaders["X-Cache"]; !ok {
t.Fatal("expected X-Cache header on GraphQL 200 response")
}
requestBody := postOp["requestBody"].(map[string]any)
schema := requestBody["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
properties := schema["properties"].(map[string]any)
if _, ok := properties["query"]; !ok {
t.Fatal("expected GraphQL request schema to include query field")
}
if _, ok := properties["variables"]; !ok {
t.Fatal("expected GraphQL request schema to include variables field")
}
if _, ok := properties["operationName"]; !ok {
t.Fatal("expected GraphQL request schema to include operationName field")
}
}
func TestSpecBuilder_Good_GraphQLPlaygroundEndpoint(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
GraphQLPath: "/graphql",
GraphQLPlayground: 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 := spec["paths"].(map[string]any)
pathItem, ok := paths["/graphql/playground"].(map[string]any)
if !ok {
t.Fatal("expected /graphql/playground path in spec")
}
getOp := pathItem["get"].(map[string]any)
if getOp["operationId"] != "get_graphql_playground" {
t.Fatalf("expected playground operationId to be get_graphql_playground, got %v", getOp["operationId"])
}
responses := getOp["responses"].(map[string]any)
success := responses["200"].(map[string]any)
content := success["content"].(map[string]any)
if _, ok := content["text/html"]; !ok {
t.Fatal("expected text/html content type for GraphQL playground response")
}
}
func TestSpecBuilder_Good_GraphQLPlaygroundDefaultsToGraphQLPath(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
GraphQLPlayground: 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 := spec["paths"].(map[string]any)
if _, ok := paths["/graphql"].(map[string]any); !ok {
t.Fatal("expected default /graphql path when playground is enabled")
}
if _, ok := paths["/graphql/playground"].(map[string]any); !ok {
t.Fatal("expected default /graphql/playground path when playground is enabled")
}
}
func TestSpecBuilder_Good_GraphQLPlaygroundDefaultsToGraphQLTag(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
GraphQLPlayground: 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)
}
tags := spec["tags"].([]any)
found := false
for _, tag := range tags {
tm := tag.(map[string]any)
if tm["name"] == "graphql" {
found = true
break
}
}
if !found {
t.Fatal("expected graphql tag when playground enables the default GraphQL path")
}
}
func TestSpecBuilder_Good_EnabledTransportsUseDefaultPaths(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
SwaggerEnabled: true,
GraphQLEnabled: true,
WSEnabled: true,
SSEEnabled: 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-swagger-ui-path"]; got != "/swagger" {
t.Fatalf("expected default swagger path, got %v", got)
}
if got := spec["x-graphql-path"]; got != "/graphql" {
t.Fatalf("expected default graphql path, got %v", got)
}
if got := spec["x-ws-path"]; got != "/ws" {
t.Fatalf("expected default websocket path, got %v", got)
}
if got := spec["x-sse-path"]; got != "/events" {
t.Fatalf("expected default sse path, got %v", got)
}
paths := spec["paths"].(map[string]any)
for _, path := range []string{"/graphql", "/ws", "/events"} {
if _, ok := paths[path].(map[string]any); !ok {
t.Fatalf("expected %s path in spec", path)
}
}
tags := spec["tags"].([]any)
foundGraphQL := false
foundEvents := false
for _, tag := range tags {
tm := tag.(map[string]any)
switch tm["name"] {
case "graphql":
foundGraphQL = true
case "events":
foundEvents = true
}
}
if !foundGraphQL {
t.Fatal("expected graphql tag when GraphQL is enabled")
}
if !foundEvents {
t.Fatal("expected events tag when SSE is enabled")
}
}
func TestSpecBuilder_Good_WebSocketEndpoint(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
WSPath: "/ws",
}
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)
}
tags := spec["tags"].([]any)
found := false
for _, tag := range tags {
tm := tag.(map[string]any)
if tm["name"] == "system" {
found = true
break
}
}
if !found {
t.Fatal("expected system tag in spec")
}
paths := spec["paths"].(map[string]any)
pathItem, ok := paths["/ws"].(map[string]any)
if !ok {
t.Fatal("expected /ws path in spec")
}
getOp := pathItem["get"].(map[string]any)
if getOp["operationId"] != "get_ws" {
t.Fatalf("expected WebSocket operationId to be get_ws, got %v", getOp["operationId"])
}
if getOp["summary"] != "WebSocket connection" {
t.Fatalf("expected WebSocket summary, got %v", getOp["summary"])
}
responses := getOp["responses"].(map[string]any)
if _, ok := responses["101"]; !ok {
t.Fatal("expected 101 response on /ws")
}
if _, ok := responses["429"]; !ok {
t.Fatal("expected 429 response on /ws")
}
}
func TestSpecBuilder_Good_ServerSentEventsEndpoint(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
SSEPath: "/events",
}
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)
}
tags := spec["tags"].([]any)
found := false
for _, tag := range tags {
tm := tag.(map[string]any)
if tm["name"] == "events" {
found = true
break
}
}
if !found {
t.Fatal("expected events tag in spec")
}
paths := spec["paths"].(map[string]any)
pathItem, ok := paths["/events"].(map[string]any)
if !ok {
t.Fatal("expected /events path in spec")
}
getOp := pathItem["get"].(map[string]any)
if getOp["operationId"] != "get_events" {
t.Fatalf("expected SSE operationId to be get_events, got %v", getOp["operationId"])
}
params := getOp["parameters"].([]any)
if len(params) != 1 {
t.Fatalf("expected one SSE query parameter, got %d", len(params))
}
param := params[0].(map[string]any)
if param["name"] != "channel" || param["in"] != "query" {
t.Fatalf("expected channel query parameter, got %+v", param)
}
responses := getOp["responses"].(map[string]any)
success := responses["200"].(map[string]any)
content := success["content"].(map[string]any)
if _, ok := content["text/event-stream"]; !ok {
t.Fatal("expected text/event-stream content type for SSE response")
}
headers := success["headers"].(map[string]any)
for _, name := range []string{"Cache-Control", "Connection", "X-Accel-Buffering"} {
if _, ok := headers[name]; !ok {
t.Fatalf("expected %s header in SSE response", name)
}
}
}
func TestSpecBuilder_Good_InfoIncludesLicenseMetadata(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "Licensed test API",
Version: "1.2.3",
LicenseName: "EUPL-1.2",
LicenseURL: "https://eupl.eu/1.2/en/",
}
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)
}
info := spec["info"].(map[string]any)
license, ok := info["license"].(map[string]any)
if !ok {
t.Fatal("expected license metadata in spec info")
}
if license["name"] != "EUPL-1.2" {
t.Fatalf("expected license name EUPL-1.2, got %v", license["name"])
}
if license["url"] != "https://eupl.eu/1.2/en/" {
t.Fatalf("expected license url to be preserved, got %v", license["url"])
}
}
func TestSpecBuilder_Good_InfoIncludesSummary(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Summary: "Concise API overview",
Description: "Summary test API",
Version: "1.2.3",
}
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)
}
info := spec["info"].(map[string]any)
if info["summary"] != "Concise API overview" {
t.Fatalf("expected summary to be preserved, got %v", info["summary"])
}
}
func TestSpecBuilder_Good_InfoIncludesContactMetadata(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "Contact test API",
Version: "1.2.3",
ContactName: "API Support",
ContactURL: "https://example.com/support",
ContactEmail: "support@example.com",
}
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)
}
info := spec["info"].(map[string]any)
contact, ok := info["contact"].(map[string]any)
if !ok {
t.Fatal("expected contact metadata in spec info")
}
if contact["name"] != "API Support" {
t.Fatalf("expected contact name API Support, got %v", contact["name"])
}
if contact["url"] != "https://example.com/support" {
t.Fatalf("expected contact url to be preserved, got %v", contact["url"])
}
if contact["email"] != "support@example.com" {
t.Fatalf("expected contact email to be preserved, got %v", contact["email"])
}
}
func TestSpecBuilder_Good_InfoIncludesTermsOfService(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "Terms test API",
Version: "1.2.3",
TermsOfService: "https://example.com/terms",
}
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)
}
info := spec["info"].(map[string]any)
if info["termsOfService"] != "https://example.com/terms" {
t.Fatalf("expected termsOfService to be preserved, got %v", info["termsOfService"])
}
}
func TestSpecBuilder_Good_InfoIncludesExternalDocs(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "External docs test API",
Version: "1.2.3",
ExternalDocsDescription: "Developer guide",
ExternalDocsURL: "https://example.com/docs",
}
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)
}
externalDocs, ok := spec["externalDocs"].(map[string]any)
if !ok {
t.Fatal("expected externalDocs metadata in spec")
}
if externalDocs["description"] != "Developer guide" {
t.Fatalf("expected externalDocs description to be preserved, got %v", externalDocs["description"])
}
if externalDocs["url"] != "https://example.com/docs" {
t.Fatalf("expected externalDocs url to be preserved, got %v", externalDocs["url"])
}
}
func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "Test API",
Version: "1.0.0",
}
group := &specStubGroup{
name: "items",
basePath: "/api/items",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/list",
Summary: "List items",
Tags: []string{"items"},
Response: map[string]any{
"type": "array",
"items": map[string]any{
"type": "string",
},
},
},
{
Method: "POST",
Path: "/create",
Summary: "Create item",
Description: "Creates a new item",
Tags: []string{"items"},
RequestBody: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
},
RequestExample: map[string]any{
"name": "Widget",
},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "integer"},
},
},
ResponseExample: map[string]any{
"id": 42,
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
// Verify GET /api/items/list exists.
listPath, ok := paths["/api/items/list"]
if !ok {
t.Fatal("expected /api/items/list path in spec")
}
getOp := listPath.(map[string]any)["get"]
if getOp == nil {
t.Fatal("expected GET operation on /api/items/list")
}
if getOp.(map[string]any)["summary"] != "List items" {
t.Fatalf("expected summary='List items', got %v", getOp.(map[string]any)["summary"])
}
if getOp.(map[string]any)["operationId"] != "get_api_items_list" {
t.Fatalf("expected operationId='get_api_items_list', got %v", getOp.(map[string]any)["operationId"])
}
// Verify POST /api/items/create exists with request body.
createPath, ok := paths["/api/items/create"]
if !ok {
t.Fatal("expected /api/items/create path in spec")
}
postOp := createPath.(map[string]any)["post"]
if postOp == nil {
t.Fatal("expected POST operation on /api/items/create")
}
if postOp.(map[string]any)["summary"] != "Create item" {
t.Fatalf("expected summary='Create item', got %v", postOp.(map[string]any)["summary"])
}
if postOp.(map[string]any)["operationId"] != "post_api_items_create" {
t.Fatalf("expected operationId='post_api_items_create', got %v", postOp.(map[string]any)["operationId"])
}
if postOp.(map[string]any)["requestBody"] == nil {
t.Fatal("expected requestBody on POST /api/items/create")
}
requestBody := postOp.(map[string]any)["requestBody"].(map[string]any)
appJSON := requestBody["content"].(map[string]any)["application/json"].(map[string]any)
if appJSON["example"].(map[string]any)["name"] != "Widget" {
t.Fatalf("expected request example to be preserved, got %v", appJSON["example"])
}
responses := postOp.(map[string]any)["responses"].(map[string]any)
created := responses["200"].(map[string]any)
createdJSON := created["content"].(map[string]any)["application/json"].(map[string]any)
if createdJSON["example"].(map[string]any)["id"] != float64(42) {
t.Fatalf("expected response example to be preserved, got %v", createdJSON["example"])
}
}
func TestSpecBuilder_Good_DescribeIterGroup(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &iterStubGroup{
name: "iter",
basePath: "/api/iter",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/status",
Summary: "Iter status",
Tags: []string{"iter"},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
op := spec["paths"].(map[string]any)["/api/iter/status"].(map[string]any)["get"].(map[string]any)
if op["summary"] != "Iter status" {
t.Fatalf("expected summary='Iter status', got %v", op["summary"])
}
tags, ok := op["tags"].([]any)
if !ok || len(tags) != 1 || tags[0] != "iter" {
t.Fatalf("expected tags to be populated from DescribeIter, got %v", op["tags"])
}
}
func TestSpecBuilder_Good_DescribeIterSnapshotOnce(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &countingIterGroup{
name: "counted",
basePath: "/api/count",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/status",
Summary: "Counted status",
Tags: []string{"counted"},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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 group.describeCalls != 1 {
t.Fatalf("expected DescribeIter to be called once, got %d", group.describeCalls)
}
op := spec["paths"].(map[string]any)["/api/count/status"].(map[string]any)["get"].(map[string]any)
if op["summary"] != "Counted status" {
t.Fatalf("expected summary='Counted status', got %v", op["summary"])
}
}
func TestSpecBuilder_Good_DescribeIterNilFallsBackToDescribe(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &iterNilFallbackGroup{
name: "fallback-iter",
basePath: "/api/fallback-iter",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/status",
Summary: "Fallback status",
Tags: []string{"fallback-iter"},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
op := spec["paths"].(map[string]any)["/api/fallback-iter/status"].(map[string]any)["get"].(map[string]any)
if op["summary"] != "Fallback status" {
t.Fatalf("expected summary='Fallback status', got %v", op["summary"])
}
}
func TestSpecBuilder_Good_GroupMetadataIsSnapshottedOnce(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &snapshottingGroup{
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/status",
Summary: "Snapshot status",
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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["/alpha/status"]; !ok {
t.Fatalf("expected snapshotted path /alpha/status, got %v", paths)
}
if _, ok := paths["/beta/status"]; ok {
t.Fatal("did not expect mutated base path to leak into the spec")
}
tags := spec["tags"].([]any)
foundAlpha := false
for _, tag := range tags {
tm := tag.(map[string]any)
if tm["name"] == "alpha" {
foundAlpha = true
break
}
if tm["name"] == "beta" {
t.Fatal("did not expect mutated group name to leak into the spec")
}
}
if !foundAlpha {
t.Fatal("expected snapshotted group name in spec tags")
}
op := paths["/alpha/status"].(map[string]any)["get"].(map[string]any)
opTags, ok := op["tags"].([]any)
if !ok || len(opTags) != 1 || opTags[0] != "alpha" {
t.Fatalf("expected snapshotted operation tag alpha, got %v", op["tags"])
}
if group.nameCalls != 1 {
t.Fatalf("expected Name to be called once, got %d", group.nameCalls)
}
if group.basePathCalls != 1 {
t.Fatalf("expected BasePath to be called once, got %d", group.basePathCalls)
}
}
func TestSpecBuilder_Good_SecuredResponses(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "secure",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/private",
Summary: "Private endpoint",
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
responses := spec["paths"].(map[string]any)["/api/private"].(map[string]any)["get"].(map[string]any)["responses"].(map[string]any)
if _, ok := responses["401"]; !ok {
t.Fatal("expected 401 response in secured operation")
}
if _, ok := responses["403"]; !ok {
t.Fatal("expected 403 response in secured operation")
}
if _, ok := responses["429"]; !ok {
t.Fatal("expected 429 response in secured operation")
}
if _, ok := responses["504"]; !ok {
t.Fatal("expected 504 response in secured operation")
}
if _, ok := responses["500"]; !ok {
t.Fatal("expected 500 response in secured operation")
}
rateLimit429 := responses["429"].(map[string]any)
headers := rateLimit429["headers"].(map[string]any)
if _, ok := headers["Retry-After"]; !ok {
t.Fatal("expected Retry-After header in secured operation 429 response")
}
if _, ok := headers["X-Request-ID"]; !ok {
t.Fatal("expected X-Request-ID header in secured operation 429 response")
}
if _, ok := headers["X-RateLimit-Limit"]; !ok {
t.Fatal("expected X-RateLimit-Limit header in secured operation 429 response")
}
if _, ok := headers["X-RateLimit-Remaining"]; !ok {
t.Fatal("expected X-RateLimit-Remaining header in secured operation 429 response")
}
if _, ok := headers["X-RateLimit-Reset"]; !ok {
t.Fatal("expected X-RateLimit-Reset header in secured operation 429 response")
}
for _, code := range []string{"400", "401", "403", "504", "500"} {
resp := responses[code].(map[string]any)
respHeaders := resp["headers"].(map[string]any)
if _, ok := respHeaders["X-Request-ID"]; !ok {
t.Fatalf("expected X-Request-ID header in secured operation %s response", code)
}
if _, ok := respHeaders["X-RateLimit-Limit"]; !ok {
t.Fatalf("expected X-RateLimit-Limit header in secured operation %s response", code)
}
if _, ok := respHeaders["X-RateLimit-Remaining"]; !ok {
t.Fatalf("expected X-RateLimit-Remaining header in secured operation %s response", code)
}
if _, ok := respHeaders["X-RateLimit-Reset"]; !ok {
t.Fatalf("expected X-RateLimit-Reset header in secured operation %s response", code)
}
}
}
func TestSpecBuilder_Good_CustomSuccessStatusCode(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "items",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "POST",
Path: "/items",
Summary: "Create item",
StatusCode: http.StatusCreated,
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
responses := spec["paths"].(map[string]any)["/api/items"].(map[string]any)["post"].(map[string]any)["responses"].(map[string]any)
if _, ok := responses["201"]; !ok {
t.Fatal("expected 201 response for created operation")
}
if _, ok := responses["200"]; ok {
t.Fatal("expected 200 response to be omitted when a custom success status is declared")
}
created := responses["201"].(map[string]any)
if created["description"] != "Created" {
t.Fatalf("expected created description, got %v", created["description"])
}
if created["content"] == nil {
t.Fatal("expected content for 201 response")
}
}
func TestSpecBuilder_Good_NoContentSuccessStatusCode(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "items",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "DELETE",
Path: "/items/{id}",
Summary: "Delete item",
StatusCode: http.StatusNoContent,
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
responses := spec["paths"].(map[string]any)["/api/items/{id}"].(map[string]any)["delete"].(map[string]any)["responses"].(map[string]any)
resp204 := responses["204"].(map[string]any)
if resp204["description"] != "No content" {
t.Fatalf("expected no-content description, got %v", resp204["description"])
}
if _, ok := resp204["content"]; ok {
t.Fatal("expected no content block for 204 response")
}
}
func TestSpecBuilder_Good_RouteSecurityOverrides(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "security",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/public",
Summary: "Public endpoint",
Security: []map[string][]string{},
Response: map[string]any{
"type": "object",
},
},
{
Method: "GET",
Path: "/scoped",
Summary: "Scoped endpoint",
Security: []map[string][]string{
{
"bearerAuth": []string{},
},
{
"oauth2": []string{"read:items"},
},
},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
publicOp := paths["/api/public"].(map[string]any)["get"].(map[string]any)
publicSecurity, ok := publicOp["security"].([]any)
if !ok {
t.Fatalf("expected public security array, got %T", publicOp["security"])
}
if len(publicSecurity) != 0 {
t.Fatalf("expected public route to have empty security requirement, got %v", publicSecurity)
}
publicResponses := publicOp["responses"].(map[string]any)
if _, ok := publicResponses["401"]; ok {
t.Fatal("expected public route to omit 401 response documentation")
}
if _, ok := publicResponses["403"]; ok {
t.Fatal("expected public route to omit 403 response documentation")
}
scopedOp := paths["/api/scoped"].(map[string]any)["get"].(map[string]any)
scopedSecurity, ok := scopedOp["security"].([]any)
if !ok {
t.Fatalf("expected scoped security array, got %T", scopedOp["security"])
}
if len(scopedSecurity) != 2 {
t.Fatalf("expected 2 security requirements, got %d", len(scopedSecurity))
}
firstReq := scopedSecurity[0].(map[string]any)
if _, ok := firstReq["bearerAuth"]; !ok {
t.Fatalf("expected bearerAuth requirement, got %v", firstReq)
}
secondReq := scopedSecurity[1].(map[string]any)
if scopes, ok := secondReq["oauth2"].([]any); !ok || len(scopes) != 1 || scopes[0] != "read:items" {
t.Fatalf("expected oauth2 read:items requirement, got %v", secondReq["oauth2"])
}
}
func TestSpecBuilder_Good_EnvelopeWrapping(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "data",
basePath: "/data",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/fetch",
Summary: "Fetch data",
Tags: []string{"data"},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"value": map[string]any{"type": "string"},
},
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
fetchPath := paths["/data/fetch"].(map[string]any)
getOp := fetchPath["get"].(map[string]any)
responses := getOp["responses"].(map[string]any)
resp200 := responses["200"].(map[string]any)
headers := resp200["headers"].(map[string]any)
if _, ok := headers["X-Request-ID"]; !ok {
t.Fatal("expected X-Request-ID header on 200 response")
}
if _, ok := headers["X-RateLimit-Limit"]; !ok {
t.Fatal("expected X-RateLimit-Limit header on 200 response")
}
if _, ok := headers["X-RateLimit-Remaining"]; !ok {
t.Fatal("expected X-RateLimit-Remaining header on 200 response")
}
if _, ok := headers["X-RateLimit-Reset"]; !ok {
t.Fatal("expected X-RateLimit-Reset header on 200 response")
}
if _, ok := headers["X-Cache"]; !ok {
t.Fatal("expected X-Cache header on 200 response")
}
content := resp200["content"].(map[string]any)
appJSON := content["application/json"].(map[string]any)
schema := appJSON["schema"].(map[string]any)
if getOp["operationId"] != "get_data_fetch" {
t.Fatalf("expected operationId='get_data_fetch', got %v", getOp["operationId"])
}
// Verify envelope structure.
if schema["type"] != "object" {
t.Fatalf("expected schema type=object, got %v", schema["type"])
}
properties := schema["properties"].(map[string]any)
// Verify success field.
success := properties["success"].(map[string]any)
if success["type"] != "boolean" {
t.Fatalf("expected success.type=boolean, got %v", success["type"])
}
// Verify data field contains the original response schema.
dataField := properties["data"].(map[string]any)
if dataField["type"] != "object" {
t.Fatalf("expected data.type=object, got %v", dataField["type"])
}
dataProps := dataField["properties"].(map[string]any)
if dataProps["value"] == nil {
t.Fatal("expected data.properties.value to exist")
}
// Verify required contains "success".
required := schema["required"].([]any)
foundSuccess := false
for _, r := range required {
if r == "success" {
foundSuccess = true
break
}
}
if !foundSuccess {
t.Fatal("expected 'success' in required array")
}
}
func TestSpecBuilder_Good_OperationIDPreservesPathParams(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "users",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/users/{id}",
Summary: "Get user by id",
Tags: []string{"users"},
Response: map[string]any{
"type": "object",
},
},
{
Method: "GET",
Path: "/users/{name}",
Summary: "Get user by name",
Tags: []string{"users"},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
byID := paths["/api/users/{id}"].(map[string]any)["get"].(map[string]any)
byName := paths["/api/users/{name}"].(map[string]any)["get"].(map[string]any)
if byID["operationId"] != "get_api_users_id" {
t.Fatalf("expected operationId='get_api_users_id', got %v", byID["operationId"])
}
if byName["operationId"] != "get_api_users_name" {
t.Fatalf("expected operationId='get_api_users_name', got %v", byName["operationId"])
}
if byID["operationId"] == byName["operationId"] {
t.Fatal("expected unique operationId values for distinct path parameters")
}
}
func TestSpecBuilder_Good_RequestBodyOnDelete(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "resources",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "DELETE",
Path: "/resources/{id}",
Summary: "Delete resource",
Tags: []string{"resources"},
RequestBody: map[string]any{
"type": "object",
"properties": map[string]any{
"reason": map[string]any{"type": "string"},
},
},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
deleteOp := paths["/api/resources/{id}"].(map[string]any)["delete"].(map[string]any)
if deleteOp["requestBody"] == nil {
t.Fatal("expected requestBody on DELETE /api/resources/{id}")
}
}
func TestSpecBuilder_Good_RequestBodyOnHead(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "resources",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "HEAD",
Path: "/resources/{id}",
Summary: "Check resource",
Tags: []string{"resources"},
RequestBody: map[string]any{
"type": "object",
"properties": map[string]any{
"include": map[string]any{"type": "string"},
},
},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
headOp := paths["/api/resources/{id}"].(map[string]any)["head"].(map[string]any)
if headOp["requestBody"] == nil {
t.Fatal("expected requestBody on HEAD /api/resources/{id}")
}
}
func TestSpecBuilder_Good_RequestExampleWithoutSchema(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "resources",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "POST",
Path: "/resources",
Summary: "Create resource",
Tags: []string{"resources"},
RequestExample: map[string]any{
"name": "Example resource",
},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
postOp := spec["paths"].(map[string]any)["/api/resources"].(map[string]any)["post"].(map[string]any)
requestBody := postOp["requestBody"].(map[string]any)
appJSON := requestBody["content"].(map[string]any)["application/json"].(map[string]any)
if appJSON["example"].(map[string]any)["name"] != "Example resource" {
t.Fatalf("expected request example to be preserved, got %v", appJSON["example"])
}
schema := appJSON["schema"].(map[string]any)
if len(schema) != 0 {
t.Fatalf("expected example-only request body to use an empty schema, got %v", schema)
}
}
func TestSpecBuilder_Good_ResponseExampleWithoutSchema(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "resources",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/resources/{id}",
Summary: "Fetch resource",
Tags: []string{"resources"},
ResponseExample: map[string]any{
"name": "Example resource",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
getOp := spec["paths"].(map[string]any)["/api/resources/{id}"].(map[string]any)["get"].(map[string]any)
responses := getOp["responses"].(map[string]any)
resp200 := responses["200"].(map[string]any)
appJSON := resp200["content"].(map[string]any)["application/json"].(map[string]any)
if appJSON["example"].(map[string]any)["name"] != "Example resource" {
t.Fatalf("expected response example to be preserved, got %v", appJSON["example"])
}
schema := appJSON["schema"].(map[string]any)
properties, ok := schema["properties"].(map[string]any)
if !ok {
t.Fatalf("expected envelope schema properties, got %v", schema)
}
if _, ok := properties["data"]; !ok {
t.Fatal("expected example-only response to expose an empty data schema")
}
}
func TestSpecBuilder_Good_ResponseHeaders(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "downloads",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/exports/{id}",
Summary: "Download export",
ResponseHeaders: map[string]string{
"Content-Disposition": "Download filename suggested by the server",
"X-Export-ID": "Identifier for the generated export",
},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
responses := spec["paths"].(map[string]any)["/api/exports/{id}"].(map[string]any)["get"].(map[string]any)["responses"].(map[string]any)
resp200 := responses["200"].(map[string]any)
headers, ok := resp200["headers"].(map[string]any)
if !ok {
t.Fatalf("expected headers map, got %T", resp200["headers"])
}
header, ok := headers["Content-Disposition"].(map[string]any)
if !ok {
t.Fatal("expected Content-Disposition response header to be documented")
}
if header["description"] != "Download filename suggested by the server" {
t.Fatalf("expected header description to be preserved, got %v", header["description"])
}
schema := header["schema"].(map[string]any)
if schema["type"] != "string" {
t.Fatalf("expected response header schema type string, got %v", schema["type"])
}
errorResp := responses["500"].(map[string]any)
errorHeaders, ok := errorResp["headers"].(map[string]any)
if !ok {
t.Fatalf("expected 500 headers map, got %T", errorResp["headers"])
}
if _, ok := errorHeaders["Content-Disposition"]; !ok {
t.Fatal("expected route-specific headers on error responses too")
}
if _, ok := errorHeaders["X-Export-ID"]; !ok {
t.Fatal("expected route-specific headers on error responses too")
}
}
func TestSpecBuilder_Good_PathParameters(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "users",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/users/{id}/{slug}",
Summary: "Get user",
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
op := spec["paths"].(map[string]any)["/api/users/{id}/{slug}"].(map[string]any)["get"].(map[string]any)
params, ok := op["parameters"].([]any)
if !ok {
t.Fatalf("expected parameters array, got %T", op["parameters"])
}
if len(params) != 2 {
t.Fatalf("expected 2 path parameters, got %d", len(params))
}
first := params[0].(map[string]any)
if first["name"] != "id" {
t.Fatalf("expected first parameter name=id, got %v", first["name"])
}
if first["in"] != "path" {
t.Fatalf("expected first parameter in=path, got %v", first["in"])
}
if required, ok := first["required"].(bool); !ok || !required {
t.Fatalf("expected first parameter to be required, got %v", first["required"])
}
second := params[1].(map[string]any)
if second["name"] != "slug" {
t.Fatalf("expected second parameter name=slug, got %v", second["name"])
}
}
func TestSpecBuilder_Good_PathNormalisation(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "users",
basePath: "/api/",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "users/{id}",
Summary: "Get user",
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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/users/{id}"]; !ok {
t.Fatalf("expected normalised path /api/users/{id}, got %v", paths)
}
}
func TestSpecBuilder_Good_GinPathParameters(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "users",
basePath: "/api/",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "users/:id",
Summary: "Get user",
Response: map[string]any{
"type": "object",
},
},
{
Method: "GET",
Path: "files/*path",
Summary: "Get file",
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
userOp := paths["/api/users/{id}"].(map[string]any)["get"].(map[string]any)
userParams := userOp["parameters"].([]any)
if len(userParams) != 1 {
t.Fatalf("expected 1 parameter for gin path, got %d", len(userParams))
}
if userParams[0].(map[string]any)["name"] != "id" {
t.Fatalf("expected gin path parameter name=id, got %v", userParams[0])
}
fileOp := paths["/api/files/{path}"].(map[string]any)["get"].(map[string]any)
fileParams := fileOp["parameters"].([]any)
if len(fileParams) != 1 {
t.Fatalf("expected 1 parameter for wildcard path, got %d", len(fileParams))
}
if fileParams[0].(map[string]any)["name"] != "path" {
t.Fatalf("expected wildcard parameter name=path, got %v", fileParams[0])
}
}
func TestSpecBuilder_Good_ExplicitParameters(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "users",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/users/{id}",
Summary: "Get user",
Parameters: []api.ParameterDescription{
{
Name: "id",
In: "path",
Description: "User identifier",
Schema: map[string]any{
"type": "string",
},
},
{
Name: "verbose",
In: "query",
Description: "Include verbose details",
Schema: map[string]any{
"type": "boolean",
},
},
},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
op := spec["paths"].(map[string]any)["/api/users/{id}"].(map[string]any)["get"].(map[string]any)
params, ok := op["parameters"].([]any)
if !ok {
t.Fatalf("expected parameters array, got %T", op["parameters"])
}
if len(params) != 2 {
t.Fatalf("expected 2 parameters, got %d", len(params))
}
pathParam := params[0].(map[string]any)
if pathParam["name"] != "id" {
t.Fatalf("expected path parameter name=id, got %v", pathParam["name"])
}
if pathParam["in"] != "path" {
t.Fatalf("expected path parameter in=path, got %v", pathParam["in"])
}
if pathParam["description"] != "User identifier" {
t.Fatalf("expected merged path parameter description, got %v", pathParam["description"])
}
queryParam := params[1].(map[string]any)
if queryParam["name"] != "verbose" {
t.Fatalf("expected query parameter name=verbose, got %v", queryParam["name"])
}
if queryParam["in"] != "query" {
t.Fatalf("expected query parameter in=query, got %v", queryParam["in"])
}
if required, ok := queryParam["required"].(bool); !ok || required {
t.Fatalf("expected query parameter to be optional, got %v", queryParam["required"])
}
}
func TestSpecBuilder_Good_NonDescribableGroup(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
data, err := sb.Build([]api.RouteGroup{plainStubGroup{}})
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)
}
// Verify plainStubGroup appears in tags.
tags := spec["tags"].([]any)
foundPlain := false
for _, tag := range tags {
tm := tag.(map[string]any)
if tm["name"] == "plain" {
foundPlain = true
break
}
}
if !foundPlain {
t.Fatal("expected 'plain' tag in spec for non-describable group")
}
// Verify only /health exists in paths (plain group adds no paths).
paths := spec["paths"].(map[string]any)
if len(paths) != 1 {
t.Fatalf("expected 1 path (/health only), got %d", len(paths))
}
if _, ok := paths["/health"]; !ok {
t.Fatal("expected /health path in spec")
}
health := paths["/health"].(map[string]any)["get"].(map[string]any)
if health["operationId"] != "get_health" {
t.Fatalf("expected operationId='get_health', got %v", health["operationId"])
}
if security := health["security"]; security == nil {
t.Fatal("expected explicit public security override on /health")
}
if len(health["security"].([]any)) != 0 {
t.Fatalf("expected /health security to be empty, got %v", health["security"])
}
}
func TestSpecBuilder_Good_EmptyDescribableGroupStillAddsTag(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "empty",
basePath: "/api/empty",
descs: nil,
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
tags := spec["tags"].([]any)
foundEmpty := false
for _, tag := range tags {
tm := tag.(map[string]any)
if tm["name"] == "empty" {
foundEmpty = true
break
}
}
if !foundEmpty {
t.Fatal("expected empty describable group to appear in spec tags")
}
paths := spec["paths"].(map[string]any)
if len(paths) != 1 {
t.Fatalf("expected only /health path, got %d paths", len(paths))
}
if _, ok := paths["/health"]; !ok {
t.Fatal("expected /health path in spec")
}
}
func TestSpecBuilder_Good_DefaultTagsFromGroupName(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "fallback",
basePath: "/api/fallback",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/status",
Summary: "Check status",
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
operation := spec["paths"].(map[string]any)["/api/fallback/status"].(map[string]any)["get"].(map[string]any)
tags, ok := operation["tags"].([]any)
if !ok {
t.Fatalf("expected tags array, got %T", operation["tags"])
}
if len(tags) != 1 || tags[0] != "fallback" {
t.Fatalf("expected fallback tag from group name, got %v", operation["tags"])
}
}
func TestSpecBuilder_Good_TagsAreSortedDeterministically(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "gamma",
basePath: "/api/gamma",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/status",
Summary: "Check status",
Tags: []string{"zeta", "alpha", "beta"},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
tags, ok := spec["tags"].([]any)
if !ok {
t.Fatalf("expected tags array, got %T", spec["tags"])
}
names := make([]string, 0, len(tags))
for _, raw := range tags {
tag := raw.(map[string]any)
name, _ := tag["name"].(string)
names = append(names, name)
}
expected := []string{"system", "alpha", "beta", "gamma", "zeta"}
if len(names) != len(expected) {
t.Fatalf("expected %d tags, got %d: %v", len(expected), len(names), names)
}
for i := range expected {
if names[i] != expected[i] {
t.Fatalf("expected tag order %v, got %v", expected, names)
}
}
}
func TestSpecBuilder_Good_DeprecatedOperation(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "legacy",
basePath: "/api/legacy",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/status",
Summary: "Check legacy status",
Deprecated: true,
SunsetDate: "2025-06-01",
Replacement: "/api/v2/status",
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
op := spec["paths"].(map[string]any)["/api/legacy/status"].(map[string]any)["get"].(map[string]any)
deprecated, ok := op["deprecated"].(bool)
if !ok {
t.Fatalf("expected deprecated boolean, got %T", op["deprecated"])
}
if !deprecated {
t.Fatal("expected deprecated operation to be marked true")
}
responses := op["responses"].(map[string]any)
success := responses["200"].(map[string]any)
headers := success["headers"].(map[string]any)
for _, name := range []string{"Deprecation", "Sunset", "Link", "X-API-Warn"} {
if _, ok := headers[name]; !ok {
t.Fatalf("expected deprecation header %q in operation response headers", name)
}
}
gone, ok := responses["410"].(map[string]any)
if !ok {
t.Fatal("expected 410 Gone response for sunsetted operation")
}
if got := gone["description"]; got != "Gone" {
t.Fatalf("expected 410 response description Gone, got %v", got)
}
goneHeaders, ok := gone["headers"].(map[string]any)
if !ok {
t.Fatalf("expected 410 response headers map, got %T", gone["headers"])
}
for _, name := range []string{"Deprecation", "Sunset", "Link", "X-API-Warn"} {
if _, ok := goneHeaders[name]; !ok {
t.Fatalf("expected deprecation header %q in 410 response headers", name)
}
}
components := spec["components"].(map[string]any)
headerComponents := components["headers"].(map[string]any)
for _, name := range []string{"deprecation", "sunset", "link", "xapiwarn"} {
if _, ok := headerComponents[name]; !ok {
t.Fatalf("expected reusable header component %q", name)
}
}
deprecationHeader := headers["Deprecation"].(map[string]any)
if got := deprecationHeader["$ref"]; got != "#/components/headers/deprecation" {
t.Fatalf("expected Deprecation header to reference shared component, got %v", got)
}
}
func TestSpecBuilder_Good_BlankTagsAreIgnored(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: " ",
basePath: "/api/blank",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/status",
Summary: "Check status",
Tags: []string{"", " ", "data", "data"},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
tags := spec["tags"].([]any)
var foundData bool
for _, raw := range tags {
tag := raw.(map[string]any)
name, _ := tag["name"].(string)
if name == "" {
t.Fatal("expected blank tag names to be ignored")
}
if name == "data" {
foundData = true
}
}
if !foundData {
t.Fatal("expected data tag to be retained")
}
op := spec["paths"].(map[string]any)["/api/blank/status"].(map[string]any)["get"].(map[string]any)
opTags, ok := op["tags"].([]any)
if !ok {
t.Fatalf("expected tags array, got %T", op["tags"])
}
if len(opTags) != 1 || opTags[0] != "data" {
t.Fatalf("expected operation tags to be cleaned to [data], got %v", opTags)
}
}
func TestSpecBuilder_Good_BlankRouteTagsFallBackToGroupName(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "fallback",
basePath: "/api/fallback",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/status",
Summary: "Check status",
Tags: []string{"", " "},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
op := spec["paths"].(map[string]any)["/api/fallback/status"].(map[string]any)["get"].(map[string]any)
tags, ok := op["tags"].([]any)
if !ok {
t.Fatalf("expected tags array, got %T", op["tags"])
}
if len(tags) != 1 || tags[0] != "fallback" {
t.Fatalf("expected blank route tags to fall back to group name, got %v", tags)
}
}
func TestSpecBuilder_Good_HiddenRoutesAreOmitted(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
visible := &specStubGroup{
name: "visible",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/public",
Summary: "Public endpoint",
Tags: []string{"public"},
Response: map[string]any{
"type": "object",
},
},
{
Method: "GET",
Path: "/internal",
Summary: "Internal endpoint",
Tags: []string{"internal"},
Hidden: true,
Response: map[string]any{
"type": "object",
},
},
},
}
hidden := &specStubGroup{
name: "hidden-group",
basePath: "/api/internal",
hidden: true,
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/status",
Summary: "Hidden group endpoint",
Tags: []string{"hidden"},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{visible, hidden})
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/public"]; !ok {
t.Fatal("expected visible route to remain in the spec")
}
if _, ok := paths["/api/internal"]; ok {
t.Fatal("did not expect hidden route to appear in the spec")
}
if _, ok := paths["/api/internal/status"]; ok {
t.Fatal("did not expect hidden group routes to appear in the spec")
}
tags := spec["tags"].([]any)
foundPublic := false
foundInternal := false
foundHidden := false
foundVisibleGroup := false
foundHiddenGroup := false
for _, raw := range tags {
tag := raw.(map[string]any)
name, _ := tag["name"].(string)
switch name {
case "public":
foundPublic = true
case "internal":
foundInternal = true
case "hidden":
foundHidden = true
case "visible":
foundVisibleGroup = true
case "hidden-group":
foundHiddenGroup = true
}
}
if !foundPublic {
t.Fatal("expected public tag to remain in the spec")
}
if !foundVisibleGroup {
t.Fatal("expected visible group tag to remain in the spec")
}
if foundInternal {
t.Fatal("did not expect hidden route tag to appear in the spec")
}
if foundHidden {
t.Fatal("did not expect hidden group route tag to appear in the spec")
}
if foundHiddenGroup {
t.Fatal("did not expect hidden group tag to appear in the spec")
}
}
func TestSpecBuilder_Good_ToolBridgeIntegration(t *testing.T) {
gin.SetMode(gin.TestMode)
sb := &api.SpecBuilder{
Title: "Tool API",
Version: "1.0.0",
}
bridge := api.NewToolBridge("/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"},
},
},
OutputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"content": map[string]any{"type": "string"},
},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("ok"))
})
bridge.Add(api.ToolDescriptor{
Name: "metrics_query",
Description: "Query metrics data",
Group: "metrics",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("ok"))
})
data, err := sb.Build([]api.RouteGroup{bridge})
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)
}
tags, ok := spec["tags"].([]any)
if !ok {
t.Fatalf("expected tags array, got %T", spec["tags"])
}
expectedTags := map[string]bool{
"system": true,
"tools": true,
"files": true,
"metrics": true,
}
for _, raw := range tags {
tag := raw.(map[string]any)
name, _ := tag["name"].(string)
delete(expectedTags, name)
}
if len(expectedTags) != 0 {
t.Fatalf("expected declared tags to include system, tools, files, and metrics, missing %v", expectedTags)
}
paths := spec["paths"].(map[string]any)
// Verify POST /tools/file_read exists.
fileReadPath, ok := paths["/tools/file_read"]
if !ok {
t.Fatal("expected /tools/file_read path in spec")
}
postOp := fileReadPath.(map[string]any)["post"]
if postOp == nil {
t.Fatal("expected POST operation on /tools/file_read")
}
if postOp.(map[string]any)["summary"] != "Read a file from disk" {
t.Fatalf("expected summary='Read a file from disk', got %v", postOp.(map[string]any)["summary"])
}
if postOp.(map[string]any)["operationId"] != "post_tools_file_read" {
t.Fatalf("expected operationId='post_tools_file_read', got %v", postOp.(map[string]any)["operationId"])
}
// Verify POST /tools/metrics_query exists.
metricsPath, ok := paths["/tools/metrics_query"]
if !ok {
t.Fatal("expected /tools/metrics_query path in spec")
}
metricsOp := metricsPath.(map[string]any)["post"]
if metricsOp == nil {
t.Fatal("expected POST operation on /tools/metrics_query")
}
if metricsOp.(map[string]any)["summary"] != "Query metrics data" {
t.Fatalf("expected summary='Query metrics data', got %v", metricsOp.(map[string]any)["summary"])
}
if metricsOp.(map[string]any)["operationId"] != "post_tools_metrics_query" {
t.Fatalf("expected operationId='post_tools_metrics_query', got %v", metricsOp.(map[string]any)["operationId"])
}
// Verify request body is present on both (both are POST with InputSchema).
if postOp.(map[string]any)["requestBody"] == nil {
t.Fatal("expected requestBody on POST /tools/file_read")
}
if metricsOp.(map[string]any)["requestBody"] == nil {
t.Fatal("expected requestBody on POST /tools/metrics_query")
}
}
func TestSpecBuilder_Bad_InfoFields(t *testing.T) {
sb := &api.SpecBuilder{
Title: "MyAPI",
Description: "Test API",
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)
}
info := spec["info"].(map[string]any)
if info["title"] != "MyAPI" {
t.Fatalf("expected title=MyAPI, got %v", info["title"])
}
if info["description"] != "Test API" {
t.Fatalf("expected description='Test API', got %v", info["description"])
}
if info["version"] != "1.0.0" {
t.Fatalf("expected version=1.0.0, got %v", info["version"])
}
}
func TestSpecBuilder_Good_Servers(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
Servers: []string{
" https://api.example.com ",
"/",
"",
"https://api.example.com",
},
}
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)
}
servers, ok := spec["servers"].([]any)
if !ok {
t.Fatalf("expected servers array, got %T", spec["servers"])
}
if len(servers) != 2 {
t.Fatalf("expected 2 normalised servers, got %d", len(servers))
}
first := servers[0].(map[string]any)
if first["url"] != "https://api.example.com" {
t.Fatalf("expected first server url=%q, got %v", "https://api.example.com", first["url"])
}
second := servers[1].(map[string]any)
if second["url"] != "/" {
t.Fatalf("expected second server url=%q, got %v", "/", second["url"])
}
}
func TestSpecBuilder_Good_ServersCollapseTrailingSlashes(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
Servers: []string{
"https://api.example.com/",
"https://api.example.com",
"/api/",
"/api",
},
}
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)
}
servers, ok := spec["servers"].([]any)
if !ok {
t.Fatalf("expected servers array, got %T", spec["servers"])
}
if len(servers) != 2 {
t.Fatalf("expected 2 collapsed servers, got %d", len(servers))
}
first := servers[0].(map[string]any)
if first["url"] != "https://api.example.com" {
t.Fatalf("expected first server url=%q, got %v", "https://api.example.com", first["url"])
}
second := servers[1].(map[string]any)
if second["url"] != "/api" {
t.Fatalf("expected second server url=%q, got %v", "/api", second["url"])
}
}
func TestSpecBuilder_Good_RuntimeDebugEndpointsDocumentRateLimitHeaders(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
PprofEnabled: true,
ExpvarEnabled: 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 := spec["paths"].(map[string]any)
for _, path := range []string{"/debug/pprof", "/debug/vars"} {
item := paths[path].(map[string]any)
op := item["get"].(map[string]any)
for _, code := range []string{"200", "401", "403"} {
resp := op["responses"].(map[string]any)[code].(map[string]any)
headers := resp["headers"].(map[string]any)
for _, name := range []string{"X-Request-ID", "X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset"} {
if _, ok := headers[name]; !ok {
t.Fatalf("expected %s header on %s %s response", name, path, code)
}
}
}
}
}