mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user