feat(api): document custom success statuses

This commit is contained in:
Virgil 2026-04-01 20:04:34 +00:00
parent 6017ac7132
commit cd4e24d15f
4 changed files with 146 additions and 11 deletions

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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",