// SPDX-License-Identifier: EUPL-1.2 package api import ( "encoding/json" "strings" ) // SpecBuilder constructs an OpenAPI 3.1 specification from registered RouteGroups. type SpecBuilder struct { Title string Description string Version string } // Build generates the complete OpenAPI 3.1 JSON spec. // Groups implementing DescribableGroup contribute endpoint documentation. // Other groups are listed as tags only. func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) { spec := map[string]any{ "openapi": "3.1.0", "info": map[string]any{ "title": sb.Title, "description": sb.Description, "version": sb.Version, }, "paths": sb.buildPaths(groups), "tags": sb.buildTags(groups), } // Add component schemas for the response envelope. spec["components"] = map[string]any{ "schemas": map[string]any{ "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"}, }, }, }, } return json.MarshalIndent(spec, "", " ") } // buildPaths generates the paths object from all DescribableGroups. func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any { 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"}, "responses": 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"}), }, }, }, }, }, }, } for _, g := range groups { dg, ok := g.(DescribableGroup) if !ok { continue } for _, rd := range dg.Describe() { fullPath := g.BasePath() + rd.Path method := strings.ToLower(rd.Method) operation := map[string]any{ "summary": rd.Summary, "description": rd.Description, "tags": rd.Tags, "responses": map[string]any{ "200": map[string]any{ "description": "Successful response", "content": map[string]any{ "application/json": map[string]any{ "schema": envelopeSchema(rd.Response), }, }, }, "400": map[string]any{ "description": "Bad request", "content": map[string]any{ "application/json": map[string]any{ "schema": envelopeSchema(nil), }, }, }, }, } // Add request body for methods that accept one. if rd.RequestBody != nil && (method == "post" || method == "put" || method == "patch") { operation["requestBody"] = map[string]any{ "required": true, "content": map[string]any{ "application/json": map[string]any{ "schema": rd.RequestBody, }, }, } } // 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, } } } } return paths } // buildTags generates the tags array from all RouteGroups. func (sb *SpecBuilder) buildTags(groups []RouteGroup) []map[string]any { tags := []map[string]any{ {"name": "system", "description": "System endpoints"}, } seen := map[string]bool{"system": true} for _, g := range groups { name := g.Name() if !seen[name] { tags = append(tags, map[string]any{ "name": name, "description": name + " endpoints", }) seen[name] = true } } return tags } // 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"}, } }