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") }