From ccfbe57fafea6bc079c4dfcd424687cc98f581f9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:42:13 +0000 Subject: [PATCH] feat(openapi): document response headers --- group.go | 1 + openapi.go | 33 +++++++++++++++++++++++++++-- openapi_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/group.go b/group.go index bc8f372..9abae08 100644 --- a/group.go +++ b/group.go @@ -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. diff --git a/openapi.go b/openapi.go index b23d3d3..aa74fc6 100644 --- a/openapi.go +++ b/openapi.go @@ -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 { diff --git a/openapi_test.go b/openapi_test.go index 799812d..beca70b 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -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",