2018 lines
52 KiB
Go
2018 lines
52 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"iter"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"slices"
|
|
)
|
|
|
|
// SpecBuilder constructs an OpenAPI 3.1 specification from registered RouteGroups.
|
|
// Title, Summary, Description, Version, and optional contact/licence/terms metadata populate the
|
|
// OpenAPI info block. Top-level external documentation metadata is also supported, along with
|
|
// additive extension fields that describe runtime transport, cache, i18n, and Authentik settings.
|
|
//
|
|
// Example:
|
|
//
|
|
// builder := &api.SpecBuilder{Title: "Service", Version: "1.0.0"}
|
|
// spec, err := builder.Build(engine.Groups())
|
|
type SpecBuilder struct {
|
|
Title string
|
|
Summary string
|
|
Description string
|
|
Version string
|
|
SwaggerEnabled bool
|
|
SwaggerPath string
|
|
GraphQLEnabled bool
|
|
GraphQLPath string
|
|
GraphQLPlayground bool
|
|
WSPath string
|
|
WSEnabled bool
|
|
SSEPath string
|
|
SSEEnabled bool
|
|
TermsOfService string
|
|
ContactName string
|
|
ContactURL string
|
|
ContactEmail string
|
|
Servers []string
|
|
LicenseName string
|
|
LicenseURL string
|
|
SecuritySchemes map[string]any
|
|
ExternalDocsDescription string
|
|
ExternalDocsURL string
|
|
PprofEnabled bool
|
|
ExpvarEnabled bool
|
|
CacheEnabled bool
|
|
CacheTTL string
|
|
CacheMaxEntries int
|
|
CacheMaxBytes int
|
|
I18nDefaultLocale string
|
|
I18nSupportedLocales []string
|
|
AuthentikIssuer string
|
|
AuthentikClientID string
|
|
AuthentikTrustedProxy bool
|
|
AuthentikPublicPaths []string
|
|
}
|
|
|
|
type preparedRouteGroup struct {
|
|
name string
|
|
basePath string
|
|
descs []RouteDescription
|
|
}
|
|
|
|
const openAPIDialect = "https://spec.openapis.org/oas/3.1/dialect/base"
|
|
|
|
// Build generates the complete OpenAPI 3.1 JSON spec.
|
|
// Groups implementing DescribableGroup contribute endpoint documentation.
|
|
// Other groups are listed as tags only.
|
|
//
|
|
// Example:
|
|
//
|
|
// data, err := (&api.SpecBuilder{Title: "Service", Version: "1.0.0"}).Build(engine.Groups())
|
|
func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) {
|
|
if sb == nil {
|
|
sb = &SpecBuilder{}
|
|
}
|
|
|
|
prepared := prepareRouteGroups(groups)
|
|
|
|
spec := map[string]any{
|
|
"openapi": "3.1.0",
|
|
"jsonSchemaDialect": openAPIDialect,
|
|
"info": map[string]any{
|
|
"title": sb.Title,
|
|
"summary": sb.Summary,
|
|
"description": sb.Description,
|
|
"version": sb.Version,
|
|
},
|
|
"paths": sb.buildPaths(prepared),
|
|
"tags": sb.buildTags(prepared),
|
|
}
|
|
|
|
if sb.LicenseName != "" {
|
|
license := map[string]any{
|
|
"name": sb.LicenseName,
|
|
}
|
|
if sb.LicenseURL != "" {
|
|
license["url"] = sb.LicenseURL
|
|
}
|
|
spec["info"].(map[string]any)["license"] = license
|
|
}
|
|
if sb.Summary != "" {
|
|
spec["info"].(map[string]any)["summary"] = sb.Summary
|
|
} else {
|
|
delete(spec["info"].(map[string]any), "summary")
|
|
}
|
|
|
|
if swaggerPath := sb.effectiveSwaggerPath(); swaggerPath != "" {
|
|
spec["x-swagger-ui-path"] = normaliseSwaggerPath(swaggerPath)
|
|
}
|
|
if sb.SwaggerEnabled {
|
|
spec["x-swagger-enabled"] = true
|
|
}
|
|
if sb.GraphQLEnabled {
|
|
spec["x-graphql-enabled"] = true
|
|
}
|
|
if graphqlPath := sb.effectiveGraphQLPath(); graphqlPath != "" {
|
|
spec["x-graphql-path"] = normaliseOpenAPIPath(graphqlPath)
|
|
spec["x-graphql-playground"] = sb.GraphQLPlayground
|
|
}
|
|
if wsPath := sb.effectiveWSPath(); wsPath != "" {
|
|
spec["x-ws-path"] = normaliseOpenAPIPath(wsPath)
|
|
}
|
|
if sb.WSEnabled {
|
|
spec["x-ws-enabled"] = true
|
|
}
|
|
if ssePath := sb.effectiveSSEPath(); ssePath != "" {
|
|
spec["x-sse-path"] = normaliseOpenAPIPath(ssePath)
|
|
}
|
|
if sb.SSEEnabled {
|
|
spec["x-sse-enabled"] = true
|
|
}
|
|
if sb.PprofEnabled {
|
|
spec["x-pprof-enabled"] = true
|
|
}
|
|
if sb.ExpvarEnabled {
|
|
spec["x-expvar-enabled"] = true
|
|
}
|
|
if sb.CacheEnabled {
|
|
spec["x-cache-enabled"] = true
|
|
}
|
|
if ttl := strings.TrimSpace(sb.CacheTTL); ttl != "" {
|
|
spec["x-cache-ttl"] = ttl
|
|
}
|
|
if sb.CacheMaxEntries > 0 {
|
|
spec["x-cache-max-entries"] = sb.CacheMaxEntries
|
|
}
|
|
if sb.CacheMaxBytes > 0 {
|
|
spec["x-cache-max-bytes"] = sb.CacheMaxBytes
|
|
}
|
|
if locale := strings.TrimSpace(sb.I18nDefaultLocale); locale != "" {
|
|
spec["x-i18n-default-locale"] = locale
|
|
}
|
|
if len(sb.I18nSupportedLocales) > 0 {
|
|
spec["x-i18n-supported-locales"] = slices.Clone(sb.I18nSupportedLocales)
|
|
}
|
|
if issuer := strings.TrimSpace(sb.AuthentikIssuer); issuer != "" {
|
|
spec["x-authentik-issuer"] = issuer
|
|
}
|
|
if clientID := strings.TrimSpace(sb.AuthentikClientID); clientID != "" {
|
|
spec["x-authentik-client-id"] = clientID
|
|
}
|
|
if sb.AuthentikTrustedProxy {
|
|
spec["x-authentik-trusted-proxy"] = true
|
|
}
|
|
if len(sb.AuthentikPublicPaths) > 0 {
|
|
spec["x-authentik-public-paths"] = slices.Clone(sb.AuthentikPublicPaths)
|
|
}
|
|
|
|
if sb.TermsOfService != "" {
|
|
spec["info"].(map[string]any)["termsOfService"] = sb.TermsOfService
|
|
}
|
|
|
|
if sb.ContactName != "" || sb.ContactURL != "" || sb.ContactEmail != "" {
|
|
contact := map[string]any{}
|
|
if sb.ContactName != "" {
|
|
contact["name"] = sb.ContactName
|
|
}
|
|
if sb.ContactURL != "" {
|
|
contact["url"] = sb.ContactURL
|
|
}
|
|
if sb.ContactEmail != "" {
|
|
contact["email"] = sb.ContactEmail
|
|
}
|
|
spec["info"].(map[string]any)["contact"] = contact
|
|
}
|
|
|
|
if servers := normaliseServers(sb.Servers); len(servers) > 0 {
|
|
out := make([]map[string]any, 0, len(servers))
|
|
for _, server := range servers {
|
|
out = append(out, map[string]any{"url": server})
|
|
}
|
|
spec["servers"] = out
|
|
}
|
|
|
|
if sb.ExternalDocsURL != "" {
|
|
externalDocs := map[string]any{
|
|
"url": sb.ExternalDocsURL,
|
|
}
|
|
if sb.ExternalDocsDescription != "" {
|
|
externalDocs["description"] = sb.ExternalDocsDescription
|
|
}
|
|
spec["externalDocs"] = externalDocs
|
|
}
|
|
|
|
// Add component schemas for the response envelope.
|
|
spec["components"] = map[string]any{
|
|
"schemas": map[string]any{
|
|
"Response": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"success": map[string]any{"type": "boolean"},
|
|
"data": map[string]any{},
|
|
"error": map[string]any{
|
|
"$ref": "#/components/schemas/Error",
|
|
},
|
|
"meta": map[string]any{
|
|
"$ref": "#/components/schemas/Meta",
|
|
},
|
|
},
|
|
"required": []string{"success"},
|
|
},
|
|
"Error": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"code": map[string]any{"type": "string"},
|
|
"message": map[string]any{"type": "string"},
|
|
"details": map[string]any{},
|
|
},
|
|
"required": []string{"code", "message"},
|
|
},
|
|
"Meta": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"request_id": map[string]any{"type": "string"},
|
|
"duration": map[string]any{"type": "string"},
|
|
"page": map[string]any{"type": "integer"},
|
|
"per_page": map[string]any{"type": "integer"},
|
|
"total": map[string]any{"type": "integer"},
|
|
},
|
|
},
|
|
},
|
|
"securitySchemes": securitySchemeComponents(sb.SecuritySchemes),
|
|
"headers": deprecationHeaderComponents(),
|
|
"responses": responseComponents(),
|
|
}
|
|
|
|
return json.MarshalIndent(spec, "", " ")
|
|
}
|
|
|
|
// BuildIter generates the complete OpenAPI 3.1 JSON spec from a route-group
|
|
// iterator. The iterator is snapshotted before building so the result stays
|
|
// stable even if the source changes during rendering.
|
|
//
|
|
// Example:
|
|
//
|
|
// data, err := (&api.SpecBuilder{Title: "Service"}).BuildIter(api.RegisteredSpecGroupsIter())
|
|
func (sb *SpecBuilder) BuildIter(groups iter.Seq[RouteGroup]) ([]byte, error) {
|
|
if sb == nil {
|
|
sb = &SpecBuilder{}
|
|
}
|
|
|
|
return sb.Build(collectRouteGroups(groups))
|
|
}
|
|
|
|
// buildPaths generates the paths object from all DescribableGroups.
|
|
func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any {
|
|
operationIDs := map[string]int{}
|
|
paths := map[string]any{
|
|
// Built-in health endpoint.
|
|
"/health": map[string]any{
|
|
"get": map[string]any{
|
|
"summary": "Health check",
|
|
"description": "Returns server health status",
|
|
"tags": []string{"system"},
|
|
"operationId": operationID("get", "/health", operationIDs),
|
|
"responses": healthResponses(),
|
|
},
|
|
},
|
|
}
|
|
|
|
graphqlPath := sb.effectiveGraphQLPath()
|
|
if graphqlPath != "" {
|
|
graphqlPath = normaliseOpenAPIPath(graphqlPath)
|
|
paths[graphqlPath] = graphqlPathItem(graphqlPath, operationIDs)
|
|
if sb.GraphQLPlayground {
|
|
playgroundPath := normaliseOpenAPIPath(graphqlPath + "/playground")
|
|
paths[playgroundPath] = graphqlPlaygroundPathItem(playgroundPath, operationIDs)
|
|
}
|
|
}
|
|
|
|
if wsPath := sb.effectiveWSPath(); wsPath != "" {
|
|
wsPath = normaliseOpenAPIPath(wsPath)
|
|
paths[wsPath] = wsPathItem(wsPath, operationIDs)
|
|
}
|
|
|
|
if ssePath := sb.effectiveSSEPath(); ssePath != "" {
|
|
ssePath = normaliseOpenAPIPath(ssePath)
|
|
paths[ssePath] = ssePathItem(ssePath, operationIDs)
|
|
}
|
|
|
|
if sb.PprofEnabled {
|
|
paths["/debug/pprof"] = pprofPathItem(operationIDs)
|
|
}
|
|
|
|
if sb.ExpvarEnabled {
|
|
paths["/debug/vars"] = expvarPathItem(operationIDs)
|
|
}
|
|
|
|
for _, g := range groups {
|
|
for _, rd := range g.descs {
|
|
fullPath := joinOpenAPIPath(g.basePath, rd.Path)
|
|
method := strings.ToLower(rd.Method)
|
|
deprecated := rd.Deprecated || strings.TrimSpace(rd.SunsetDate) != "" || strings.TrimSpace(rd.Replacement) != ""
|
|
deprecationHeaders := deprecationResponseHeaders(deprecated, rd.SunsetDate, rd.Replacement)
|
|
|
|
operation := map[string]any{
|
|
"summary": rd.Summary,
|
|
"description": rd.Description,
|
|
"operationId": operationID(method, fullPath, operationIDs),
|
|
"responses": operationResponses(method, rd.StatusCode, rd.Response, rd.ResponseExample, rd.ResponseHeaders, rd.Security, deprecated, rd.SunsetDate, rd.Replacement, deprecationHeaders),
|
|
}
|
|
if deprecated {
|
|
operation["deprecated"] = true
|
|
}
|
|
if rd.Security != nil {
|
|
operation["security"] = rd.Security
|
|
} else {
|
|
operation["security"] = []any{
|
|
map[string]any{
|
|
"bearerAuth": []any{},
|
|
},
|
|
}
|
|
}
|
|
if tags := resolvedOperationTags(g.name, rd); len(tags) > 0 {
|
|
operation["tags"] = tags
|
|
}
|
|
|
|
if params := pathParameters(fullPath); len(params) > 0 {
|
|
operation["parameters"] = params
|
|
}
|
|
if explicit := operationParameters(rd.Parameters); len(explicit) > 0 {
|
|
operation["parameters"] = mergeOperationParameters(operation["parameters"], explicit)
|
|
}
|
|
|
|
// Add request body for methods that accept one.
|
|
// The contract only excludes GET; other verbs may legitimately carry bodies.
|
|
// An example-only request body still produces a documented payload so
|
|
// callers can see the expected shape even when a schema is omitted.
|
|
if method != "get" && (rd.RequestBody != nil || rd.RequestExample != nil) {
|
|
requestSchema := rd.RequestBody
|
|
if requestSchema == nil {
|
|
requestSchema = map[string]any{}
|
|
}
|
|
requestMediaType := map[string]any{
|
|
"schema": requestSchema,
|
|
}
|
|
if rd.RequestExample != nil {
|
|
requestMediaType["example"] = rd.RequestExample
|
|
}
|
|
|
|
operation["requestBody"] = map[string]any{
|
|
"required": true,
|
|
"content": map[string]any{
|
|
"application/json": requestMediaType,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Create or extend path item.
|
|
if existing, exists := paths[fullPath]; exists {
|
|
existing.(map[string]any)[method] = operation
|
|
} else {
|
|
paths[fullPath] = map[string]any{
|
|
method: operation,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The built-in health check remains public, so override the inherited
|
|
// default security requirement with an explicit empty array.
|
|
if health, ok := paths["/health"].(map[string]any); ok {
|
|
if op, ok := health["get"].(map[string]any); ok {
|
|
op["security"] = []any{}
|
|
}
|
|
}
|
|
|
|
return paths
|
|
}
|
|
|
|
// joinOpenAPIPath normalises a base path and relative route path into a single
|
|
// OpenAPI path without duplicate or missing separators. Gin-style parameters
|
|
// such as :id and *path are converted to OpenAPI template parameters.
|
|
func joinOpenAPIPath(basePath, routePath string) string {
|
|
basePath = strings.TrimSpace(basePath)
|
|
routePath = strings.TrimSpace(routePath)
|
|
|
|
if basePath == "" {
|
|
basePath = "/"
|
|
}
|
|
if routePath == "" || routePath == "/" {
|
|
return normaliseOpenAPIPath(basePath)
|
|
}
|
|
|
|
basePath = normaliseOpenAPIPath(basePath)
|
|
routePath = normaliseOpenAPIPath(routePath)
|
|
|
|
if basePath == "/" {
|
|
return routePath
|
|
}
|
|
|
|
return strings.TrimRight(basePath, "/") + "/" + strings.TrimPrefix(routePath, "/")
|
|
}
|
|
|
|
// normaliseOpenAPIPath trims whitespace and collapses trailing separators
|
|
// while preserving the root path and converting Gin-style path parameters.
|
|
func normaliseOpenAPIPath(path string) string {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return "/"
|
|
}
|
|
|
|
segments := strings.Split(path, "/")
|
|
cleaned := make([]string, 0, len(segments))
|
|
for _, segment := range segments {
|
|
segment = strings.TrimSpace(segment)
|
|
if segment == "" {
|
|
continue
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(segment, ":") && len(segment) > 1:
|
|
segment = "{" + segment[1:] + "}"
|
|
case strings.HasPrefix(segment, "*") && len(segment) > 1:
|
|
segment = "{" + segment[1:] + "}"
|
|
}
|
|
cleaned = append(cleaned, segment)
|
|
}
|
|
|
|
if len(cleaned) == 0 {
|
|
return "/"
|
|
}
|
|
|
|
return "/" + strings.Join(cleaned, "/")
|
|
}
|
|
|
|
// operationResponses builds the standard response set for a documented API
|
|
// operation. The framework always exposes the common envelope responses, plus
|
|
// middleware-driven 429 and 504 errors.
|
|
func operationResponses(method string, statusCode int, dataSchema map[string]any, example any, responseHeaders map[string]string, security []map[string][]string, deprecated bool, sunsetDate, replacement string, deprecationHeaders map[string]any) map[string]any {
|
|
documentedHeaders := documentedResponseHeaders(responseHeaders)
|
|
successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders(), deprecationHeaders, documentedHeaders)
|
|
if method == "get" {
|
|
successHeaders = mergeHeaders(successHeaders, cacheSuccessHeaders())
|
|
}
|
|
|
|
isPublic := security != nil && len(security) == 0
|
|
errorHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders(), deprecationHeaders, documentedHeaders)
|
|
|
|
code := successStatusCode(statusCode)
|
|
if dataSchema == nil && example != nil {
|
|
dataSchema = map[string]any{}
|
|
}
|
|
successResponse := map[string]any{
|
|
"description": successResponseDescription(code),
|
|
"headers": successHeaders,
|
|
}
|
|
if !isNoContentStatus(code) {
|
|
content := map[string]any{
|
|
"schema": envelopeSchema(dataSchema),
|
|
}
|
|
if example != nil {
|
|
// Example payloads are optional, but when a route provides one we
|
|
// expose it alongside the schema so generated docs stay useful.
|
|
content["example"] = example
|
|
}
|
|
|
|
successResponse["content"] = map[string]any{
|
|
"application/json": content,
|
|
}
|
|
}
|
|
|
|
responses := map[string]any{
|
|
strconv.Itoa(code): successResponse,
|
|
"400": map[string]any{
|
|
"description": "Bad request",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": errorHeaders,
|
|
},
|
|
"429": map[string]any{
|
|
"description": "Too many requests",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": mergeHeaders(standardResponseHeaders(), rateLimitHeaders(), deprecationHeaders, documentedHeaders),
|
|
},
|
|
"504": map[string]any{
|
|
"description": "Gateway timeout",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": errorHeaders,
|
|
},
|
|
"500": map[string]any{
|
|
"description": "Internal server error",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": errorHeaders,
|
|
},
|
|
}
|
|
|
|
if deprecated && (strings.TrimSpace(sunsetDate) != "" || strings.TrimSpace(replacement) != "") {
|
|
responses["410"] = map[string]any{
|
|
"description": "Gone",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": errorHeaders,
|
|
}
|
|
}
|
|
|
|
if !isPublic {
|
|
responses["401"] = map[string]any{
|
|
"description": "Unauthorised",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": errorHeaders,
|
|
}
|
|
responses["403"] = map[string]any{
|
|
"description": "Forbidden",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": errorHeaders,
|
|
}
|
|
}
|
|
|
|
return responses
|
|
}
|
|
|
|
func successStatusCode(statusCode int) int {
|
|
if statusCode < 200 || statusCode > 299 {
|
|
return http.StatusOK
|
|
}
|
|
if statusCode == 0 {
|
|
return http.StatusOK
|
|
}
|
|
return statusCode
|
|
}
|
|
|
|
func isNoContentStatus(statusCode int) bool {
|
|
switch statusCode {
|
|
case http.StatusNoContent, http.StatusResetContent:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func successResponseDescription(statusCode int) string {
|
|
switch statusCode {
|
|
case http.StatusCreated:
|
|
return "Created"
|
|
case http.StatusAccepted:
|
|
return "Accepted"
|
|
case http.StatusNoContent:
|
|
return "No content"
|
|
case http.StatusResetContent:
|
|
return "Reset content"
|
|
default:
|
|
return "Successful response"
|
|
}
|
|
}
|
|
|
|
// healthResponses builds the response set for the built-in health endpoint.
|
|
// It stays public, but rate limiting and timeouts can still apply.
|
|
func healthResponses() map[string]any {
|
|
return map[string]any{
|
|
"200": map[string]any{
|
|
"description": "Server is healthy",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(map[string]any{"type": "string"}),
|
|
},
|
|
},
|
|
"headers": mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders(), cacheSuccessHeaders()),
|
|
},
|
|
"429": map[string]any{
|
|
"description": "Too many requests",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": mergeHeaders(standardResponseHeaders(), rateLimitHeaders()),
|
|
},
|
|
"504": map[string]any{
|
|
"description": "Gateway timeout",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()),
|
|
},
|
|
"500": map[string]any{
|
|
"description": "Internal server error",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()),
|
|
},
|
|
}
|
|
}
|
|
|
|
// deprecationResponseHeaders documents the standard deprecation headers for
|
|
// deprecated or sunsetted operations.
|
|
func deprecationResponseHeaders(deprecated bool, sunsetDate, replacement string) map[string]any {
|
|
sunsetDate = strings.TrimSpace(sunsetDate)
|
|
replacement = strings.TrimSpace(replacement)
|
|
|
|
if !deprecated && sunsetDate == "" && replacement == "" {
|
|
return nil
|
|
}
|
|
|
|
headers := map[string]any{
|
|
"Deprecation": map[string]any{
|
|
"$ref": "#/components/headers/deprecation",
|
|
},
|
|
"X-API-Warn": map[string]any{
|
|
"$ref": "#/components/headers/xapiwarn",
|
|
},
|
|
}
|
|
|
|
if sunsetDate != "" {
|
|
headers["Sunset"] = map[string]any{
|
|
"$ref": "#/components/headers/sunset",
|
|
}
|
|
}
|
|
|
|
if replacement != "" {
|
|
headers["Link"] = map[string]any{
|
|
"$ref": "#/components/headers/link",
|
|
}
|
|
}
|
|
|
|
return headers
|
|
}
|
|
|
|
// deprecationHeaderComponents returns reusable OpenAPI header components for
|
|
// the standard deprecation and sunset middleware headers.
|
|
func deprecationHeaderComponents() map[string]any {
|
|
return map[string]any{
|
|
"deprecation": map[string]any{
|
|
"description": "Indicates that the endpoint is deprecated.",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
"enum": []string{"true"},
|
|
},
|
|
},
|
|
"sunset": map[string]any{
|
|
"description": "The date and time after which the endpoint will no longer be supported.",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
"format": "date-time",
|
|
},
|
|
},
|
|
"link": map[string]any{
|
|
"description": "Reference to the successor endpoint, when one is provided.",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
"xapiwarn": map[string]any{
|
|
"description": "Human-readable deprecation warning for clients.",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// responseComponents returns reusable OpenAPI response objects for the
|
|
// common error cases exposed by the framework. The path operations still
|
|
// inline their concrete headers so existing callers keep the same output,
|
|
// but these components make the response catalogue available for reuse.
|
|
func responseComponents() map[string]any {
|
|
return map[string]any{
|
|
"BadRequest": map[string]any{
|
|
"description": "Bad request",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": standardResponseHeaders(),
|
|
},
|
|
"Unauthorized": map[string]any{
|
|
"description": "Unauthorised",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": standardResponseHeaders(),
|
|
},
|
|
"Forbidden": map[string]any{
|
|
"description": "Forbidden",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": standardResponseHeaders(),
|
|
},
|
|
"RateLimitExceeded": map[string]any{
|
|
"description": "Too many requests",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": mergeHeaders(standardResponseHeaders(), rateLimitHeaders()),
|
|
},
|
|
"GatewayTimeout": map[string]any{
|
|
"description": "Gateway timeout",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": standardResponseHeaders(),
|
|
},
|
|
"InternalServerError": map[string]any{
|
|
"description": "Internal server error",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": standardResponseHeaders(),
|
|
},
|
|
"Gone": map[string]any{
|
|
"description": "Gone",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": envelopeSchema(nil),
|
|
},
|
|
},
|
|
"headers": mergeHeaders(standardResponseHeaders(), deprecationResponseHeaders(true, "", "")),
|
|
},
|
|
}
|
|
}
|
|
|
|
// securitySchemeComponents builds the OpenAPI security scheme registry.
|
|
// bearerAuth stays available by default, while callers can add or override
|
|
// additional scheme definitions for custom security requirements.
|
|
func securitySchemeComponents(overrides map[string]any) map[string]any {
|
|
schemes := map[string]any{
|
|
"bearerAuth": map[string]any{
|
|
"type": "http",
|
|
"scheme": "bearer",
|
|
"bearerFormat": "JWT",
|
|
},
|
|
}
|
|
|
|
for name, scheme := range overrides {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || scheme == nil {
|
|
continue
|
|
}
|
|
schemes[name] = cloneOpenAPIValue(scheme)
|
|
}
|
|
|
|
return schemes
|
|
}
|
|
|
|
// buildTags generates the tags array from all RouteGroups.
|
|
func (sb *SpecBuilder) buildTags(groups []preparedRouteGroup) []map[string]any {
|
|
tags := []map[string]any{
|
|
{"name": "system", "description": "System endpoints"},
|
|
}
|
|
seen := map[string]bool{"system": true}
|
|
|
|
if graphqlPath := sb.effectiveGraphQLPath(); graphqlPath != "" && !seen["graphql"] {
|
|
tags = append(tags, map[string]any{
|
|
"name": "graphql",
|
|
"description": "GraphQL endpoints",
|
|
})
|
|
seen["graphql"] = true
|
|
}
|
|
|
|
if ssePath := sb.effectiveSSEPath(); ssePath != "" && !seen["events"] {
|
|
tags = append(tags, map[string]any{
|
|
"name": "events",
|
|
"description": "Server-Sent Events endpoints",
|
|
})
|
|
seen["events"] = true
|
|
}
|
|
|
|
if (sb.PprofEnabled || sb.ExpvarEnabled) && !seen["debug"] {
|
|
tags = append(tags, map[string]any{
|
|
"name": "debug",
|
|
"description": "Runtime debug endpoints",
|
|
})
|
|
seen["debug"] = true
|
|
}
|
|
|
|
for _, g := range groups {
|
|
name := strings.TrimSpace(g.name)
|
|
if name != "" && !seen[name] {
|
|
tags = append(tags, map[string]any{
|
|
"name": name,
|
|
"description": name + " endpoints",
|
|
})
|
|
seen[name] = true
|
|
}
|
|
|
|
for _, rd := range g.descs {
|
|
for _, tag := range rd.Tags {
|
|
tag = strings.TrimSpace(tag)
|
|
if tag == "" || seen[tag] {
|
|
continue
|
|
}
|
|
tags = append(tags, map[string]any{
|
|
"name": tag,
|
|
"description": tag + " endpoints",
|
|
})
|
|
seen[tag] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
sortTags(tags)
|
|
|
|
return tags
|
|
}
|
|
|
|
// sortTags keeps system first and orders the remaining tags alphabetically so
|
|
// generated specs stay stable across registration order changes.
|
|
func sortTags(tags []map[string]any) {
|
|
if len(tags) < 2 {
|
|
return
|
|
}
|
|
|
|
sort.SliceStable(tags, func(i, j int) bool {
|
|
left, _ := tags[i]["name"].(string)
|
|
right, _ := tags[j]["name"].(string)
|
|
|
|
switch {
|
|
case left == "system":
|
|
return true
|
|
case right == "system":
|
|
return false
|
|
default:
|
|
return left < right
|
|
}
|
|
})
|
|
}
|
|
|
|
func graphqlPathItem(path string, operationIDs map[string]int) map[string]any {
|
|
return map[string]any{
|
|
"get": map[string]any{
|
|
"summary": "GraphQL query",
|
|
"description": "Executes GraphQL queries over GET using query parameters",
|
|
"tags": []string{"graphql"},
|
|
"operationId": operationID("get", path, operationIDs),
|
|
"security": []any{
|
|
map[string]any{
|
|
"bearerAuth": []any{},
|
|
},
|
|
},
|
|
"parameters": graphqlQueryParameters(),
|
|
"responses": graphqlResponses(),
|
|
},
|
|
"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 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 wsPathItem(path string, operationIDs map[string]int) map[string]any {
|
|
return map[string]any{
|
|
"get": map[string]any{
|
|
"summary": "WebSocket connection",
|
|
"description": "Upgrades the connection to a WebSocket stream",
|
|
"tags": []string{"system"},
|
|
"operationId": operationID("get", path, operationIDs),
|
|
"security": []any{
|
|
map[string]any{
|
|
"bearerAuth": []any{},
|
|
},
|
|
},
|
|
"responses": wsResponses(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func ssePathItem(path string, operationIDs map[string]int) map[string]any {
|
|
return map[string]any{
|
|
"get": map[string]any{
|
|
"summary": "Server-Sent Events stream",
|
|
"description": "Streams published events as text/event-stream",
|
|
"tags": []string{"events"},
|
|
"operationId": operationID("get", path, operationIDs),
|
|
"security": []any{
|
|
map[string]any{
|
|
"bearerAuth": []any{},
|
|
},
|
|
},
|
|
"parameters": []map[string]any{
|
|
{
|
|
"name": "channel",
|
|
"in": "query",
|
|
"required": false,
|
|
"description": "Restrict the stream to a specific channel",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
},
|
|
"responses": sseResponses(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func wsResponses() map[string]any {
|
|
successHeaders := mergeHeaders(
|
|
standardResponseHeaders(),
|
|
rateLimitSuccessHeaders(),
|
|
wsUpgradeHeaders(),
|
|
)
|
|
errorHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders())
|
|
|
|
return map[string]any{
|
|
"101": map[string]any{
|
|
"description": "Switching protocols",
|
|
"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 wsUpgradeHeaders() map[string]any {
|
|
return map[string]any{
|
|
"Upgrade": map[string]any{
|
|
"description": "Indicates that the connection has switched to WebSocket",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
"Connection": map[string]any{
|
|
"description": "Keeps the upgraded connection open",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
"Sec-WebSocket-Accept": map[string]any{
|
|
"description": "Validates the WebSocket handshake",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func pprofPathItem(operationIDs map[string]int) map[string]any {
|
|
successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders())
|
|
errorHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders())
|
|
|
|
return map[string]any{
|
|
"get": map[string]any{
|
|
"summary": "pprof index",
|
|
"description": "Lists the available Go runtime profiles",
|
|
"tags": []string{"debug"},
|
|
"operationId": operationID("get", "/debug/pprof", operationIDs),
|
|
"security": []any{
|
|
map[string]any{
|
|
"bearerAuth": []any{},
|
|
},
|
|
},
|
|
"responses": map[string]any{
|
|
"200": map[string]any{
|
|
"description": "pprof index",
|
|
"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,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func expvarPathItem(operationIDs map[string]int) map[string]any {
|
|
successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders())
|
|
errorHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders())
|
|
|
|
return map[string]any{
|
|
"get": map[string]any{
|
|
"summary": "Runtime metrics",
|
|
"description": "Returns expvar metrics as JSON",
|
|
"tags": []string{"debug"},
|
|
"operationId": operationID("get", "/debug/vars", operationIDs),
|
|
"security": []any{
|
|
map[string]any{
|
|
"bearerAuth": []any{},
|
|
},
|
|
},
|
|
"responses": map[string]any{
|
|
"200": map[string]any{
|
|
"description": "Runtime metrics",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": map[string]any{
|
|
"type": "object",
|
|
"additionalProperties": true,
|
|
},
|
|
},
|
|
},
|
|
"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,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
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 graphqlQueryParameters() []map[string]any {
|
|
return []map[string]any{
|
|
{
|
|
"name": "query",
|
|
"in": "query",
|
|
"required": true,
|
|
"description": "GraphQL query or mutation document",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
{
|
|
"name": "variables",
|
|
"in": "query",
|
|
"required": false,
|
|
"description": "JSON-encoded GraphQL variables",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
{
|
|
"name": "operationName",
|
|
"in": "query",
|
|
"required": false,
|
|
"description": "Operation name to execute",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func graphqlResponses() map[string]any {
|
|
successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders(), cacheSuccessHeaders())
|
|
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,
|
|
},
|
|
}
|
|
}
|
|
|
|
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(),
|
|
rateLimitSuccessHeaders(),
|
|
sseResponseHeaders(),
|
|
)
|
|
errorHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders())
|
|
|
|
return map[string]any{
|
|
"200": map[string]any{
|
|
"description": "Event stream",
|
|
"content": map[string]any{
|
|
"text/event-stream": 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,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
if len(groups) == 0 {
|
|
return nil
|
|
}
|
|
|
|
out := make([]preparedRouteGroup, 0, len(groups))
|
|
for _, g := range groups {
|
|
if g == nil {
|
|
continue
|
|
}
|
|
if isHiddenRouteGroup(g) {
|
|
continue
|
|
}
|
|
out = append(out, preparedRouteGroup{
|
|
name: g.Name(),
|
|
basePath: g.BasePath(),
|
|
descs: collectRouteDescriptions(g),
|
|
})
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func collectRouteGroups(groups iter.Seq[RouteGroup]) []RouteGroup {
|
|
if groups == nil {
|
|
return nil
|
|
}
|
|
|
|
out := make([]RouteGroup, 0)
|
|
for group := range groups {
|
|
out = append(out, group)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func collectRouteDescriptions(g RouteGroup) []RouteDescription {
|
|
descIter := routeDescriptions(g)
|
|
if descIter == nil {
|
|
return nil
|
|
}
|
|
|
|
descs := make([]RouteDescription, 0)
|
|
for rd := range descIter {
|
|
if rd.Hidden {
|
|
continue
|
|
}
|
|
descs = append(descs, rd)
|
|
}
|
|
|
|
return descs
|
|
}
|
|
|
|
func isHiddenRouteGroup(g RouteGroup) bool {
|
|
type hiddenRouteGroup interface {
|
|
Hidden() bool
|
|
}
|
|
|
|
hg, ok := g.(hiddenRouteGroup)
|
|
return ok && hg.Hidden()
|
|
}
|
|
|
|
// routeDescriptions returns OpenAPI route descriptions for a group.
|
|
// Iterator-backed implementations are preferred when available so builders
|
|
// can avoid slice allocation.
|
|
func routeDescriptions(g RouteGroup) iter.Seq[RouteDescription] {
|
|
if dg, ok := g.(DescribableGroupIter); ok {
|
|
if descIter := dg.DescribeIter(); descIter != nil {
|
|
return descIter
|
|
}
|
|
}
|
|
if dg, ok := g.(DescribableGroup); ok {
|
|
descs := dg.Describe()
|
|
return func(yield func(RouteDescription) bool) {
|
|
for _, rd := range descs {
|
|
if !yield(rd) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pathParameters extracts unique OpenAPI path parameters from a path template.
|
|
// Parameters are returned in the order they appear in the path.
|
|
func pathParameters(path string) []map[string]any {
|
|
const (
|
|
open = '{'
|
|
close = '}'
|
|
)
|
|
|
|
seen := map[string]bool{}
|
|
params := make([]map[string]any, 0)
|
|
|
|
for i := 0; i < len(path); i++ {
|
|
if path[i] != open {
|
|
continue
|
|
}
|
|
end := strings.IndexByte(path[i+1:], close)
|
|
if end < 0 {
|
|
continue
|
|
}
|
|
name := path[i+1 : i+1+end]
|
|
if name == "" || strings.ContainsAny(name, "/{}") || seen[name] {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
params = append(params, map[string]any{
|
|
"name": name,
|
|
"in": "path",
|
|
"required": true,
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
})
|
|
i += end + 1
|
|
}
|
|
|
|
return params
|
|
}
|
|
|
|
// operationParameters converts explicit route parameter descriptions into
|
|
// OpenAPI parameter objects.
|
|
func operationParameters(params []ParameterDescription) []map[string]any {
|
|
if len(params) == 0 {
|
|
return nil
|
|
}
|
|
|
|
out := make([]map[string]any, 0, len(params))
|
|
for _, param := range params {
|
|
if param.Name == "" || param.In == "" {
|
|
continue
|
|
}
|
|
|
|
entry := map[string]any{
|
|
"name": param.Name,
|
|
"in": param.In,
|
|
"required": param.Required || param.In == "path",
|
|
}
|
|
if param.Description != "" {
|
|
entry["description"] = param.Description
|
|
}
|
|
if param.Deprecated {
|
|
entry["deprecated"] = true
|
|
}
|
|
if len(param.Schema) > 0 {
|
|
entry["schema"] = param.Schema
|
|
} else if param.In == "path" || param.In == "query" || param.In == "header" || param.In == "cookie" {
|
|
entry["schema"] = map[string]any{"type": "string"}
|
|
}
|
|
if param.Example != nil {
|
|
entry["example"] = param.Example
|
|
}
|
|
|
|
out = append(out, entry)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// mergeOperationParameters combines generated and explicit parameter
|
|
// definitions, letting explicit entries override auto-generated path params.
|
|
func mergeOperationParameters(existing any, explicit []map[string]any) []map[string]any {
|
|
merged := make([]map[string]any, 0, len(explicit))
|
|
index := map[string]int{}
|
|
|
|
add := func(param map[string]any) {
|
|
name, _ := param["name"].(string)
|
|
in, _ := param["in"].(string)
|
|
if name == "" || in == "" {
|
|
return
|
|
}
|
|
key := in + ":" + name
|
|
if pos, ok := index[key]; ok {
|
|
merged[pos] = param
|
|
return
|
|
}
|
|
index[key] = len(merged)
|
|
merged = append(merged, param)
|
|
}
|
|
|
|
if params, ok := existing.([]map[string]any); ok {
|
|
for _, param := range params {
|
|
add(param)
|
|
}
|
|
}
|
|
|
|
for _, param := range explicit {
|
|
add(param)
|
|
}
|
|
|
|
if len(merged) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return merged
|
|
}
|
|
|
|
// resolvedOperationTags returns the explicit route tags when provided, or a
|
|
// stable fallback derived from the group's name when the route omits tags.
|
|
func resolvedOperationTags(groupName string, rd RouteDescription) []string {
|
|
if tags := cleanTags(rd.Tags); len(tags) > 0 {
|
|
return tags
|
|
}
|
|
|
|
if name := strings.TrimSpace(groupName); name != "" {
|
|
return []string{name}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// cleanTags trims whitespace and removes empty or duplicate tags while
|
|
// preserving the first occurrence of each name.
|
|
func cleanTags(tags []string) []string {
|
|
if len(tags) == 0 {
|
|
return nil
|
|
}
|
|
|
|
cleaned := make([]string, 0, len(tags))
|
|
seen := make(map[string]struct{}, len(tags))
|
|
for _, tag := range tags {
|
|
tag = strings.TrimSpace(tag)
|
|
if tag == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[tag]; ok {
|
|
continue
|
|
}
|
|
seen[tag] = struct{}{}
|
|
cleaned = append(cleaned, tag)
|
|
}
|
|
if len(cleaned) == 0 {
|
|
return nil
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
// envelopeSchema wraps a data schema in the standard Response[T] envelope.
|
|
func envelopeSchema(dataSchema map[string]any) map[string]any {
|
|
properties := map[string]any{
|
|
"success": map[string]any{"type": "boolean"},
|
|
"error": map[string]any{
|
|
"$ref": "#/components/schemas/Error",
|
|
},
|
|
"meta": map[string]any{
|
|
"$ref": "#/components/schemas/Meta",
|
|
},
|
|
}
|
|
|
|
if dataSchema != nil {
|
|
properties["data"] = dataSchema
|
|
}
|
|
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": properties,
|
|
"required": []string{"success"},
|
|
}
|
|
}
|
|
|
|
// rateLimitHeaders documents the response headers emitted when rate limiting
|
|
// rejects a request.
|
|
func rateLimitHeaders() map[string]any {
|
|
return map[string]any{
|
|
"X-RateLimit-Limit": map[string]any{
|
|
"description": "Maximum number of requests allowed in the current window",
|
|
"schema": map[string]any{
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
},
|
|
},
|
|
"X-RateLimit-Remaining": map[string]any{
|
|
"description": "Number of requests remaining in the current window",
|
|
"schema": map[string]any{
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
},
|
|
},
|
|
"X-RateLimit-Reset": map[string]any{
|
|
"description": "Unix timestamp when the rate limit window resets",
|
|
"schema": map[string]any{
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
},
|
|
},
|
|
"Retry-After": map[string]any{
|
|
"description": "Seconds until the rate limit resets",
|
|
"schema": map[string]any{
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// rateLimitSuccessHeaders documents the response headers emitted on
|
|
// successful requests when rate limiting is enabled.
|
|
func rateLimitSuccessHeaders() map[string]any {
|
|
return map[string]any{
|
|
"X-RateLimit-Limit": map[string]any{
|
|
"description": "Maximum number of requests allowed in the current window",
|
|
"schema": map[string]any{
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
},
|
|
},
|
|
"X-RateLimit-Remaining": map[string]any{
|
|
"description": "Number of requests remaining in the current window",
|
|
"schema": map[string]any{
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
},
|
|
},
|
|
"X-RateLimit-Reset": map[string]any{
|
|
"description": "Unix timestamp when the rate limit window resets",
|
|
"schema": map[string]any{
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// cacheSuccessHeaders documents the response header emitted on successful
|
|
// cache hits.
|
|
func cacheSuccessHeaders() map[string]any {
|
|
return map[string]any{
|
|
"X-Cache": map[string]any{
|
|
"description": "Indicates the response was served from the in-memory cache",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// sseResponseHeaders documents the response headers emitted by the SSE stream.
|
|
func sseResponseHeaders() map[string]any {
|
|
return map[string]any{
|
|
"Cache-Control": map[string]any{
|
|
"description": "Prevents intermediaries from caching the event stream",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
"Connection": map[string]any{
|
|
"description": "Keeps the HTTP connection open for streaming",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
"X-Accel-Buffering": map[string]any{
|
|
"description": "Disables buffering in compatible reverse proxies",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// effectiveGraphQLPath returns the configured GraphQL path or the default
|
|
// GraphQL path when GraphQL is enabled without an explicit path.
|
|
func (sb *SpecBuilder) effectiveGraphQLPath() string {
|
|
graphqlPath := strings.TrimSpace(sb.GraphQLPath)
|
|
if graphqlPath == "" && (sb.GraphQLEnabled || sb.GraphQLPlayground) {
|
|
return defaultGraphQLPath
|
|
}
|
|
return graphqlPath
|
|
}
|
|
|
|
// effectiveSwaggerPath returns the configured Swagger UI path or the default
|
|
// path when Swagger is enabled without an explicit override.
|
|
func (sb *SpecBuilder) effectiveSwaggerPath() string {
|
|
swaggerPath := strings.TrimSpace(sb.SwaggerPath)
|
|
if swaggerPath == "" && sb.SwaggerEnabled {
|
|
return defaultSwaggerPath
|
|
}
|
|
return swaggerPath
|
|
}
|
|
|
|
// effectiveWSPath returns the configured WebSocket path or the default path
|
|
// when WebSockets are enabled without an explicit override.
|
|
func (sb *SpecBuilder) effectiveWSPath() string {
|
|
wsPath := strings.TrimSpace(sb.WSPath)
|
|
if wsPath == "" && sb.WSEnabled {
|
|
return defaultWSPath
|
|
}
|
|
return wsPath
|
|
}
|
|
|
|
// effectiveSSEPath returns the configured SSE path or the default path when
|
|
// SSE is enabled without an explicit override.
|
|
func (sb *SpecBuilder) effectiveSSEPath() string {
|
|
ssePath := strings.TrimSpace(sb.SSEPath)
|
|
if ssePath == "" && sb.SSEEnabled {
|
|
return defaultSSEPath
|
|
}
|
|
return ssePath
|
|
}
|
|
|
|
// documentedResponseHeaders converts route-specific response header metadata
|
|
// into OpenAPI header objects.
|
|
func documentedResponseHeaders(headers map[string]string) map[string]any {
|
|
if len(headers) == 0 {
|
|
return nil
|
|
}
|
|
|
|
out := make(map[string]any, len(headers))
|
|
for name, description := range headers {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
out[name] = map[string]any{
|
|
"description": description,
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// standardResponseHeaders documents headers emitted by the response envelope
|
|
// middleware on all responses when request IDs are enabled.
|
|
func standardResponseHeaders() map[string]any {
|
|
return map[string]any{
|
|
"X-Request-ID": map[string]any{
|
|
"description": "Request identifier propagated from the client or generated by the server",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// mergeHeaders combines multiple OpenAPI header maps into one.
|
|
func mergeHeaders(sets ...map[string]any) map[string]any {
|
|
merged := make(map[string]any)
|
|
for _, set := range sets {
|
|
for name, value := range set {
|
|
merged[name] = value
|
|
}
|
|
}
|
|
return merged
|
|
}
|
|
|
|
// operationID builds a stable OpenAPI operationId from the HTTP method and path.
|
|
// The generated identifier is lower snake_case and preserves path parameter names.
|
|
func operationID(method, path string, operationIDs map[string]int) string {
|
|
var b strings.Builder
|
|
b.Grow(len(method) + len(path) + 1)
|
|
lastUnderscore := false
|
|
|
|
writeUnderscore := func() {
|
|
if b.Len() > 0 && !lastUnderscore {
|
|
b.WriteByte('_')
|
|
lastUnderscore = true
|
|
}
|
|
}
|
|
|
|
appendToken := func(r rune) {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
if unicode.IsUpper(r) {
|
|
r = unicode.ToLower(r)
|
|
}
|
|
b.WriteRune(r)
|
|
lastUnderscore = false
|
|
return
|
|
}
|
|
writeUnderscore()
|
|
}
|
|
|
|
for _, r := range method {
|
|
appendToken(r)
|
|
}
|
|
writeUnderscore()
|
|
for _, r := range path {
|
|
switch r {
|
|
case '/':
|
|
writeUnderscore()
|
|
case '-':
|
|
writeUnderscore()
|
|
case '.':
|
|
writeUnderscore()
|
|
case ' ':
|
|
writeUnderscore()
|
|
default:
|
|
appendToken(r)
|
|
}
|
|
}
|
|
|
|
out := strings.Trim(b.String(), "_")
|
|
if out == "" {
|
|
return "operation"
|
|
}
|
|
|
|
if operationIDs == nil {
|
|
return out
|
|
}
|
|
|
|
count := operationIDs[out]
|
|
operationIDs[out] = count + 1
|
|
if count == 0 {
|
|
return out
|
|
}
|
|
return out + "_" + strconv.Itoa(count+1)
|
|
}
|