feat(openapi): document GraphQL endpoint

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 00:17:26 +00:00
parent 9b5477c051
commit 68bf8dcaf8
5 changed files with 254 additions and 1 deletions

6
api.go
View file

@ -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,

View file

@ -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 {

View file

@ -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",

View file

@ -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,

View file

@ -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)