Answer refusals as RFC 9457 problem documents; OAuth keeps its own vocabulary

A refusal now leaves by one door and is written in the vocabulary of the
address it happened at.

HTTPError loses its struct tags and becomes a plain value; MarshalJSON is the
single place that says what it looks like. Its three members already mapped
onto both standards exactly, so no field was added: Status is `status`, Msg is
`detail`, Code is an extension member (RFC 9457), and the same value reads as
`error` / `error_description` under RFC 6749 §5.2. With() members still merge
at the top level per §3.2, with the document's own written last.

zip.OAuth(r) returns a Router whose routes answer the OAuth vocabulary. The
fact rides the route entry rather than a table of addresses, so it survives
composition, and it is a property of the address rather than of the returned
value — which is what makes it reach the refusals no handler wrote (Bind,
validation, panics). A verb the endpoint does not serve is the router
refusing, and answers a problem document with Allow.

middleware.Recover wrote its own 500 body with c.JSON, a second error surface
that spoke only the default vocabulary; it now returns the error so the one
handler renders it. group() copies oauth beside wrap, so a nested scope keeps
the vocabulary.

Allow on 405 comes from the fiber fork's router and was already correct;
TestAWrongVerbSaysWhichVerbsWork now pins it in both directions so an error
handler edit cannot drop it.

