From 68bf8dcaf81ec129b5ea318f8039c66ac3836251 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:17:26 +0000 Subject: [PATCH] feat(openapi): document GraphQL endpoint Co-Authored-By: Virgil --- api.go | 6 ++ openapi.go | 150 ++++++++++++++++++++++++++++++++++++++++++++++++ openapi_test.go | 56 ++++++++++++++++++ swagger.go | 3 +- swagger_test.go | 40 +++++++++++++ 5 files changed, 254 insertions(+), 1 deletion(-) diff --git a/api.go b/api.go index c5161de..959e42e 100644 --- a/api.go +++ b/api.go @@ -241,6 +241,12 @@ func (e *Engine) build() *gin.Engine { e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, + func() string { + if e.graphql == nil { + return "" + } + return e.graphql.path + }(), e.swaggerTermsOfService, e.swaggerContactName, e.swaggerContactURL, diff --git a/openapi.go b/openapi.go index ba7f9c9..2fbdc98 100644 --- a/openapi.go +++ b/openapi.go @@ -23,6 +23,7 @@ type SpecBuilder struct { Title string Description string Version string + GraphQLPath string TermsOfService string ContactName string ContactURL string @@ -184,6 +185,11 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any { }, } + if graphqlPath := strings.TrimSpace(sb.GraphQLPath); graphqlPath != "" { + graphqlPath = normaliseOpenAPIPath(graphqlPath) + paths[graphqlPath] = graphqlPathItem(graphqlPath, operationIDs) + } + for _, g := range groups { for _, rd := range g.descs { fullPath := joinOpenAPIPath(g.group.BasePath(), rd.Path) @@ -569,6 +575,14 @@ func (sb *SpecBuilder) buildTags(groups []preparedRouteGroup) []map[string]any { } seen := map[string]bool{"system": true} + if graphqlPath := strings.TrimSpace(sb.GraphQLPath); graphqlPath != "" && !seen["graphql"] { + tags = append(tags, map[string]any{ + "name": "graphql", + "description": "GraphQL endpoints", + }) + seen["graphql"] = true + } + for _, g := range groups { name := strings.TrimSpace(g.group.Name()) if name != "" && !seen[name] { @@ -597,6 +611,142 @@ func (sb *SpecBuilder) buildTags(groups []preparedRouteGroup) []map[string]any { return tags } +func graphqlPathItem(path string, operationIDs map[string]int) map[string]any { + return map[string]any{ + "post": map[string]any{ + "summary": "GraphQL query", + "description": "Executes GraphQL queries and mutations", + "tags": []string{"graphql"}, + "operationId": operationID("post", path, operationIDs), + "security": []any{ + map[string]any{ + "bearerAuth": []any{}, + }, + }, + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "application/json": map[string]any{ + "schema": graphqlRequestSchema(), + }, + }, + }, + "responses": graphqlResponses(), + }, + } +} + +func graphqlRequestSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{ + "type": "string", + }, + "variables": map[string]any{ + "type": "object", + "additionalProperties": true, + }, + "operationName": map[string]any{ + "type": "string", + }, + }, + "required": []string{"query"}, + } +} + +func graphqlResponses() map[string]any { + successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()) + errorHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()) + + return map[string]any{ + "200": map[string]any{ + "description": "GraphQL response", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "additionalProperties": true, + }, + }, + }, + "headers": successHeaders, + }, + "400": map[string]any{ + "description": "Bad request", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "additionalProperties": true, + }, + }, + }, + "headers": errorHeaders, + }, + "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, + }, + } +} + // prepareRouteGroups snapshots route descriptions once per group so iterator- // backed implementations can be consumed safely by both tag and path builders. func prepareRouteGroups(groups []RouteGroup) []preparedRouteGroup { diff --git a/openapi_test.go b/openapi_test.go index 5420a25..57f795a 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -189,6 +189,62 @@ func TestSpecBuilder_Good_EmptyGroups(t *testing.T) { } } +func TestSpecBuilder_Good_GraphQLEndpoint(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Description: "GraphQL test", + Version: "1.0.0", + GraphQLPath: "/graphql", + } + + data, err := sb.Build(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + tags := spec["tags"].([]any) + found := false + for _, tag := range tags { + tm := tag.(map[string]any) + if tm["name"] == "graphql" { + found = true + break + } + } + if !found { + t.Fatal("expected graphql tag in spec") + } + + paths := spec["paths"].(map[string]any) + pathItem, ok := paths["/graphql"].(map[string]any) + if !ok { + t.Fatal("expected /graphql path in spec") + } + + postOp := pathItem["post"].(map[string]any) + if postOp["operationId"] != "post_graphql" { + t.Fatalf("expected GraphQL operationId to be post_graphql, got %v", postOp["operationId"]) + } + + 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_InfoIncludesLicenseMetadata(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test", diff --git a/swagger.go b/swagger.go index c41f46b..9c0cb27 100644 --- a/swagger.go +++ b/swagger.go @@ -40,12 +40,13 @@ func (s *swaggerSpec) ReadDoc() string { } // registerSwagger mounts the Swagger UI and doc.json endpoint. -func registerSwagger(g *gin.Engine, title, description, version, termsOfService, contactName, contactURL, contactEmail string, servers []string, licenseName, licenseURL, externalDocsDescription, externalDocsURL string, groups []RouteGroup) { +func registerSwagger(g *gin.Engine, title, description, version, graphqlPath, termsOfService, contactName, contactURL, contactEmail string, servers []string, licenseName, licenseURL, externalDocsDescription, externalDocsURL string, groups []RouteGroup) { spec := &swaggerSpec{ builder: &SpecBuilder{ Title: title, Description: description, Version: version, + GraphQLPath: graphqlPath, TermsOfService: termsOfService, ContactName: contactName, ContactURL: contactURL, diff --git a/swagger_test.go b/swagger_test.go index 4807997..924716c 100644 --- a/swagger_test.go +++ b/swagger_test.go @@ -258,6 +258,46 @@ func TestSwagger_Good_InfoFromOptions(t *testing.T) { } } +func TestSwagger_Good_IncludesGraphQLEndpoint(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithGraphQL(newTestSchema()), api.WithSwagger("Graph API", "GraphQL docs", "1.0.0")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/swagger/doc.json") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + var doc map[string]any + if err := json.Unmarshal(body, &doc); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + paths, ok := doc["paths"].(map[string]any) + if !ok { + t.Fatal("expected paths object in swagger doc") + } + if _, ok := paths["/graphql"]; !ok { + t.Fatal("expected /graphql path in swagger doc") + } +} + func TestSwagger_Good_UsesLicenseMetadata(t *testing.T) { gin.SetMode(gin.TestMode)