Files
zip/problem.go
T
zeekayandHanzo Dev 5f71d2496b The router is three nouns, and every one of them is concrete
App, Group, Operation. A group is a prefix and what holds under it, an operation
is one typed route declared there, and the verb methods are generic methods —
which Go 1.27 allows on a concrete type and still refuses on an interface. So In
and Out come from the handler and the caller writes no type arguments:

	users := app.Group("/v1/users")
	users.Post("/", api.Create)

	mint := users.Group("/mint").With(middleware.Mint(gate))
	mint.Post("/deposit", api.Deposit)

A Group is a CLOSED HANDLE: two unexported fields, and no method that hands back
the app, the parent or the fiber underneath. A subsystem given the group at its
own prefix cannot reach above it, so confinement is a property of the type
instead of a rule something has to police — which is what a host needs in order
to hand out a bounded surface and know what comes back.

That is bought by giving up the router interface, deliberately. Router and
OpTarget are gone, and with them the ability to implement a route table outside
this package: a decorator is no longer a router, a gate is middleware on a group,
and a bound is a path. The five package-level verb functions go the same way —
a registration is a method on the thing it registers into.

Raw carries what is not a typed operation: a stream, an upgrade, a probe, a
foreign subtree. It names its method, and it is the longer spelling because
opting out of the typed model is the exception and should read like one.



Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 01:39:17 -07:00

192 lines
7.3 KiB
Go

// 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` and `detail`, 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.
//
// The optional `instance` member (RFC 9457 §3.1.4) is not written. It would
// name the address the refusal happened at — which the caller supplied and
// already holds, so it tells them nothing. What it does do is make two refusals
// at two addresses differ in their bodies, and the property worth testing about
// a refusal is that asking about a real resource and a fabricated one produce
// the SAME answer. Echoing the request path turns that comparison into one that
// can only pass by being loosened, so the member is left out.
//
// 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() 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
}
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(), 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 group, so it takes typed ops the same way — oauth.Post(…)
// — 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 composed 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.
// 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
}