Consumers reading `body.error` read `body.detail` now. Each repo takes the
change when it moves its own pin.
This commit is contained in:
2026-08-21 12:47:57 -07:00
parent 3d65856061
commit 3dd01043ec
13 changed files with 645 additions and 101 deletions
+79 -5
View File
@@ -418,8 +418,8 @@ the transport, not to the contract.
**The compatibility rule follows from the layout: reordering, inserting or
retyping a field changes the wire. Append at the end, and only at the end.**
Errors cross whole. The wire form is what `errorHandler` writes for every route
(`{status, code, error}`), so `errors.As(err, &he)` on the caller's side sees the
Errors cross whole: status, code and message travel as ZAP like everything else
on this plane, so `errors.As(err, &he)` on the caller's side sees the
`*HTTPError` the callee returned, status intact. A void op yields `(nil, nil)`.
**Generic, not generated — deliberately.** A generated per-op Go client would
@@ -731,6 +731,80 @@ MCP tool, the CLI command and the op-call plane are untouched, because each of
those carries its own outcome. A non-2xx panics at declaration — an error status
is the error a handler returns, and two places to say it is one too many.
## A refusal is written in the vocabulary its address speaks (WIRE CHANGE)
Every non-2xx zip answers is an **RFC 9457 problem document** under
`application/problem+json`:
```
HTTP 402
Content-Type: application/problem+json
{"type":"about:blank","title":"Payment Required","status":402,
"detail":"spend cap exceeded","instance":"/v1/chat","cap":5000,"spent":5127}
```
`HTTPError` is unchanged as a value and maps onto the document exactly: `Status`
is `status`, `Msg` is `detail`, `Code` is the extension member a client
dispatches on, and `With(...)` members merge at the top level (§3.2) with the
document's own written last so a domain key called `status` cannot displace the
refusal's. `title` is the status phrase, omitted for a status that has none.
`instance` is present when the document was served and absent when the value was
marshalled on its own — a refusal does not know its own occurrence, and a
package-level `var ErrGone = zip.ErrNotFound(…)` is one value shared by every
request that returns it.
This REPLACES `{status, code, error}`. A client reading `body.error` reads
`body.detail` now; there is no second spelling.
**405 carries `Allow`** (RFC 9110 §15.5.6), listing the methods that address
does serve. It comes from the fiber fork's own method-mismatch scan and survives
the error handler; `TestAWrongVerbSaysWhichVerbsWork` drives a real request and
pins both directions — every method the address serves is listed, and no method
it does not.
### The one carve-out, and how an address declares it
RFC 6749 §5.2 STATES the token endpoint's error body as
`{error, error_description}` under `application/json`; RFC 7662 §2.3 and RFC
7009 §2.2.1 send introspection and revocation to that same section. A problem
document there is not a nicer error, it is a broken authorization server. So
that family declares its vocabulary where it is registered:
```go
oauth := zip.OAuth(r)
oauth.Post("/v1/iam/oauth/token", token)
oauth.Post("/v1/iam/oauth/introspect", introspect)
oauth.Post("/v1/iam/oauth/revoke", revoke)
```
`OAuth` returns a **Router**, so it takes typed ops the same way
(`zip.Post(oauth, …)`) and nests through `Group`. There is no flag, no config
field and no second error type: the same `HTTPError` renders as
`{error: Code, error_description: Msg}`, and a refusal the framework produced
gets the code its status implies — `invalid_client` for 401, `server_error` for
5xx, `invalid_request` otherwise.
It is a property of the ADDRESS and not of the returned value, because the
refusals that most need translating are the ones no handler wrote: a body that
will not bind, a panic recovered above the handler, a gate refusing in front of
it. There is no return statement to attach a type to for any of those.
The fact rides the route ENTRY (`route.oauth`, stamped once in `App.addRoute`
from the definition it was declared on) and is reduced to an address set by
`composeOAuth` at materialise, alongside `composeOps`. That is what makes it
survive composition: a service grafted under a host answers the same way at its
new path, where an address recorded at registration time would name a path that
no longer exists — pinned by `TestTheVocabularySurvivesComposition`.
The pair is METHOD+path, so a verb the endpoint does not serve is the ROUTER
refusing, and answers a problem document with `Allow` — which is the answer that
client actually needs.
`middleware.Recover` RETURNS its refusal rather than writing one. Writing a body
made it a second error surface that spoke only the default, so a panic at an
OAuth address answered a shape no OAuth client parses.
## The schema derivation — one type, one definition (v1.17.8)
`openapi.go`. `schemaOf(t, reg, fields)` is the ONE derivation from a Go type to
@@ -1062,10 +1136,10 @@ response to put them on — the declared set still appears in the schema,
and the answer's value is identical on every transport.
**A failure with a body.** A declared status may be non-2xx, for an op
whose failure carries a TYPED BODY the error envelope cannot express — a
whose failure carries a TYPED BODY a problem document cannot express — a
409 with the conflicting record, a 404 with what was searched for. An
error RETURN still renders the standard `{status, code, error}` envelope
and is still the way to answer a failure that has nothing extra to say.
error RETURN still renders the RFC 9457 problem document and is still the
way to answer a failure that has nothing extra to say.
**A verbatim body.** A passthrough that forwards bytes to an upstream
owning the contract takes `json.RawMessage` as its input: every unnamed
+9 -5
View File
@@ -170,7 +170,7 @@ func composeOps(occ []occurrence) []*registeredOp {
// costs the definition's middleware its coverage of unmatched paths — a
// definition does not answer for addresses it does not declare.
func (a *App) materialise(occ []occurrence, ctl []route) *fiber.App {
f := fiber.New(a.fiberConfig())
f := fiber.New(a.fiberConfig(composeOAuth(occ)))
// The report goes on FIRST, so it is outermost: it observes every request
// this router answers, including the ones that match no route and the ones a
// later middleware refuses. An app gets it by being built, which is the whole
@@ -237,8 +237,10 @@ func (a *App) installRoute(f *fiber.App, prefix string, mw []Handler, r route) {
// addRoute is the ONE place a route entry is appended, so every route method,
// every typed registration and zip's own control plane record the same thing in
// the same shape with a real call site.
// the same shape with a real call site — and so the definition's error
// vocabulary reaches the entry once instead of at each of those.
func (a *App) addRoute(site callsite, r route) {
r.oauth = a.oauth
a.appendEntry(entry{n: r, site: site})
}
@@ -299,8 +301,10 @@ func (a *App) Lint() []string {
}
// fiberConfig is the App's Config as fiber wants it. One place, so New and
// every rematerialisation agree.
func (a *App) fiberConfig() fiber.Config {
// every rematerialisation agree. oauth is the address set the error handler
// answers RFC 6749 at, which is a property of the routes this generation holds
// and so arrives with them.
func (a *App) fiberConfig(oauth map[string]bool) fiber.Config {
fcfg := fiber.Config{
AppName: a.cfg.AppName,
BodyLimit: a.cfg.BodyLimit,
@@ -323,7 +327,7 @@ func (a *App) fiberConfig() fiber.Config {
if a.cfg.ErrorHandler != nil {
fcfg.ErrorHandler = a.cfg.ErrorHandler
} else {
fcfg.ErrorHandler = errorHandler
fcfg.ErrorHandler = refusing(oauth)
}
return fcfg
}
-4
View File
@@ -312,10 +312,6 @@ func Call[In, Out any](ctx context.Context, c *Conn, op string, in *In) (*Out, e
return &out, nil
}
// remoteError reconstructs the callee's error. The wire form is the one
// zip.errorHandler writes for every route — {status, code, error} — so an
// HTTPError survives the crossing whole rather than being flattened into a
// string the caller has to parse.
// remoteError rebuilds the refusal the callee chose, so errors.As on this side
// sees the *HTTPError the other side returned with its status intact. The body
// is ZAP like everything else on this plane; a body that does not decode leaves
+8
View File
@@ -129,6 +129,10 @@ type route struct {
// and zip's own control routes. Exactly one of chain/serve is set.
serve fiber.Handler
op *registeredOp
// oauth is this address speaking RFC 6749: its refusals are {error,
// error_description} and not problem documents. It rides the ENTRY rather
// than a table of addresses because composition moves paths — see [OAuth].
oauth bool
}
// entry is one element of an App's program: a payload, and where it was
@@ -339,6 +343,10 @@ func (a *App) group(site callsite, prefix string, handlers ...Handler) *App {
// (wrapRouter.Group used to return another wrapRouter, so the chain rode
// down every level) — an auth seam quietly lost at the second nesting.
g.wrap = a.wrap
// And for the same reason the error vocabulary: a group of an [OAuth] router
// is still inside the RFC 6749 family, and a nested scope that answered its
// parent's neighbours' shape would be the same silently-lost property.
g.oauth = a.oauth
for _, h := range handlers {
if h == nil {
continue
+19 -46
View File
@@ -3,7 +3,6 @@ package zip
import (
"bufio"
"context"
"errors"
"io"
luxlog "github.com/luxfi/log"
@@ -197,12 +196,19 @@ func (c *Ctx) Continue() error { return c.fc.Next() }
// Errors — handlers return one of these to control status code
// =============================================================================
// HTTPError is the canonical error type zip understands. Returning one
// causes the error handler to send a JSON {error, code, status} body.
// HTTPError is the canonical error type zip understands. Returning one is how
// a handler refuses; how the refusal is WRITTEN belongs to the address it
// happened at, and lives in problem.go.
//
// The three members map onto RFC 9457 exactly: Status is `status`, Msg is
// `detail`, and Code is the extension member a client dispatches on. They map
// onto RFC 6749 §5.2 just as exactly — Code is `error`, Msg is
// `error_description` — which is why an OAuth endpoint needs no error type of
// its own. One value, two vocabularies, chosen by the endpoint.
type HTTPError struct {
Status int `json:"status"`
Code string `json:"code,omitempty"`
Msg string `json:"error"`
Status int
Code string
Msg string
// Detail is what a refusal carries BESIDES its message, and it exists
// because a typed op's only way to refuse is to RETURN an error — so
@@ -220,9 +226,9 @@ type HTTPError struct {
// It is an EXTENSION in the RFC 9457 sense: the members a problem document
// may carry beyond its own. Rendered by MERGING rather than nesting, so a
// reader sees one object instead of a body filed under a key it has to know
// to look in — and the envelope is written last, so a domain key called
// status cannot displace the refusal's own.
Detail map[string]any `json:"-"`
// to look in — and the document's own members are written last, so a domain
// key called status cannot displace the refusal's own.
Detail map[string]any
}
// With attaches extension members to a refusal and returns it, so an op refuses
@@ -246,24 +252,11 @@ func (e *HTTPError) With(detail map[string]any) *HTTPError {
return e
}
// MarshalJSON merges the extension members under the envelope. A refusal
// carrying no detail marshals byte-identically to what it always did, which is
// what makes this additive for every repository already rendering this type.
// 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].
func (e *HTTPError) MarshalJSON() ([]byte, error) {
if len(e.Detail) == 0 {
type plain HTTPError // no method set, so no recursion
return jsonenc.Marshal((*plain)(e))
}
out := make(map[string]any, len(e.Detail)+3)
for k, v := range e.Detail {
out[k] = v
}
out["status"] = e.Status
if e.Code != "" {
out["code"] = e.Code
}
out["error"] = e.Msg
return jsonenc.Marshal(out)
return jsonenc.Marshal(e.problem(""))
}
func (e *HTTPError) Error() string { return e.Msg }
@@ -291,26 +284,6 @@ func ErrPaymentRequired(msg string) *HTTPError { return &HTTPError{Status: 402,
func ErrUnprocessable(msg string) *HTTPError { return &HTTPError{Status: 422, Msg: msg} }
func ErrInternal(msg string) *HTTPError { return &HTTPError{Status: 500, Msg: msg} }
// errorHandler is the default fiber.ErrorHandler — converts HTTPError
// into a JSON response and falls back to 500 for anything else.
func errorHandler(c fiber.Ctx, err error) error {
var he *HTTPError
if errors.As(err, &he) {
if he.Status == 0 {
he.Status = 500
}
c.Status(he.Status)
return c.JSON(he)
}
var fe *fiber.Error
if errors.As(err, &fe) {
c.Status(fe.Code)
return c.JSON(&HTTPError{Status: fe.Code, Msg: fe.Message})
}
c.Status(500)
return c.JSON(&HTTPError{Status: 500, Msg: err.Error()})
}
// Redirect sends an HTTP redirect to location with the given status code.
func (c *Ctx) Redirect(code int, location string) error {
return c.fc.Redirect().Status(code).To(location)
+17 -29
View File
@@ -20,11 +20,11 @@ func TestARefusalCarriesItsDetail(t *testing.T) {
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatal(err)
}
if got["status"] != float64(402) || got["error"] != "spend cap exceeded" {
t.Fatalf("envelope did not survive: %v", got)
if got["status"] != float64(402) || got["detail"] != "spend cap exceeded" {
t.Fatalf("problem document did not survive: %v", got)
}
// MERGED, not nested: a reader sees one object rather than a body filed
// under a key it has to know to look in.
// under a key it has to know to look in — RFC 9457 §3.2.
for k, want := range map[string]any{"cap": float64(5000), "spent": float64(5127), "ledger": "acme"} {
if got[k] != want {
t.Errorf("detail %s = %v, want %v", k, got[k], want)
@@ -32,40 +32,28 @@ func TestARefusalCarriesItsDetail(t *testing.T) {
}
}
// TestDetailCannotDisplaceTheEnvelope is the half that matters more than the
// TestDetailCannotDisplaceTheDocument is the half that matters more than the
// feature. A domain field named `status` silently overwriting the refusal's
// status is how a 402 reads as a 200 — so the envelope is written last and wins.
func TestDetailCannotDisplaceTheEnvelope(t *testing.T) {
// status is how a 402 reads as a 200 — so the document's own members are
// written last and win.
func TestDetailCannotDisplaceTheDocument(t *testing.T) {
err := ErrUnprocessable("build failed").
With(map[string]any{"status": 200, "error": "everything is fine", "code": "ok"})
With(map[string]any{"status": 200, "detail": "everything is fine", "code": "ok", "type": "https://evil"})
err.Code = "build_failed"
raw, _ := json.Marshal(err)
var got map[string]any
_ = json.Unmarshal(raw, &got)
if got["status"] != float64(422) {
t.Errorf("status = %v, want 422 — a detail key displaced the refusal", got["status"])
}
if got["error"] != "build failed" {
t.Errorf("error = %v, want the refusal's own message", got["error"])
}
if got["code"] != "build_failed" {
t.Errorf("code = %v, want the refusal's own code", got["code"])
}
}
// TestNoDetailIsTheShapeItAlwaysWas pins that this is ADDITIVE. Twelve
// repositories render errors through this type; a refusal carrying no detail
// must marshal byte-identically to what they read before.
func TestNoDetailIsTheShapeItAlwaysWas(t *testing.T) {
raw, _ := json.Marshal(&HTTPError{Status: 404, Code: "absent", Msg: "no such thing"})
if string(raw) != `{"status":404,"code":"absent","error":"no such thing"}` {
t.Errorf("shape moved: %s", raw)
}
bare, _ := json.Marshal(&HTTPError{Status: 500, Msg: "boom"})
if string(bare) != `{"status":500,"error":"boom"}` {
t.Errorf("shape moved: %s", bare)
for member, want := range map[string]any{
"status": float64(422),
"detail": "build failed",
"code": "build_failed",
"type": "about:blank",
} {
if got[member] != want {
t.Errorf("%s = %v, want %v — an extension member displaced the refusal", member, got[member], want)
}
}
}
+4 -1
View File
@@ -82,8 +82,11 @@ func TestRuntimeRoute(t *testing.T) {
if resp.StatusCode != 404 {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/problem+json" {
t.Fatalf("Content-Type = %q, want the RFC 9457 media type", ct)
}
body, _ := io.ReadAll(resp.Body)
assertContainsAll(t, string(body), `"error":"unknown language"`)
assertContainsAll(t, string(body), `"detail":"unknown language"`, `"status":404`)
})
// The op is in the document because it IS an op — the assertion that says
+1 -1
View File
@@ -293,7 +293,7 @@ func (a *App) liveOrBuild() *generation {
at := version.Load() // BEFORE the build: an append during it must invalidate
g, err := a.build()
if g == nil {
g = &generation{router: fiber.New(a.fiberConfig())}
g = &generation{router: fiber.New(a.fiberConfig(nil))}
}
// The error is REMEMBERED, not swallowed. An invalid program used to render
// as an empty one — Registry() 0 ops, Declaration() 0 routes, and
+3 -3
View File
@@ -235,9 +235,9 @@ func defaultFailureClassifier(err error, status int) bool {
// Breaker returns the zip middleware form of b. The middleware:
//
// 1. Calls b.Allow() before delegating to the next handler.
// 2. If Allow returns false, short-circuits with 503 and a brief
// {"error":"upstream unavailable"} body. NO retry. NO queue. Fail
// fast — let the client back off.
// 2. If Allow returns false, short-circuits by RETURNING a 503 naming the
// upstream, which the app renders in the vocabulary the address answers
// in. NO retry. NO queue. Fail fast — let the client back off.
// 3. Otherwise runs c.Continue() and reports the outcome.
//
// The breaker is single-purpose: it sheds load when the protected
+11 -7
View File
@@ -19,10 +19,17 @@ import (
"github.com/zap-proto/zip"
)
// Recover catches handler panics and turns them into a 500 JSON response.
// Always include this first in the chain.
// Recover catches handler panics and turns them into a 500. Always include this
// first in the chain.
//
// It RETURNS the refusal rather than writing one, so a panic leaves by the same
// door every other failure does: the app's error handler, which is what knows
// the vocabulary the address answers in and the media type that names it. A
// middleware writing its own body would be a second error surface speaking only
// the default, and a panic at an OAuth endpoint would answer a shape no OAuth
// client parses.
func Recover() zip.Handler {
return func(c *zip.Ctx) error {
return func(c *zip.Ctx) (err error) {
defer func() {
if r := recover(); r != nil {
c.Log().Error("zip panic recovered",
@@ -31,10 +38,7 @@ func Recover() zip.Handler {
"method", c.Method(),
"stack", string(debug.Stack()),
)
_ = c.JSON(500, &zip.HTTPError{
Status: 500,
Msg: "internal server error",
})
err = zip.ErrInternal("internal server error")
}
}()
return c.Continue()
+200
View File
@@ -0,0 +1,200 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package zip
import (
"errors"
"net/http"
fiber "github.com/zap-proto/fiber/v3"
)
// A REFUSAL IS WRITTEN IN THE VOCABULARY ITS ADDRESS SPEAKS.
//
// RFC 9457 is the one an HTTP API answers in: a problem document under the
// registered media type application/problem+json, carrying `type`, `title`,
// `status`, `detail` and `instance`, plus whatever extension members the
// refusal adds. A client that has never seen this service can read a 402 from
// it and know what happened, which is the entire point of a registered type —
// where `{"error":"..."}` is a shape you have to be told about first.
//
// OAuth is the exception, and it is not a matter of taste. RFC 6749 §5.2 states
// the token endpoint's error body as {error, error_description} under
// application/json; RFC 7662 §2.3 and RFC 7009 §2.2.1 point introspection and
// revocation at that same section. Every OAuth client parses those two members
// and nothing else. A problem document there is not a nicer error, it is a
// broken authorization server — so the family keeps its own vocabulary, and
// says so at registration: see [OAuth].
//
// Which one an address speaks is resolved ONCE, here, from the route the
// request matched — never from the error value. The refusals that most need
// translating are the ones no handler wrote: a body that would not bind, a
// panic, a middleware refusing in front of the handler. There is no return
// statement to attach a type to for any of those, and they are exactly the
// requests where a client is already confused.
// mimeProblem is RFC 9457 §3. The suffix is what tells a client the body is
// JSON AND that its members mean what the RFC says they mean.
const mimeProblem = "application/problem+json"
// problem renders a refusal as an RFC 9457 problem document.
//
// instance is the address the refusal happened at. It is a parameter and not a
// field because a refusal does not know its own occurrence: the same
// [ErrNotFound] value may be returned from two routes, and a package-level
// sentinel is returned from all of them. Empty when the value is rendered
// outside a request, and then the member is absent — a document that names no
// occurrence is honest, one that names the wrong one is not.
//
// Extension members MERGE at the top level (RFC 9457 §3.2) rather than nesting
// under a key a reader would have to know to look in. The document's own
// members are written LAST, so a domain key called `status` cannot displace the
// refusal's.
func (e *HTTPError) problem(instance string) map[string]any {
out := make(map[string]any, len(e.Detail)+6)
for k, v := range e.Detail {
out[k] = v
}
// RFC 9457 §3.1.1 — the type of a problem that carries no identity beyond
// its status, and also the value assumed when the member is absent. Written
// out rather than omitted so the document reads completely without the
// reader holding the RFC.
out["type"] = "about:blank"
// §3.1.1 again: with that type, the title SHOULD be the status's own phrase.
// A status with no registered phrase gets no title rather than an invented
// one.
if title := http.StatusText(e.Status); title != "" {
out["title"] = title
}
out["status"] = e.Status
out["detail"] = e.Msg
if e.Code != "" {
out["code"] = e.Code
}
if instance != "" {
out["instance"] = instance
}
return out
}
// oauth renders a refusal as RFC 6749 §5.2: the error CODE, and the human
// description beside it.
//
// [HTTPError] already carries both halves — Code is the token a client
// dispatches on, Msg the sentence a developer reads — so the same value serves
// both vocabularies and there is no second error type to keep in step.
func (e *HTTPError) oauth() map[string]any {
out := make(map[string]any, len(e.Detail)+2)
for k, v := range e.Detail {
out[k] = v
}
code := e.Code
if code == "" {
code = oauthCode(e.Status)
}
out["error"] = code
if e.Msg != "" {
out["error_description"] = e.Msg
}
return out
}
// oauthCode names a refusal the framework produced, where no handler chose a
// code. RFC 6749 §5.2 pairs invalid_client with 401 specifically and makes
// invalid_request the answer for a request that is malformed in any other way;
// server_error is the registry entry (RFC 6749 §4.1.2.1) for a failure that is
// the server's own.
func oauthCode(status int) string {
switch {
case status == 401:
return "invalid_client"
case status >= 500:
return "server_error"
default:
return "invalid_request"
}
}
// refusing is the app's error handler, closed over the addresses that speak
// RFC 6749. The map is built with the router (see [composeOAuth]) and read only
// once a request has already failed, so an app declaring no OAuth surface
// carries a nil map and pays one nil-map lookup on the error path.
func refusing(oauth map[string]bool) fiber.ErrorHandler {
return func(c fiber.Ctx, err error) error {
e := refusal(err)
c.Status(e.Status)
if oauth[c.Method()+" "+c.Route().Path] {
return c.JSON(e.oauth(), mimeJSON)
}
return c.JSON(e.problem(c.Path()), mimeProblem)
}
}
// refusal is the [HTTPError] an error stands for: the one it carries, fiber's
// own with the status it chose, or a 500 for anything else.
//
// A refusal that names no status is answered as a copy rather than by filling
// the original in, because a package-level `var ErrGone = zip.ErrNotFound(…)`
// is one value shared by every request that returns it.
func refusal(err error) *HTTPError {
var he *HTTPError
if errors.As(err, &he) {
if he.Status != 0 {
return he
}
return &HTTPError{Status: 500, Code: he.Code, Msg: he.Msg, Detail: he.Detail}
}
var fe *fiber.Error
if errors.As(err, &fe) {
return &HTTPError{Status: fe.Code, Msg: fe.Message}
}
return &HTTPError{Status: 500, Msg: err.Error()}
}
// OAuth is where the RFC 6749 family is registered: the token, introspection
// and revocation endpoints, whose refusals are {error, error_description} and
// whose neighbours' are problem documents.
//
// oauth := zip.OAuth(r)
// oauth.Post("/v1/oauth/token", token)
// oauth.Post("/v1/oauth/introspect", introspect)
// oauth.Post("/v1/oauth/revoke", revoke)
//
// It returns a Router, so it takes typed ops the same way — zip.Post(oauth, …)
// — and nests: a Group of it is still OAuth's. The vocabulary rides the route
// ENTRY from here (see [App.addRoute]), which is what makes it survive
// composition: a service grafted under a host answers the same way at its new
// path, where an address recorded at registration would name a path that no
// longer exists.
//
// It covers the whole address and not just the handler, because the refusals
// that must obey RFC 6749 include the ones the handler never sees — a body over
// the limit, a panic recovered above it, a gate refusing in front of it.
func OAuth(on OpTarget) Router {
s := on.OpScope()
g := s.App.group(here(1), s.Prefix)
g.wrap = s.Middleware
g.oauth = true
return g
}
// composeOAuth reduces a walk to the addresses whose refusals speak RFC 6749,
// in the absolute spelling the router matched on — the same projection the
// declaration and the op registry are, over the same occurrence slice.
func composeOAuth(occ []occurrence) map[string]bool {
var out map[string]bool
for _, o := range occ {
r, ok := o.route()
if !ok || !r.oauth || r.method == "" {
continue
}
if out == nil {
out = map[string]bool{}
}
for _, m := range declaredMethods(r.method) {
out[m+" "+joinPath(o.ctx.prefix, r.path)] = true
}
}
return out
}
+289
View File
@@ -0,0 +1,289 @@
package zip_test
import (
"context"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
)
// answered drives one request and returns what arrived: status, headers, and
// the body already parsed. Every test here reads the WIRE — a rendering asserted
// against the function that produced it proves only that the function is itself.
func answered(t *testing.T, app *zip.App, method, path string) (*http.Response, map[string]any) {
t.Helper()
req, err := http.NewRequest(method, path, nil)
if err != nil {
t.Fatal(err)
}
res, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(res.Body)
var body map[string]any
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("%s %s: body is not JSON: %s", method, path, raw)
}
return res, body
}
func refusalApp(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{AppName: "refusals", DisableStartupMessage: true})
app.Use(middleware.Recover())
app.Get("/thing/:id", func(c *zip.Ctx) error { return c.JSON(200, map[string]any{"ok": true}) })
app.Put("/thing/:id", func(c *zip.Ctx) error { return c.JSON(200, map[string]any{"ok": true}) })
app.Get("/panic", func(c *zip.Ctx) error { panic("nil map write") })
app.Get("/capped", func(c *zip.Ctx) error {
return zip.ErrPaymentRequired("spend cap exceeded").
With(map[string]any{"cap": 5000, "spent": 5127})
})
oauth := zip.OAuth(app)
oauth.Post("/v1/oauth/token", func(c *zip.Ctx) error {
return &zip.HTTPError{Status: 400, Code: "invalid_grant", Msg: "code already redeemed"}
})
oauth.Post("/v1/oauth/introspect", func(c *zip.Ctx) error { panic("store closed") })
return app
}
// TestARefusalIsAProblemDocument is RFC 9457 on the wire: the registered media
// type, and the five members a client that has never seen this service can read.
func TestARefusalIsAProblemDocument(t *testing.T) {
res, body := answered(t, refusalApp(t), "GET", "/capped")
if res.StatusCode != 402 {
t.Fatalf("status = %d, want 402", res.StatusCode)
}
if ct := res.Header.Get("Content-Type"); ct != "application/problem+json" {
t.Errorf("Content-Type = %q, want application/problem+json — the suffix is what says the members mean what RFC 9457 says", ct)
}
for member, want := range map[string]any{
"type": "about:blank",
"title": "Payment Required",
"status": float64(402),
"detail": "spend cap exceeded",
"instance": "/capped",
} {
if body[member] != want {
t.Errorf("%s = %v, want %v", member, body[member], want)
}
}
// Extension members sit BESIDE the document's own (§3.2), not nested under a
// key a client would have to be told about.
if body["cap"] != float64(5000) || body["spent"] != float64(5127) {
t.Errorf("extension members did not merge: %v", body)
}
}
// TestAWrongVerbSaysWhichVerbsWork is RFC 9110 §15.5.6: a 405 MUST carry Allow.
// Without it a client cannot tell a wrong verb from a wrong address, and the two
// have opposite fixes.
func TestAWrongVerbSaysWhichVerbsWork(t *testing.T) {
res, body := answered(t, refusalApp(t), "DELETE", "/thing/7")
if res.StatusCode != 405 {
t.Fatalf("status = %d, want 405", res.StatusCode)
}
allow := res.Header.Values("Allow")
if len(allow) == 0 {
t.Fatal("no Allow header on a 405")
}
for _, want := range []string{"GET", "HEAD", "PUT"} {
found := false
for _, got := range allow {
for _, m := range splitList(got) {
if m == want {
found = true
}
}
}
if !found {
t.Errorf("Allow %v omits %s, which the address does serve", allow, want)
}
}
for _, m := range []string{"DELETE", "POST", "PATCH"} {
for _, got := range allow {
for _, listed := range splitList(got) {
if listed == m {
t.Errorf("Allow %v lists %s, which the address does not serve", allow, m)
}
}
}
}
if body["status"] != float64(405) || body["title"] != "Method Not Allowed" {
t.Errorf("405 body is not a problem document: %v", body)
}
}
func splitList(s string) []string {
var out []string
cur := ""
for _, r := range s {
switch r {
case ',', ' ':
if cur != "" {
out = append(out, cur)
cur = ""
}
default:
cur += string(r)
}
}
if cur != "" {
out = append(out, cur)
}
return out
}
// TestAPanicIsAProblemDocument pins that a refusal nobody wrote leaves by the
// same door as one somebody did — Recover returns it rather than writing a body
// of its own, so it gets the media type and the vocabulary the address answers
// in.
func TestAPanicIsAProblemDocument(t *testing.T) {
res, body := answered(t, refusalApp(t), "GET", "/panic")
if res.StatusCode != 500 {
t.Fatalf("status = %d, want 500", res.StatusCode)
}
if ct := res.Header.Get("Content-Type"); ct != "application/problem+json" {
t.Errorf("Content-Type = %q, want application/problem+json", ct)
}
if body["status"] != float64(500) || body["instance"] != "/panic" {
t.Errorf("panic did not render as a problem document: %v", body)
}
}
// TestTheOAuthFamilyKeepsItsOwnVocabulary is the carve-out, and it is not a
// preference: RFC 6749 §5.2 states {error, error_description} under
// application/json, and RFC 7662 §2.3 and RFC 7009 §2.2.1 point at that section.
// A problem document here is a broken authorization server.
func TestTheOAuthFamilyKeepsItsOwnVocabulary(t *testing.T) {
res, body := answered(t, refusalApp(t), "POST", "/v1/oauth/token")
if res.StatusCode != 400 {
t.Fatalf("status = %d, want 400", res.StatusCode)
}
if ct := res.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json — RFC 6749 §5.2", ct)
}
if body["error"] != "invalid_grant" || body["error_description"] != "code already redeemed" {
t.Errorf("token endpoint answered %v, which no OAuth client parses", body)
}
for _, member := range []string{"type", "title", "detail", "instance", "status"} {
if _, ok := body[member]; ok {
t.Errorf("problem member %q leaked into an OAuth error", member)
}
}
}
// TestAnOAuthAddressSpeaksOAuthForRefusalsItDidNotWrite is why the vocabulary is
// a property of the ADDRESS and not of the returned value. A panic, a body that
// will not bind, a gate refusing in front of the handler: no handler is there to
// choose a shape for any of them, and they are exactly the requests where a
// client is already confused.
func TestAnOAuthAddressSpeaksOAuthForRefusalsItDidNotWrite(t *testing.T) {
res, body := answered(t, refusalApp(t), "POST", "/v1/oauth/introspect")
if res.StatusCode != 500 {
t.Fatalf("status = %d, want 500", res.StatusCode)
}
if body["error"] != "server_error" {
t.Errorf("error = %v, want the RFC 6749 code for a failure that is the server's own", body["error"])
}
if _, ok := body["type"]; ok {
t.Errorf("a panic at an OAuth address answered a problem document: %v", body)
}
}
// TestAWrongVerbAtAnOAuthAddressIsTheRouterRefusing draws the line: the
// vocabulary belongs to the METHOD+path pair that was registered, so a verb the
// endpoint does not serve is the router's refusal and carries Allow, which is
// the answer the client actually needs.
func TestAWrongVerbAtAnOAuthAddressIsTheRouterRefusing(t *testing.T) {
res, body := answered(t, refusalApp(t), "GET", "/v1/oauth/token")
if res.StatusCode != 405 {
t.Fatalf("status = %d, want 405", res.StatusCode)
}
if got := res.Header.Get("Allow"); got != "POST" {
t.Errorf("Allow = %q, want POST", got)
}
if body["status"] != float64(405) {
t.Errorf("405 body is not a problem document: %v", body)
}
}
// TestTheVocabularySurvivesComposition is the reason it rides the route entry
// and not a table of addresses: a definition included under a host answers at a
// path it did not know it would have.
func TestTheVocabularySurvivesComposition(t *testing.T) {
auth := zip.New(zip.Config{AppName: "auth", DisableStartupMessage: true})
oauth := zip.OAuth(auth)
oauth.Post("/token", func(c *zip.Ctx) error { return zip.ErrUnauthorized("bad secret") })
host := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
host.Group("/v1/iam/oauth").Use(auth)
res, body := answered(t, host, "POST", "/v1/iam/oauth/token")
if res.StatusCode != 401 {
t.Fatalf("status = %d, want 401", res.StatusCode)
}
if body["error"] != "invalid_client" || body["error_description"] != "bad secret" {
t.Errorf("the grafted token endpoint answered %v — the vocabulary did not follow the route", body)
}
}
// TestAGroupOfTheOAuthRouterIsStillOAuth pins that the vocabulary descends the
// way a scoped chain does. A nested scope answering its parent's neighbours'
// shape is the same silently-lost property as a gate dropped at the second
// nesting, and just as invisible until a client breaks.
func TestAGroupOfTheOAuthRouterIsStillOAuth(t *testing.T) {
app := zip.New(zip.Config{AppName: "nested-oauth", DisableStartupMessage: true})
zip.OAuth(app).Group("/v1/oauth").Post("/revoke", func(c *zip.Ctx) error {
return zip.ErrBadRequest("token is required")
})
res, body := answered(t, app, "POST", "/v1/oauth/revoke")
if res.StatusCode != 400 {
t.Fatalf("status = %d, want 400", res.StatusCode)
}
if body["error"] != "invalid_request" || body["error_description"] != "token is required" {
t.Errorf("a group of the OAuth router answered %v — the vocabulary did not descend", body)
}
}
// TestOAuthTakesTypedOpsToo pins that the carve-out is a Router in full: an op
// declared through it is registered, documented and refused the same way an
// untyped handler is.
func TestOAuthTakesTypedOpsToo(t *testing.T) {
app := zip.New(zip.Config{AppName: "typed-oauth", DisableStartupMessage: true})
type in struct {
Token string `json:"token"`
}
type out struct {
Active bool `json:"active"`
}
zip.Post(zip.OAuth(app), "/v1/oauth/revoke", func(_ context.Context, i *in) (*out, error) {
return nil, zip.ErrBadRequest("token is required")
})
res, body := answered(t, app, "POST", "/v1/oauth/revoke")
if res.StatusCode != 400 {
t.Fatalf("status = %d, want 400", res.StatusCode)
}
if body["error"] != "invalid_request" || body["error_description"] != "token is required" {
t.Errorf("typed op at an OAuth address answered %v", body)
}
if len(app.Routes()) != 1 || app.Routes()[0].Op == "" {
t.Errorf("the op was not registered as an op: %v", app.Routes())
}
}
+5
View File
@@ -229,6 +229,11 @@ type App struct {
// nothing for it.
wrap Middleware
// oauth: every route registered on this definition answers RFC 6749 §5.2.
// Set by [OAuth] on the group it hands back, so the vocabulary is a property
// of where a route was declared and not of what its handler returns.
oauth bool
servers []Server // the running transport listeners, set by Listen
// unbind withdraws this app from the by-address table [Serving] reads, one