From cd4e24d15fa37e90bd5fd53d648550c002ac4c29 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 20:04:34 +0000 Subject: [PATCH] feat(api): document custom success statuses --- docs/architecture.md | 1 + group.go | 3 ++ openapi.go | 62 ++++++++++++++++++++++++------ openapi_test.go | 91 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 11 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 9e18931..7fd1ca5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,6 +128,7 @@ type RouteDescription struct { Summary string Description string Tags []string + StatusCode int Parameters []ParameterDescription RequestBody map[string]any Response map[string]any diff --git a/group.go b/group.go index ec15ec5..1e589ab 100644 --- a/group.go +++ b/group.go @@ -39,6 +39,9 @@ type RouteDescription struct { Summary string // Short summary Description string // Long description Tags []string // OpenAPI tags for grouping + // StatusCode is the documented 2xx success status code. + // Zero defaults to 200. + 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 diff --git a/openapi.go b/openapi.go index 713039a..f53769c 100644 --- a/openapi.go +++ b/openapi.go @@ -4,6 +4,7 @@ package api import ( "encoding/json" + "net/http" "strconv" "strings" "unicode" @@ -123,7 +124,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.Response), + "responses": operationResponses(method, rd.StatusCode, rd.Response), } if rd.Security != nil { operation["security"] = rd.Security @@ -238,22 +239,27 @@ 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, dataSchema map[string]any) map[string]any { +func operationResponses(method string, statusCode int, dataSchema map[string]any) map[string]any { successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()) if method == "get" { successHeaders = mergeHeaders(successHeaders, cacheSuccessHeaders()) } - return map[string]any{ - "200": map[string]any{ - "description": "Successful response", - "content": map[string]any{ - "application/json": map[string]any{ - "schema": envelopeSchema(dataSchema), - }, + code := successStatusCode(statusCode) + successResponse := map[string]any{ + "description": successResponseDescription(code), + "headers": successHeaders, + } + if !isNoContentStatus(code) { + successResponse["content"] = map[string]any{ + "application/json": map[string]any{ + "schema": envelopeSchema(dataSchema), }, - "headers": successHeaders, - }, + } + } + + return map[string]any{ + strconv.Itoa(code): successResponse, "400": map[string]any{ "description": "Bad request", "content": map[string]any{ @@ -311,6 +317,40 @@ func operationResponses(method string, dataSchema map[string]any) map[string]any } } +func successStatusCode(statusCode int) int { + if statusCode < 200 || statusCode > 299 { + return http.StatusOK + } + if statusCode == 0 { + return http.StatusOK + } + return statusCode +} + +func isNoContentStatus(statusCode int) bool { + switch statusCode { + case http.StatusNoContent, http.StatusResetContent: + return true + default: + return false + } +} + +func successResponseDescription(statusCode int) string { + switch statusCode { + case http.StatusCreated: + return "Created" + case http.StatusAccepted: + return "Accepted" + case http.StatusNoContent: + return "No content" + case http.StatusResetContent: + return "Reset content" + default: + return "Successful response" + } +} + // healthResponses builds the response set for the built-in health endpoint. // It stays public, but rate limiting and timeouts can still apply. func healthResponses() map[string]any { diff --git a/openapi_test.go b/openapi_test.go index ae3bd50..7967f02 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -321,6 +321,97 @@ func TestSpecBuilder_Good_SecuredResponses(t *testing.T) { } } +func TestSpecBuilder_Good_CustomSuccessStatusCode(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + } + + group := &specStubGroup{ + name: "items", + basePath: "/api", + descs: []api.RouteDescription{ + { + Method: "POST", + Path: "/items", + Summary: "Create item", + StatusCode: http.StatusCreated, + 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/items"].(map[string]any)["post"].(map[string]any)["responses"].(map[string]any) + if _, ok := responses["201"]; !ok { + t.Fatal("expected 201 response for created operation") + } + if _, ok := responses["200"]; ok { + t.Fatal("expected 200 response to be omitted when a custom success status is declared") + } + + created := responses["201"].(map[string]any) + if created["description"] != "Created" { + t.Fatalf("expected created description, got %v", created["description"]) + } + if created["content"] == nil { + t.Fatal("expected content for 201 response") + } +} + +func TestSpecBuilder_Good_NoContentSuccessStatusCode(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + } + + group := &specStubGroup{ + name: "items", + basePath: "/api", + descs: []api.RouteDescription{ + { + Method: "DELETE", + Path: "/items/{id}", + Summary: "Delete item", + StatusCode: http.StatusNoContent, + 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/items/{id}"].(map[string]any)["delete"].(map[string]any)["responses"].(map[string]any) + resp204 := responses["204"].(map[string]any) + if resp204["description"] != "No content" { + t.Fatalf("expected no-content description, got %v", resp204["description"]) + } + if _, ok := resp204["content"]; ok { + t.Fatal("expected no content block for 204 response") + } +} + func TestSpecBuilder_Good_RouteSecurityOverrides(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test",