// 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 mutatingIterGroup struct { name string basePath string descs []api.RouteDescription } func (s *mutatingIterGroup) Name() string { return s.name } func (s *mutatingIterGroup) BasePath() string { return s.basePath } func (s *mutatingIterGroup) RegisterRoutes(rg *gin.RouterGroup) {} func (s *mutatingIterGroup) Describe() []api.RouteDescription { return nil } func (s *mutatingIterGroup) DescribeIter() iter.Seq[api.RouteDescription] { return func(yield func(api.RouteDescription) bool) { for i, rd := range s.descs { if !yield(rd) { return } s.descs[i].Response["mutated"] = true s.descs[i].RequestBody["mutated"] = true s.descs[i].Parameters[0].Schema["mutated"] = true s.descs[i].ResponseHeaders["X-Mutated"] = "yes" } } } 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_NormalisesMetadataAtBuild(t *testing.T) { sb := &api.SpecBuilder{ Title: " Test API ", Summary: " ", Description: " Trimmed description ", Version: " 1.2.3 ", TermsOfService: " https://example.com/terms ", ContactName: " API Support ", ContactURL: " https://example.com/support ", ContactEmail: " support@example.com ", LicenseName: " EUPL-1.2 ", LicenseURL: " https://eupl.eu/1.2/en/ ", ExternalDocsURL: " https://example.com/docs ", ExternalDocsDescription: " Developer guide ", 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) } info := spec["info"].(map[string]any) if info["title"] != "Test API" { t.Fatalf("expected trimmed title, got %v", info["title"]) } if _, ok := info["summary"]; ok { t.Fatal("expected blank summary to be omitted") } if info["description"] != "Trimmed description" { t.Fatalf("expected trimmed description, got %v", info["description"]) } if info["version"] != "1.2.3" { t.Fatalf("expected trimmed version, got %v", info["version"]) } if info["termsOfService"] != "https://example.com/terms" { t.Fatalf("expected trimmed termsOfService, got %v", info["termsOfService"]) } contact := info["contact"].(map[string]any) if contact["name"] != "API Support" { t.Fatalf("expected trimmed contact name, got %v", contact["name"]) } if contact["url"] != "https://example.com/support" { t.Fatalf("expected trimmed contact url, got %v", contact["url"]) } if contact["email"] != "support@example.com" { t.Fatalf("expected trimmed contact email, got %v", contact["email"]) } license := info["license"].(map[string]any) if license["name"] != "EUPL-1.2" { t.Fatalf("expected trimmed licence name, got %v", license["name"]) } if license["url"] != "https://eupl.eu/1.2/en/" { t.Fatalf("expected trimmed licence url, got %v", license["url"]) } externalDocs := spec["externalDocs"].(map[string]any) if externalDocs["description"] != "Developer guide" { t.Fatalf("expected trimmed external docs description, got %v", externalDocs["description"]) } if externalDocs["url"] != "https://example.com/docs" { t.Fatalf("expected trimmed external docs url, got %v", externalDocs["url"]) } if got := spec["x-swagger-ui-path"]; got != "/docs" { t.Fatalf("expected trimmed swagger path, got %v", got) } } 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_OmitsNonPositiveCacheTTLExtension(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test", Description: "Cache TTL test", Version: "1.0.0", CacheTTL: "0s", } 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 _, ok := spec["x-cache-ttl"]; ok { t.Fatal("expected non-positive cache TTL to be omitted from spec") } } 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") } if _, ok := spec["x-graphql-playground"]; ok { t.Fatal("expected x-graphql-playground to be omitted when playground is disabled") } 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, GraphQLPlaygroundPath: "/graphql/playground", } 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"]) } if got := spec["x-graphql-playground-path"]; got != "/graphql/playground" { t.Fatalf("expected x-graphql-playground-path=/graphql/playground, got %v", got) } 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_DeepClonesRouteMetadata(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test", Version: "1.0.0", } group := &mutatingIterGroup{ name: "alpha", basePath: "/api", descs: []api.RouteDescription{ { Method: "POST", Path: "/items", Summary: "Create item", Tags: []string{"items"}, Parameters: []api.ParameterDescription{ { Name: "id", In: "path", Schema: map[string]any{ "type": "string", }, }, }, RequestBody: map[string]any{ "type": "object", }, Response: map[string]any{ "type": "object", }, ResponseHeaders: map[string]string{ "X-Test": "Original header", }, }, }, } 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/items"].(map[string]any)["post"].(map[string]any) requestSchema := op["requestBody"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any) if _, ok := requestSchema["mutated"]; ok { t.Fatal("did not expect request body mutation to leak into the spec") } responses := op["responses"].(map[string]any) resp201 := responses["200"].(map[string]any) appJSON := resp201["content"].(map[string]any)["application/json"].(map[string]any) responseSchema := appJSON["schema"].(map[string]any)["properties"].(map[string]any)["data"].(map[string]any) if _, ok := responseSchema["mutated"]; ok { t.Fatal("did not expect response mutation to leak into the spec") } headers := resp201["headers"].(map[string]any) if _, ok := headers["X-Mutated"]; ok { t.Fatal("did not expect response header mutation to leak into the spec") } params := op["parameters"].([]any) pathParam := params[0].(map[string]any) schema := pathParam["schema"].(map[string]any) if _, ok := schema["mutated"]; ok { t.Fatal("did not expect parameter schema mutation to leak into the spec") } } 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_AuthentikPublicPathsMakeMatchingOperationsPublic(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test", Version: "1.0.0", AuthentikPublicPaths: []string{"/api/public"}, } group := &specStubGroup{ name: "security", basePath: "/api", descs: []api.RouteDescription{ { Method: "GET", Path: "/public", Summary: "Public endpoint", Security: []map[string][]string{{"bearerAuth": []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/public"].(map[string]any)["get"].(map[string]any) security, ok := op["security"].([]any) if !ok { t.Fatalf("expected public route security array, got %T", op["security"]) } if len(security) != 0 { t.Fatalf("expected public route to be documented without auth, got %v", security) } responses := op["responses"].(map[string]any) if _, ok := responses["401"]; ok { t.Fatal("expected public route to omit 401 response documentation") } if _, ok := responses["403"]; ok { t.Fatal("expected public route to omit 403 response documentation") } paths := spec["x-authentik-public-paths"].([]any) if len(paths) == 0 || paths[0] != "/health" { t.Fatalf("expected public path extension to include /health first, got %v", paths) } } func TestSpecBuilder_Good_AuthentikPublicPathsMakeBuiltInEndpointsPublic(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test", Version: "1.0.0", GraphQLEnabled: true, GraphQLPath: "/graphql", AuthentikPublicPaths: []string{"/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) } pathItem := spec["paths"].(map[string]any)["/graphql"].(map[string]any) for _, method := range []string{"get", "post"} { op := pathItem[method].(map[string]any) security, ok := op["security"].([]any) if !ok { t.Fatalf("expected %s security array, got %T", method, op["security"]) } if len(security) != 0 { t.Fatalf("expected %s operation to be documented without auth, got %v", method, security) } responses := op["responses"].(map[string]any) if _, ok := responses["401"]; ok { t.Fatalf("expected %s operation to omit 401 response documentation", method) } if _, ok := responses["403"]; ok { t.Fatalf("expected %s operation to omit 403 response documentation", method) } } } 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) } } } } }