feat(api): document custom success statuses
This commit is contained in:
parent
6017ac7132
commit
cd4e24d15f
4 changed files with 146 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
3
group.go
3
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
|
||||
|
|
|
|||
62
openapi.go
62
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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue