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