feat(openapi): document GraphQL endpoint
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
9b5477c051
commit
68bf8dcaf8
5 changed files with 254 additions and 1 deletions
6
api.go
6
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,
|
||||
|
|
|
|||
150
openapi.go
150
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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue