feat(openapi): document response headers

This commit is contained in:
Virgil 2026-04-01 22:42:13 +00:00
parent 00c20ea6e8
commit ccfbe57faf
3 changed files with 87 additions and 2 deletions

View file

@ -64,6 +64,7 @@ type RouteDescription struct {
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.
ResponseHeaders map[string]string
}
// ParameterDescription describes an OpenAPI parameter for a route.

View file

@ -192,7 +192,7 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any {
"summary": rd.Summary,
"description": rd.Description,
"operationId": operationID(method, fullPath, operationIDs),
"responses": operationResponses(method, rd.StatusCode, rd.Response, rd.ResponseExample, rd.Security),
"responses": operationResponses(method, rd.StatusCode, rd.Response, rd.ResponseExample, rd.ResponseHeaders, rd.Security),
}
if rd.Deprecated {
operation["deprecated"] = true
@ -321,11 +321,14 @@ 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, example any, security []map[string][]string) map[string]any {
func operationResponses(method string, statusCode int, dataSchema map[string]any, example any, responseHeaders map[string]string, security []map[string][]string) map[string]any {
successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders())
if method == "get" {
successHeaders = mergeHeaders(successHeaders, cacheSuccessHeaders())
}
if headers := documentedResponseHeaders(responseHeaders); len(headers) > 0 {
successHeaders = mergeHeaders(successHeaders, headers)
}
isPublic := security != nil && len(security) == 0
@ -847,6 +850,32 @@ func cacheSuccessHeaders() map[string]any {
}
}
// 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 {

View file

@ -1059,6 +1059,61 @@ func TestSpecBuilder_Good_RequestExampleWithoutSchema(t *testing.T) {
}
}
func TestSpecBuilder_Good_ResponseHeaders(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "downloads",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/exports/{id}",
Summary: "Download export",
ResponseHeaders: map[string]string{
"Content-Disposition": "Download filename suggested by the server",
"X-Export-ID": "Identifier for the generated export",
},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
responses := spec["paths"].(map[string]any)["/api/exports/{id}"].(map[string]any)["get"].(map[string]any)["responses"].(map[string]any)
resp200 := responses["200"].(map[string]any)
headers, ok := resp200["headers"].(map[string]any)
if !ok {
t.Fatalf("expected headers map, got %T", resp200["headers"])
}
header, ok := headers["Content-Disposition"].(map[string]any)
if !ok {
t.Fatal("expected Content-Disposition response header to be documented")
}
if header["description"] != "Download filename suggested by the server" {
t.Fatalf("expected header description to be preserved, got %v", header["description"])
}
schema := header["schema"].(map[string]any)
if schema["type"] != "string" {
t.Fatalf("expected response header schema type string, got %v", schema["type"])
}
}
func TestSpecBuilder_Good_PathParameters(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",