feat(openapi): add route examples to spec

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 20:51:02 +00:00
parent bb7d88f3ce
commit 7e4d8eb179
3 changed files with 44 additions and 12 deletions

View file

@ -46,10 +46,12 @@ type RouteDescription struct {
StatusCode int
// Security overrides the default bearerAuth requirement when non-nil.
// Use an empty, non-nil slice to mark the route as public.
Security []map[string][]string
Parameters []ParameterDescription
RequestBody map[string]any // JSON Schema for request body (nil for GET)
Response map[string]any // JSON Schema for success response data
Security []map[string][]string
Parameters []ParameterDescription
RequestBody map[string]any // JSON Schema for request body (nil for GET)
RequestExample any // Optional example payload for the request body.
Response map[string]any // JSON Schema for success response data
ResponseExample any // Optional example payload for the success response.
}
// ParameterDescription describes an OpenAPI parameter for a route.

View file

@ -181,7 +181,7 @@ func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any {
"summary": rd.Summary,
"description": rd.Description,
"operationId": operationID(method, fullPath, operationIDs),
"responses": operationResponses(method, rd.StatusCode, rd.Response),
"responses": operationResponses(method, rd.StatusCode, rd.Response, rd.ResponseExample),
}
if rd.Deprecated {
operation["deprecated"] = true
@ -209,12 +209,17 @@ func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any {
// Add request body for methods that accept one.
// The contract only excludes GET; other verbs may legitimately carry bodies.
if rd.RequestBody != nil && method != "get" {
requestMediaType := map[string]any{
"schema": rd.RequestBody,
}
if rd.RequestExample != nil {
requestMediaType["example"] = rd.RequestExample
}
operation["requestBody"] = map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": rd.RequestBody,
},
"application/json": requestMediaType,
},
}
}
@ -299,7 +304,7 @@ func normaliseOpenAPIPath(path string) string {
// 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) map[string]any {
func operationResponses(method string, statusCode int, dataSchema map[string]any, example any) map[string]any {
successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders())
if method == "get" {
successHeaders = mergeHeaders(successHeaders, cacheSuccessHeaders())
@ -311,10 +316,17 @@ func operationResponses(method string, statusCode int, dataSchema map[string]any
"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": map[string]any{
"schema": envelopeSchema(dataSchema),
},
"application/json": content,
}
}

View file

@ -304,12 +304,18 @@ func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) {
"name": map[string]any{"type": "string"},
},
},
RequestExample: map[string]any{
"name": "Widget",
},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "integer"},
},
},
ResponseExample: map[string]any{
"id": 42,
},
},
},
}
@ -360,6 +366,18 @@ func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) {
if postOp.(map[string]any)["requestBody"] == nil {
t.Fatal("expected requestBody on POST /api/items/create")
}
requestBody := postOp.(map[string]any)["requestBody"].(map[string]any)
appJSON := requestBody["content"].(map[string]any)["application/json"].(map[string]any)
if appJSON["example"].(map[string]any)["name"] != "Widget" {
t.Fatalf("expected request example to be preserved, got %v", appJSON["example"])
}
responses := postOp.(map[string]any)["responses"].(map[string]any)
created := responses["200"].(map[string]any)
createdJSON := created["content"].(map[string]any)["application/json"].(map[string]any)
if createdJSON["example"].(map[string]any)["id"] != float64(42) {
t.Fatalf("expected response example to be preserved, got %v", createdJSON["example"])
}
}
func TestSpecBuilder_Good_SecuredResponses(t *testing.T) {