Declare each operation's refusal body as its default response

Every non-2xx a typed op answers is an RFC 9457 problem document, or
RFC 6749's error pair at an OAuth address, and the document declared
neither, so SDK generators typed errors as unknown. Each operation now
carries a default response that points at a problem-details or an
oauth-error schema. The OAuth addresses come from composeOAuth over the
same walk the error handler is built from.

Neither schema name can be a Go type's name. Neither schema writes a
boolean additionalProperties: CommandsFromSpec parses schemas as objects,
and a boolean failed it.

TestTheDocumentDeclaresTheRefusal checks the declaration at both kinds of
address, and that the refusal on the wire carries every member the schema
requires. The ErrorHandler and MarshalJSON comments still described the
old envelope and a written instance member; they describe what is written.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-11 10:36:14 -07:00
co-authored by Hanzo Dev
parent 4f5f5a34a4
commit 46a0a196f6
5 changed files with 146 additions and 5 deletions
+2 -3
View File
@@ -278,9 +278,8 @@ func (e *HTTPError) With(detail map[string]any) *HTTPError {
return e
}
// MarshalJSON writes the RFC 9457 problem document, with no `instance`: a value
// marshalled on its own has no occurrence to name. Served through an address,
// the same document gains one — see [HTTPError.problem].
// MarshalJSON writes the RFC 9457 problem document, the same one the error
// handler serves — see [HTTPError.problem].
func (e *HTTPError) MarshalJSON() ([]byte, error) {
return jsonenc.Marshal(e.problem())
}
+66
View File
@@ -75,6 +75,9 @@ func (a *App) buildOpenAPI() map[string]any {
// One registry for the whole document: a type reached by two ops is one
// definition in components.schemas that both point at.
reg := newSchemaRegistry(specDefs)
// The addresses whose refusals speak RFC 6749, read from the same walk the
// error handler is built from, so the document and the wire agree.
oauth := composeOAuth(a.plan())
// Sort ops by path,method for deterministic output.
ops := append([]*registeredOp{}, a.Registry()...)
@@ -289,6 +292,13 @@ func (a *App) buildOpenAPI() map[string]any {
}
}
// Any status the op does not declare is a refusal, written in the body its
// address speaks (see problem.go). Publishing that body is what lets a
// client generated from this document type its errors.
if resp, ok := opObj["responses"].(map[string]any); ok {
resp["default"] = refusalResponse(reg, oauth[op.Method+" "+op.Path])
}
paths[path][strings.ToLower(op.Method)] = opObj
}
@@ -306,6 +316,62 @@ func (a *App) buildOpenAPI() map[string]any {
}
}
// The schema names a refusal body is published under. Neither can be the name
// of a Go type: a type name has no '-', and a qualified one contains a '.'.
const (
problemSchema = "problem-details"
oauthSchema = "oauth-error"
)
// refusalResponse is an op's `default` response: an RFC 9457 problem document,
// or RFC 6749's error pair at an OAuth address.
func refusalResponse(reg *schemaRegistry, oauth bool) map[string]any {
name, media, schema := problemSchema, mimeProblem, problemDocument
if oauth {
name, media, schema = oauthSchema, mimeJSON, oauthError
}
if _, ok := reg.defs[name]; !ok {
reg.defs[name] = schema()
}
return map[string]any{
"description": "refused",
"content": map[string]any{media: map[string]any{"schema": reg.ref(name)}},
}
}
// problemDocument is the shape [HTTPError.problem] writes. A refusal's own
// extension members sit beside these, which an object schema allows by default.
// additionalProperties is left out rather than written as true: readers of the
// document, CommandsFromSpec among them, parse every schema as an object.
func problemDocument() map[string]any {
str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
return map[string]any{
"type": "object",
"description": "An RFC 9457 problem document.",
"properties": map[string]any{
"type": str("about:blank"),
"title": str("The status's reason phrase."),
"status": map[string]any{"type": "integer", "format": "int64", "description": "The HTTP status."},
"detail": str("What was refused, for a person to read."),
"code": str("A stable token a client can branch on, when the refusal names one."),
},
"required": []string{"type", "status", "detail"},
}
}
// oauthError is the shape [HTTPError.oauth] writes (RFC 6749 §5.2).
func oauthError() map[string]any {
return map[string]any{
"type": "object",
"description": "An RFC 6749 error response.",
"properties": map[string]any{
"error": map[string]any{"type": "string"},
"error_description": map[string]any{"type": "string"},
},
"required": []string{"error"},
}
}
// ID is an operation's name, derived from its method and its ABSOLUTE path.
//
// It is THE rule for that derivation and the only one. The OpenAPI operationId,
+1
View File
@@ -39,6 +39,7 @@ func respOf(t *testing.T, a *zip.App, path, method string) (string, map[string]a
}
op, _ := item[method].(map[string]any)
resp, _ := op["responses"].(map[string]any)
delete(resp, "default") // the refusal every op publishes; this reads the success
if len(resp) != 1 {
t.Fatalf("responses = %v, want exactly one declared status", resp)
}
+73
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/zap-proto/zip"
@@ -287,6 +288,78 @@ func TestOAuthTakesTypedOpsToo(t *testing.T) {
}
}
// TestTheDocumentDeclaresTheRefusal: every op publishes a `default` response in
// the vocabulary its address speaks, so a generated client types its errors —
// and the refusal on the wire carries every member that schema requires.
func TestTheDocumentDeclaresTheRefusal(t *testing.T) {
type in struct {
Name string `json:"name"`
}
type out struct {
OK bool `json:"ok"`
}
ok := func(context.Context, *in) (*out, error) { return &out{OK: true}, nil }
app := zip.New(zip.Config{AppName: "declared", DisableStartupMessage: true})
zip.Post(app, "/v1/things", ok)
zip.Post(zip.OAuth(app), "/v1/oauth/token", ok)
raw, err := json.Marshal(app.OpenAPISpec())
if err != nil {
t.Fatal(err)
}
var doc struct {
Paths map[string]map[string]struct {
Responses map[string]struct {
Content map[string]struct {
Schema struct {
Ref string `json:"$ref"`
} `json:"schema"`
} `json:"content"`
} `json:"responses"`
} `json:"paths"`
Components struct {
Schemas map[string]struct {
Required []string `json:"required"`
} `json:"schemas"`
} `json:"components"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
for _, c := range []struct{ path, media, schema string }{
{"/v1/things", "application/problem+json", "problem-details"},
{"/v1/oauth/token", "application/json", "oauth-error"},
} {
ref := doc.Paths[c.path]["post"].Responses["default"].Content[c.media].Schema.Ref
if ref != "#/components/schemas/"+c.schema {
t.Errorf("%s: default %s schema is %q, want %s", c.path, c.media, ref, c.schema)
continue
}
req, err := http.NewRequest("POST", c.path, strings.NewReader("{"))
if err != nil {
t.Fatal(err)
}
res, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
if got := res.Header.Get("Content-Type"); got != c.media {
t.Errorf("%s: Content-Type %q, the document says %s", c.path, got, c.media)
}
b, _ := io.ReadAll(res.Body)
var body map[string]any
if err := json.Unmarshal(b, &body); err != nil {
t.Fatalf("%s: %s", c.path, b)
}
for _, member := range doc.Components.Schemas[c.schema].Required {
if _, ok := body[member]; !ok {
t.Errorf("%s: the refusal has no %q, which the document requires: %s", c.path, member, b)
}
}
}
}
// A REFUSAL SAYS NOTHING ABOUT WHERE IT HAPPENED, so two addresses that refuse
// for the same reason answer identically. That is what lets "a real name and an
// invented one are answered the same" be compared strictly rather than loosened
+4 -2
View File
@@ -114,8 +114,10 @@ type Config struct {
// how much of it they may have.
Warm int
// ErrorHandler is the catch-all error handler. Defaults to zip.errorHandler
// which renders {error, code, status} JSON.
// ErrorHandler replaces the handler that writes every refusal. Unset, a
// refusal is an RFC 9457 problem document (application/problem+json), or
// RFC 6749's {error, error_description} at an address declared with [OAuth].
// See problem.go.
ErrorHandler fiber.ErrorHandler
// Concurrency caps the maximum number of concurrent connections the