From 273bc3d70a720489cddaa870edc5ac3b49966cdc Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:29:20 +0000 Subject: [PATCH] feat(openapi): document GraphQL playground endpoint Adds GraphQL Playground coverage to the generated OpenAPI spec when the GraphQL playground option is enabled, and wires the engine metadata through so runtime docs match the mounted route. Co-Authored-By: Virgil --- openapi.go | 101 ++++++++++++++++++++++++++++++++++++ openapi_test.go | 37 +++++++++++++ spec_builder_helper.go | 1 + spec_builder_helper_test.go | 5 +- 4 files changed, 143 insertions(+), 1 deletion(-) diff --git a/openapi.go b/openapi.go index 3c1c240..af327f7 100644 --- a/openapi.go +++ b/openapi.go @@ -25,6 +25,7 @@ type SpecBuilder struct { Description string Version string GraphQLPath string + GraphQLPlayground bool SSEPath string TermsOfService string ContactName string @@ -192,6 +193,10 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any { if graphqlPath := strings.TrimSpace(sb.GraphQLPath); graphqlPath != "" { graphqlPath = normaliseOpenAPIPath(graphqlPath) paths[graphqlPath] = graphqlPathItem(graphqlPath, operationIDs) + if sb.GraphQLPlayground { + playgroundPath := normaliseOpenAPIPath(graphqlPath + "/playground") + paths[playgroundPath] = graphqlPlaygroundPathItem(playgroundPath, operationIDs) + } } if ssePath := strings.TrimSpace(sb.SSEPath); ssePath != "" { @@ -693,6 +698,23 @@ func graphqlPathItem(path string, operationIDs map[string]int) map[string]any { } } +func graphqlPlaygroundPathItem(path string, operationIDs map[string]int) map[string]any { + return map[string]any{ + "get": map[string]any{ + "summary": "GraphQL playground", + "description": "Interactive GraphQL IDE for the configured schema", + "tags": []string{"graphql"}, + "operationId": operationID("get", path, operationIDs), + "security": []any{ + map[string]any{ + "bearerAuth": []any{}, + }, + }, + "responses": graphqlPlaygroundResponses(), + }, + } +} + func ssePathItem(path string, operationIDs map[string]int) map[string]any { return map[string]any{ "get": map[string]any{ @@ -939,6 +961,85 @@ func graphqlResponses() map[string]any { } } +func graphqlPlaygroundResponses() map[string]any { + successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()) + errorHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()) + + return map[string]any{ + "200": map[string]any{ + "description": "GraphQL playground", + "content": map[string]any{ + "text/html": map[string]any{ + "schema": map[string]any{ + "type": "string", + }, + }, + }, + "headers": successHeaders, + }, + "401": map[string]any{ + "description": "Unauthorised", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "additionalProperties": true, + }, + }, + }, + "headers": errorHeaders, + }, + "403": map[string]any{ + "description": "Forbidden", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "additionalProperties": true, + }, + }, + }, + "headers": errorHeaders, + }, + "429": map[string]any{ + "description": "Too many requests", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "additionalProperties": true, + }, + }, + }, + "headers": mergeHeaders(standardResponseHeaders(), rateLimitHeaders()), + }, + "500": map[string]any{ + "description": "Internal server error", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "additionalProperties": true, + }, + }, + }, + "headers": errorHeaders, + }, + "504": map[string]any{ + "description": "Gateway timeout", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "additionalProperties": true, + }, + }, + }, + "headers": errorHeaders, + }, + } +} + func sseResponses() map[string]any { successHeaders := mergeHeaders( standardResponseHeaders(), diff --git a/openapi_test.go b/openapi_test.go index 7e75c01..d233eba 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -267,6 +267,43 @@ func TestSpecBuilder_Good_GraphQLEndpoint(t *testing.T) { } } +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_ServerSentEventsEndpoint(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test", diff --git a/spec_builder_helper.go b/spec_builder_helper.go index 708de6d..72ea0a6 100644 --- a/spec_builder_helper.go +++ b/spec_builder_helper.go @@ -32,6 +32,7 @@ func (e *Engine) OpenAPISpecBuilder() *SpecBuilder { if e.graphql != nil { builder.GraphQLPath = e.graphql.path + builder.GraphQLPlayground = e.graphql.playground } if e.sseBroker != nil { builder.SSEPath = resolveSSEPath(e.ssePath) diff --git a/spec_builder_helper_test.go b/spec_builder_helper_test.go index 6958d7a..2cd28cb 100644 --- a/spec_builder_helper_test.go +++ b/spec_builder_helper_test.go @@ -22,7 +22,7 @@ func TestEngine_Good_OpenAPISpecBuilderCarriesEngineMetadata(t *testing.T) { api.WithSwaggerServers("https://api.example.com", "/", "https://api.example.com"), api.WithSwaggerLicense("EUPL-1.2", "https://eupl.eu/1.2/en/"), api.WithSwaggerExternalDocs("Developer guide", "https://example.com/docs"), - api.WithGraphQL(newTestSchema(), api.WithGraphQLPath("/gql")), + api.WithGraphQL(newTestSchema(), api.WithPlayground(), api.WithGraphQLPath("/gql")), api.WithSSE(broker), api.WithSSEPath("/events"), api.WithPprof(), @@ -106,6 +106,9 @@ func TestEngine_Good_OpenAPISpecBuilderCarriesEngineMetadata(t *testing.T) { if _, ok := paths["/gql"]; !ok { t.Fatal("expected GraphQL path from engine metadata in generated spec") } + if _, ok := paths["/gql/playground"]; !ok { + t.Fatal("expected GraphQL playground path from engine metadata in generated spec") + } if _, ok := paths["/events"]; !ok { t.Fatal("expected SSE path from engine metadata in generated spec") }