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>
This commit is contained in:
Virgil 2026-04-02 13:07:05 +00:00
parent ef51d9b1c3
commit ec268c8100
2 changed files with 99 additions and 9 deletions

View file

@ -107,7 +107,7 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) {
delete(spec["info"].(map[string]any), "summary")
}
if swaggerPath := strings.TrimSpace(sb.SwaggerPath); swaggerPath != "" {
if swaggerPath := sb.effectiveSwaggerPath(); swaggerPath != "" {
spec["x-swagger-ui-path"] = normaliseSwaggerPath(swaggerPath)
}
if sb.SwaggerEnabled {
@ -120,13 +120,13 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) {
spec["x-graphql-path"] = normaliseOpenAPIPath(graphqlPath)
spec["x-graphql-playground"] = sb.GraphQLPlayground
}
if wsPath := strings.TrimSpace(sb.WSPath); wsPath != "" {
if wsPath := sb.effectiveWSPath(); wsPath != "" {
spec["x-ws-path"] = normaliseOpenAPIPath(wsPath)
}
if sb.WSEnabled {
spec["x-ws-enabled"] = true
}
if ssePath := strings.TrimSpace(sb.SSEPath); ssePath != "" {
if ssePath := sb.effectiveSSEPath(); ssePath != "" {
spec["x-sse-path"] = normaliseOpenAPIPath(ssePath)
}
if sb.SSEEnabled {
@ -279,12 +279,12 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any {
}
}
if wsPath := strings.TrimSpace(sb.WSPath); wsPath != "" {
if wsPath := sb.effectiveWSPath(); wsPath != "" {
wsPath = normaliseOpenAPIPath(wsPath)
paths[wsPath] = wsPathItem(wsPath, operationIDs)
}
if ssePath := strings.TrimSpace(sb.SSEPath); ssePath != "" {
if ssePath := sb.effectiveSSEPath(); ssePath != "" {
ssePath = normaliseOpenAPIPath(ssePath)
paths[ssePath] = ssePathItem(ssePath, operationIDs)
}
@ -800,7 +800,7 @@ func (sb *SpecBuilder) buildTags(groups []preparedRouteGroup) []map[string]any {
seen["graphql"] = true
}
if ssePath := strings.TrimSpace(sb.SSEPath); ssePath != "" && !seen["events"] {
if ssePath := sb.effectiveSSEPath(); ssePath != "" && !seen["events"] {
tags = append(tags, map[string]any{
"name": "events",
"description": "Server-Sent Events endpoints",
@ -1849,16 +1849,46 @@ func sseResponseHeaders() map[string]any {
}
}
// effectiveGraphQLPath returns the configured GraphQL path, or the default
// GraphQL path when playground mode is enabled without an explicit path.
// effectiveGraphQLPath returns the configured GraphQL path or the default
// GraphQL path when GraphQL is enabled without an explicit path.
func (sb *SpecBuilder) effectiveGraphQLPath() string {
graphqlPath := strings.TrimSpace(sb.GraphQLPath)
if graphqlPath == "" && sb.GraphQLPlayground {
if graphqlPath == "" && (sb.GraphQLEnabled || sb.GraphQLPlayground) {
return defaultGraphQLPath
}
return graphqlPath
}
// effectiveSwaggerPath returns the configured Swagger UI path or the default
// path when Swagger is enabled without an explicit override.
func (sb *SpecBuilder) effectiveSwaggerPath() string {
swaggerPath := strings.TrimSpace(sb.SwaggerPath)
if swaggerPath == "" && sb.SwaggerEnabled {
return defaultSwaggerPath
}
return swaggerPath
}
// effectiveWSPath returns the configured WebSocket path or the default path
// when WebSockets are enabled without an explicit override.
func (sb *SpecBuilder) effectiveWSPath() string {
wsPath := strings.TrimSpace(sb.WSPath)
if wsPath == "" && sb.WSEnabled {
return defaultWSPath
}
return wsPath
}
// effectiveSSEPath returns the configured SSE path or the default path when
// SSE is enabled without an explicit override.
func (sb *SpecBuilder) effectiveSSEPath() string {
ssePath := strings.TrimSpace(sb.SSEPath)
if ssePath == "" && sb.SSEEnabled {
return defaultSSEPath
}
return ssePath
}
// documentedResponseHeaders converts route-specific response header metadata
// into OpenAPI header objects.
func documentedResponseHeaders(headers map[string]string) map[string]any {

View file

@ -584,6 +584,66 @@ func TestSpecBuilder_Good_GraphQLPlaygroundDefaultsToGraphQLTag(t *testing.T) {
}
}
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",