From 7e4d8eb1796b7d0e4c07caf95ead286539c88ea5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 20:51:02 +0000 Subject: [PATCH] feat(openapi): add route examples to spec Co-Authored-By: Virgil --- group.go | 10 ++++++---- openapi.go | 28 ++++++++++++++++++++-------- openapi_test.go | 18 ++++++++++++++++++ 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/group.go b/group.go index 631297b..44fc9c4 100644 --- a/group.go +++ b/group.go @@ -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. diff --git a/openapi.go b/openapi.go index f6b81c1..8f5fb1f 100644 --- a/openapi.go +++ b/openapi.go @@ -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, } } diff --git a/openapi_test.go b/openapi_test.go index b40d8a3..6fe0812 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -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) {