Files
zeekay 1dc336e5e2 Bound form fields and body codings; say what HTTP does to multipart
A url-encoded form is refused past 10000 fields, the bound Go's own
query parser holds, before any field is decoded, and a form keeps only
the keys In binds. A few kilobytes of gzip that decode to millions of
empty fields cost the decode and are answered 400, where they allocated
hundreds of megabytes a field at a time.

Every Content-Encoding line is undone, since two lines say what one
comma-separated line says, and a chain of more than three codings is
415 instead of being decoded layer by layer up to the body limit.

fasthttp reads a multipart body as a form before routing, so over HTTP
a Body field holds that form written back out, which is what c.Body()
gives a raw handler; ZAP carries the bytes as sent. Body's doc says so
instead of promising the bytes as sent on every transport.
2026-10-09 12:17:22 -07:00

931 lines
38 KiB
Go

package zip
import (
"context"
"encoding"
"fmt"
"reflect"
"runtime"
"runtime/debug"
"strconv"
"strings"
"sync"
"github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip/internal/jsonenc"
)
// TypedHandler is the generic handler signature: takes an *In, returns
// (*Out, error). zip generates OpenAPI 3.1 spec from the In/Out types
// and registers a Fiber route that unmarshals body → In, runs the
// handler, and marshals Out → JSON response.
type TypedHandler[In, Out any] func(ctx context.Context, in *In) (*Out, error)
// registeredOp is the bookkeeping zip keeps for a typed route — the ONE value
// every projection reads: the REST route, the OpenAPI doc, the MCP tool, the CLI
// command and the by-name call plane all come from this. invoke is the
// transport-agnostic handler core (decode → run → result), so a REST request, an
// MCP tools/call, a command and a zip.Call run the exact same fn.
//
// An UNTYPED route (app.Get(path, func(c *Ctx) error)) appends nothing here, so
// it is in none of those five. That is the whole cost of the escape hatch, and
// the reason a route that can name its input and output should not use it.
type registeredOp struct {
Method string
Path string
OperationID string
Summary string
// ResponseHeaders are the header names this op may set on its answer,
// declared. A name not in this set is refused rather than written — the
// document publishes exactly this list, and a header a client relies on is
// part of the contract. See [HeaderCoder].
ResponseHeaders []string
// readsHeaders is whether the input declares any `header:` field, computed
// ONCE at registration. Without it every request would walk the input type
// by reflection to discover that it declares none, which is the answer for
// almost every op.
readsHeaders bool
// Statuses are the success codes this op may answer with, declared. Empty
// means the default (200, or 204 for a nil Out). More than one means the
// handler CHOOSES per request — see [WithStatus] and [StatusCoder].
Statuses []int
Tags []string
InType reflect.Type
OutType reflect.Type
// Consumes and Produces are the media the op's request body and answer are
// declared in when they are not application/json. See [Consumes] and
// [Produces].
Consumes []string
Produces []string
// Origins are the browser origins besides its own that may open the op's
// WebSocket ([Origins]).
Origins []string
// req is how the input arrives and ans what the answer may be, each read
// once from In and Out at registration.
req intake
ans answer
// Pkg is the import path of the package that REGISTERED this op. It
// namespaces the op's entry in the process-wide documentation map, where an
// address alone is not unique: two chains in one node both answer
// GET /height. See [docs].
Pkg string
// Origin names the app this op was DECLARED in when it arrived here through
// composition — empty for an op this app registered itself. It qualifies the
// op's named types in the composed document (see schemaRegistry.origin), so
// two children may both call a type Application without one overwriting the
// other. It is the child's own AppName, never a prefix or a deployment
// choice: it says WHO declared the type, which is a property of the code.
Origin string
// rule is the [Authorizer] in force over this op. Asked rather than stored,
// because composition settles it at build (see [adopt]) and the op was
// registered long before that. The invoke seam asks it on every call and the
// document asks it once — so "is this op governed" has one answer, and a
// gated op cannot publish a contract its seam does not keep.
rule func() Authorizer
// result is the [App.OnResult] hook in force over this op, asked the same way
// and for the same reason as rule: composition settles it at build, and the
// op was registered before that.
result func() func(context.Context, Op, error)
invoke func(ctx context.Context, in input) (any, error)
// direct is invoke with the decoding removed: the In arrives as the *In the
// caller already holds. It exists for the one transport that is not a
// transport — a call whose two ends are the same process (see [Here]) —
// where encoding the value would only be a way of copying it.
//
// It shares the contract half (validate → authorize → run) with invoke
// rather than reimplementing it, so there is no path into a handler that
// skips a check another way makes. What it does not share is the BINDING of
// a URL and headers onto the input: there is no URL and no request, and an
// op reached this way is given its whole input at once.
direct func(ctx context.Context, in any) (any, error)
}
// plain says the op takes one JSON value and answers one: no body taken as
// bytes, as a form or by a Parser, and no answer that is bytes, a relay, a
// stream, an upgrade, a redirect or a union. It is the shape a graph field
// resolves to.
func (op *registeredOp) plain() bool {
return !op.req.whole() && !op.req.files && op.ans.stream == "" && !op.ans.verbatim &&
!op.ans.redirect && op.ans.union == nil && len(op.ans.json) <= 1
}
// decoder reads a request body into an op's In. It is a PARAMETER rather than a
// property of the op because the encoding belongs to the transport, not to the
// contract: the same op answers a browser over JSON and a sibling service over
// ZAP, and there is still exactly one handler core underneath both.
type decoder func([]byte, any) error
// OpOption configures a typed handler registration (OpenAPI metadata).
type OpOption func(*registeredOp)
// WithSummary sets the operation summary in OpenAPI.
func WithSummary(s string) OpOption { return func(op *registeredOp) { op.Summary = s } }
// WithTags sets the operation tags in OpenAPI.
func WithTags(tags ...string) OpOption { return func(op *registeredOp) { op.Tags = tags } }
// WithOperationID sets the operation ID in OpenAPI.
func WithOperationID(id string) OpOption {
return func(op *registeredOp) { op.OperationID = id }
}
// WithStatus declares the status a SUCCESSFUL op answers with — 201 for an op
// that creates a resource, 202 for one that accepts work it has not finished.
// Without it an op answers 200, or 204 when its handler returns a nil Out.
//
// It is declared on the op because the status is part of the CONTRACT, and the
// contract is what the registry projects: the OpenAPI document keys its response
// on this code, so a generated SDK expects the status the service actually
// sends. Setting it per request instead — reaching around the framework from
// inside a handler — writes a contract detail into a side channel no projection
// can read, which is how a document comes to say 200 about a route that has
// always answered 201.
//
// The status is an HTTP notion, so it applies to the REST route and the document
// and to nothing else: an MCP tools/call, a CLI command and a zip.Call all carry
// their own outcome and are untouched.
//
// # Declaring a non-2xx
//
// A declared non-2xx is for an op that answers a failure with its OWN TYPED
// BODY — a 409 carrying the conflicting record, a 404 carrying what was
// searched for. It is not a second way to spell an error: [ErrNotFound] and
// friends remain how a handler returns a failure, and they render the RFC 9457
// problem document every refusal is written in.
//
// The difference is which body reaches the wire. An error returns the problem
// document; a declared status returns the op's Out type, described in the
// document under that code like any other response. Reach for the error unless
// the caller genuinely needs a typed body a problem document cannot carry — two
// ways to spell the same failure is exactly the drift this package spends its
// doc comments avoiding.
func WithStatus(codes ...int) OpOption {
if len(codes) == 0 {
panic("zip: WithStatus needs at least one status")
}
seen := map[int]bool{}
for _, code := range codes {
if code < 100 || code > 599 {
panic("zip: WithStatus wants an HTTP status")
}
if seen[code] {
panic("zip: WithStatus: duplicate status")
}
seen[code] = true
}
return func(op *registeredOp) { op.Statuses = append([]int(nil), codes...) }
}
// StatusCoder is how an op with MORE THAN ONE declared success status says which
// one this answer is. The OUTPUT VALUE states it:
//
// func (r *CreateOut) StatusCode() int {
// if r.Existed { return 200 }
// return 201
// }
//
// zip.Post(app, "/v1/things", create, zip.WithStatus(200, 201))
//
// The status is a property of the ANSWER, so it rides the value the handler
// already returns. It is deliberately not a mutable slot on the request: a slot
// is a side channel, and a side channel is invisible to every projection, which
// is the whole defect this closes.
//
// A code the op did not declare is refused at the seam rather than written to
// the wire, because the document publishes exactly the declared set and a
// generated client expects nothing else.
type StatusCoder interface{ StatusCode() int }
// statusOf is the code an answer carries: what the value states if it states
// anything, else the op's first declared status, else the default. A [Body] or
// a [Redirect] that states zero states nothing; any other value stating zero
// states a status it did not declare. relayed says the answer is an upstream's,
// whose 4xx and 5xx pass through as the upstream's refusal ([Verbatim]).
func statusOf(op *registeredOp, out any, relayed bool) (int, error) {
if sc, ok := out.(StatusCoder); ok && (sc.StatusCode() != 0 || !ownStatus(out)) {
got := sc.StatusCode()
for _, declared := range op.Statuses {
if declared == got {
return got, nil
}
}
if relayed && got >= 400 && got <= 599 {
return got, nil
}
// An ERROR, not a panic: this is a runtime value, so it cannot be caught
// at boot, and a panic on a serving goroutine is a worse answer than a
// refusal the error chain renders. The document publishes exactly the
// declared set, so sending anything else tells the caller one thing and
// hands them another.
return 0, ErrInternal(fmt.Sprintf("%s %s answered %d, which it does not declare — "+
"declare it with zip.WithStatus(%d) so the document, the SDKs and the CLI publish it",
op.Method, op.Path, got, got))
}
if _, redirect := out.(redirector); redirect {
for _, code := range op.Statuses {
if code >= 300 && code < 400 {
return code, nil
}
}
}
if len(op.Statuses) > 0 {
return op.Statuses[0], nil
}
return 0, nil
}
// ownStatus reports whether an answer is one of zip's kinds whose zero status
// means the op's own: a [Body] or a [Redirect], or a type embedding one.
func ownStatus(out any) bool {
_, body := out.(answerBody)
_, redirect := out.(redirector)
return body || redirect
}
// WithResponseHeader declares the headers this op may set on its answer.
//
// zip.Get(app, "/v1/report", report,
// zip.WithResponseHeader("Cache-Control"))
//
// It is the response half of [WithStatus], and it exists for the same reason: a
// response header a caller can rely on — a cache directive, a payment challenge,
// a Set-Cookie — is part of the contract, so it belongs in the document rather
// than in a context slot some middleware writes on the way out. That slot is
// invisible to OpenAPI, to generated SDKs and to the tool schema, which is
// exactly how a browser session cookie came to be set by a route no projection
// described.
//
// The VALUE is stated by the answer, via [HeaderCoder].
func WithResponseHeader(names ...string) OpOption {
if len(names) == 0 {
panic("zip: WithResponseHeader needs at least one header name")
}
return func(op *registeredOp) { op.ResponseHeaders = append(op.ResponseHeaders, names...) }
}
// HeaderCoder is how an answer states the response headers it carries. The
// headers ride the value the handler already returns, so there is no mutable
// slot on the request:
//
// func (r *ReportOut) ResponseHeaders() map[string]string {
// return map[string]string{"Cache-Control": "no-store"}
// }
//
// A name the op did not declare is refused at the seam. The document publishes
// the declared set, so writing anything else tells a caller one thing and sends
// another — the same rule an undeclared status obeys.
//
// # Transports without a response of their own
//
// The headers are written wherever the op OWNS the response: over REST, and over
// the by-name call plane, where one request carries one op. They are NOT written
// over MCP, because one HTTP response there carries a whole JSON-RPC envelope
// that may hold several results, and there is no per-op response to put them on.
// That is a defined meaning, not an oversight: the declared set still appears in
// the tool schema, so an agent can see what the op says about itself, and the
// answer's value is identical on every transport.
type HeaderCoder interface{ ResponseHeaders() map[string]string }
// responseHeadersOf is the header set an answer carries, checked against what
// the op declared.
func responseHeadersOf(op *registeredOp, out any) (map[string]string, error) {
hc, ok := out.(HeaderCoder)
if !ok {
return nil, nil
}
got := hc.ResponseHeaders()
if len(got) == 0 {
return nil, nil
}
for name := range got {
declared := false
for _, d := range op.ResponseHeaders {
if strings.EqualFold(d, name) {
declared = true
break
}
}
if !declared {
return nil, ErrInternal(fmt.Sprintf("%s %s set response header %q, which it does not declare — "+
"declare it with zip.WithResponseHeader(%q) so the document, the SDKs and the tool schema publish it",
op.Method, op.Path, name, name))
}
}
return got, nil
}
// bindURL copies URL-borne values onto the decoded input, matching a name to the
// field whose json tag (else field name, case-insensitively) equals it. It is the
// ONE binder for both URL sources — path params and query params are the same
// kind of value (a name and a string, carried by the URL), so they get the same
// function rather than two that drift.
//
// Values arrive as strings on the wire and are converted to the field's kind:
// string, bool, the sized ints/uints, and the floats. An unparseable value leaves
// the field at its zero value rather than failing the request — `?limit=abc` is a
// caller's typo about ONE field, and refusing the whole call would make every
// typed GET brittler than the untyped handler it replaced. Declare `validate:` on
// the field to make a value mandatory.
//
// A name with no matching field is silently ignored: the route pattern and the
// input type are written together, so a mismatch is a programming error the
// OpenAPI projection surfaces, not a request to reject — refusing here would turn
// a spec typo into a 400 on every call to that route. Unknown query keys are
// ordinary (callers append tracking params), so ignoring them is required, not
// merely tolerant.
//
// Only the top level is walked. A nested field is not a URL target: the URL
// addresses one resource, and an input that nests its record declares its target
// explicitly (see the authorizer's `owned` interface) rather than having it
// guessed out of a sub-struct an attacker also controls.
//
// A field names itself for the URL with `url:` and opts out with `url:"-"` — see
// [urlFieldName]. That is what makes a route typable when its path parameter and
// one of its body fields are the same word for two different things.
// bindHeaders copies declared header values onto the decoded input. Only fields
// carrying a `header:` tag are eligible: an op reads the headers it DECLARES and
// no others, so the document and the input can never disagree about which ones
// matter.
//
// get is a function rather than a map because the transports answer differently
// and all of them answer honestly: over HTTP it reads the request's headers;
// over MCP and the call plane, where the op's arguments arrived as a JSON object
// and there is no per-op request, it reads nothing and the field keeps whatever
// the arguments supplied. That is the same shape [CallerOf] uses, and the reason
// neither of them panics on a transport that has no HTTP request.
func bindHeaders(in any, get func(string) string) {
if get == nil {
return
}
v := reflect.ValueOf(in)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return
}
for _, f := range wireFields(v.Type()) {
name := headerFieldName(f)
if name == "" {
continue
}
fv := v.FieldByIndex(f.Index)
if !fv.CanSet() {
continue
}
if got := get(name); got != "" {
setScalar(fv, got)
}
}
}
func bindURL(in any, values map[string]string) {
if len(values) == 0 {
return
}
v := reflect.ValueOf(in)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return
}
bindFields(v, "", values)
}
// bindFields writes what the URL carried onto v's fields, under prefix. It
// reads [nests], the same predicate [urlFields] reads, so what a caller may
// write and what the document publishes are one answer.
//
// A record's leaves are named THROUGH it — `?startIndex.utxo=` — and only
// through it. A bare `?utxo=` still reaches a top-level utxo and never a nested
// one, so an input that nests a record names its target explicitly and an
// authorizer reads the target the caller wrote.
//
// Reports whether anything bound, which is how a pointer record tells "the
// caller named nothing in here" from "the caller named it empty".
func bindFields(v reflect.Value, prefix string, values map[string]string) bool {
bound := false
for _, f := range wireFields(v.Type()) {
name := urlFieldName(f)
if name == "-" {
continue
}
name = prefix + name
// FieldByIndex, because wireFields reaches through embedding: the index is
// the path to the field, which is just [i] for the type's own fields.
fv := v.FieldByIndex(f.Index)
if !fv.CanSet() {
continue
}
if urlRecord(fv.Type()) {
bound = bindRecord(fv, name+".", values) || bound
continue
}
for k, val := range values {
if strings.EqualFold(k, name) {
setScalar(fv, val)
bound = true
break
}
}
}
return bound
}
// bindRecord binds one nested record's leaves, allocating a pointer only where
// the URL named something inside it — a record nobody wrote stays absent rather
// than becoming an empty one.
//
// The descent is bounded by the KEYS, not by the type: a record is entered only
// where a key is written under its prefix, so a self-referential input costs one
// pass per level a caller actually named.
func bindRecord(fv reflect.Value, prefix string, values map[string]string) bool {
if !namedUnder(prefix, values) {
return false
}
if fv.Kind() != reflect.Pointer {
return bindFields(fv, prefix, values)
}
held := reflect.New(fv.Type().Elem())
if !bindFields(held.Elem(), prefix, values) {
return false
}
fv.Set(held)
return true
}
// namedUnder reports whether the URL wrote anything under prefix.
func namedUnder(prefix string, values map[string]string) bool {
for k := range values {
if len(k) > len(prefix) && strings.EqualFold(k[:len(prefix)], prefix) {
return true
}
}
return false
}
// setScalar writes one wire string into one field.
//
// A type that states HOW TO READ ITSELF FROM TEXT has said the last word, and
// is asked first — see [setText]. That is the precedence encoding/json already
// keeps: a named string with an UnmarshalText is decoded through it and not by
// assignment, so a URL and a JSON body on the same struct read the same word
// the same way. It also has to be first to be reached at all, because a written
// form is very often carried by a NUMERIC kind: a P-Chain height is a u64 whose
// reserved value is spelled "proposed", and an encoding is a u8 spelled "json".
// Deciding those by kind ran ParseUint on a word, failed, and left the field at
// zero — a request for the proposed height served as height 0, and a request
// for JSON served as hex, both with a 200 on the answer.
//
// After that it is the field's kind, and what is left is a record rather than a
// value: a map, or a struct with no written form. Those stay untouched here —
// [bindRecord] names their leaves through them.
func setScalar(fv reflect.Value, val string) {
if readsText(fv.Type()) {
setText(fv, val)
return
}
switch fv.Kind() {
case reflect.String:
fv.SetString(val)
case reflect.Bool:
// An empty value means "flag present" — `?debug` reads as true, the
// convention every HTML form and CLI already uses.
if val == "" {
fv.SetBool(true)
return
}
if b, err := strconv.ParseBool(val); err == nil {
fv.SetBool(b)
}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if n, err := strconv.ParseInt(val, 10, fv.Type().Bits()); err == nil {
fv.SetInt(n)
}
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
if n, err := strconv.ParseUint(val, 10, fv.Type().Bits()); err == nil {
fv.SetUint(n)
}
case reflect.Float32, reflect.Float64:
if f, err := strconv.ParseFloat(val, fv.Type().Bits()); err == nil {
fv.SetFloat(f)
}
case reflect.Slice:
setList(fv, val)
}
}
// setList writes one wire string into a repeated field, splitting it on commas.
//
// That is `style: form, explode: false`, the spelling OpenAPI already publishes
// for a repeated query parameter and the one a URL can carry in a single value
// — which is what the binder is given, one value per name. Each element is
// written by [setScalar], so a list of ids says nothing about ids a second
// time, and a list of lists is refused the same way a bare one would be.
//
// A []byte is not a list here. encoding/json spells it as one base64 string, so
// splitting it on commas would invent a shape nothing sends; it goes to
// [setText] instead, where a named byte slice with its own written form is read
// by that form.
func setList(fv reflect.Value, val string) {
if fv.Type().Elem().Kind() == reflect.Uint8 {
setText(fv, val)
return
}
parts := strings.Split(val, ",")
out := reflect.MakeSlice(fv.Type(), len(parts), len(parts))
for i, p := range parts {
setScalar(out.Index(i), strings.TrimSpace(p))
}
fv.Set(out)
}
// setText writes one wire string into a field that states how to read itself
// from text.
//
// An id, a hash, a timestamp, a public key: each is one value with one written
// form, and each is spelled in a URL every day. The kinds above cannot hold any
// of them — a 32-byte id is an ARRAY — so a route addressing a resource by its
// id published a query parameter that silently bound nothing, and the handler
// ran on a zero id. The type already answered the question: it implements
// [encoding.TextUnmarshaler], which is precisely "here is how to read me from
// the characters a URL can carry".
//
// The basic kinds are decided ABOVE this, so a named string that also reads
// text keeps the reading it has always had, and this only reaches what nothing
// else could carry.
//
// Reading is all-or-nothing: the value is built beside the field and moves onto
// it only on success, so an unparseable value leaves the zero — the same
// contract the numeric cases keep, rather than a half-written field.
func setText(fv reflect.Value, val string) {
t := fv.Type()
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
// TextUnmarshaler is declared on the pointer, so the fresh value is one.
held := reflect.New(t)
read, ok := held.Interface().(encoding.TextUnmarshaler)
if !ok || read.UnmarshalText([]byte(val)) != nil {
return
}
if fv.Kind() == reflect.Pointer {
fv.Set(held)
return
}
fv.Set(held.Elem())
}
// readsText is [setText]'s question asked of the TYPE, which is what the
// document has to ask: describing a parameter there is no value to address.
func readsText(t reflect.Type) bool {
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
return reflect.PointerTo(t).Implements(textReader)
}
var textReader = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()
// registrarPkg is the import path of the package that called Get/Post/… — the
// package cmd/zipdoc files this op's prose under, because that is the source it
// read the registration and the handler's doc comment from.
//
// The HANDLER's package is the wrong question and used to be the one asked. It
// answers the same thing only while the handler is declared where it is
// registered, and a handler composed by a helper elsewhere breaks it: a metered
// op registered as fare.Paid(s, o.listBuckets) reports the helper's package, so
// every one of apps/s3's seven paid ops lost its prose — the document, the MCP
// tools and the SDKs published them with no description. It reported the
// registering package under Go 1.26 and the helper's under 1.27, because whether
// that closure is inlined is the compiler's business and not a contract.
func registrarPkg() string {
var pcs [16]uintptr
frames := runtime.CallersFrames(pcs[:runtime.Callers(3, pcs[:])])
for {
f, more := frames.Next()
if pkg := packageOf(f.Function, mainPath()); pkg != "" && pkg != zipPkg {
return pkg
}
if !more {
return ""
}
}
}
// zipPkg is this package, whose frames sit between a registration and its caller.
const zipPkg = "github.com/zap-proto/zip"
// packageOf reads the package out of a runtime function name. program is the
// import path that "main" stands for.
func packageOf(full, program string) string {
slash := strings.LastIndexByte(full, '/')
dot := strings.IndexByte(full[slash+1:], '.')
if dot < 0 {
return full
}
if pkg := full[:slash+1+dot]; pkg != "main" {
return pkg
}
return program
}
// mainPath is the import path of this binary's package main.
var mainPath = sync.OnceValue(func() string {
if bi, ok := debug.ReadBuildInfo(); ok && bi.Path != "" {
return bi.Path
}
return "main"
})
// settle completes the op's declaration from what its In and Out say — the
// media it consumes, the statuses and headers its answer states — and refuses
// one the wire cannot carry. It runs after every set of options, at
// registration and again after [Operation.With], so a declaration made either
// way is checked the same.
func (op *registeredOp) settle() {
if len(op.Consumes) > 0 && !hasBody(op.Method) {
panic(fmt.Sprintf("zip: %s %s carries no request body, so it consumes nothing", op.Method, op.Path))
}
op.req.settle(op.Method, op.InType, op.Consumes)
op.Consumes = consumes(op)
op.ans.settle(op)
}
// registerTyped declares one typed op. depth is how many frames sit between it
// and the line the programmer wrote, NOT counting the public door itself: zero
// for [Get] and its four siblings, one for a scope's verb method, which reaches
// here through [Scope.declare]. Get it wrong and every conflict, cycle and
// post-seal write in a scoped service cites a line in some caller far above the
// registration. TestSite_IsTheWrittenLine holds the count.
func registerTyped[In, Out any](depth int, on *App, method, path string, fn TypedHandler[In, Out], opts ...OpOption) *registeredOp {
scope := on.opScope()
app := scope.App
// The op's path is the WHOLE path — the group's prefix composed with the
// leaf, exactly as the router composes it. Every projection keys on
// op.Path, so a prefix left out here would name a route that does not exist.
path = joinPath(scope.Prefix, path)
var inZero In
var outZero Out
op := &registeredOp{
Method: method,
Path: path,
Pkg: registrarPkg(),
InType: reflect.TypeOf(inZero),
OutType: reflect.TypeOf(outZero),
}
for _, o := range opts {
o(op)
}
op.req = intakeOf(op.InType)
op.ans = answerOf(op.OutType)
op.settle()
op.readsHeaders = len(headerFields(op.InType)) > 0
op.rule = app.rule
op.result = app.result
// The op's stable identity, resolved once (after opts) and handed to the
// authorizer on every invoke — REST and MCP alike.
meta := Op{Method: op.Method, Path: op.Path, OperationID: opName(op)}
// The CONTRACT, on an In however it was obtained: validate, authorize, run
// fn, return Out (or a literal nil for a void result). Both seams below end
// here, so no way of reaching this op can skip a check another way makes. A
// nil *Out becomes a nil `any`.
run := func(ctx context.Context, in *In) (out any, err error) {
// The call, readable by the handler through [OpOf] and [AddressOf].
// Stated here because this is the one contract every projection funnels
// through and the one place that holds BOTH the resolved pattern and the
// decoded input — so the address is computed once, correctly, for REST, a
// tools/call, the call plane and an in-process invoke alike.
//
// The authorizer below and the result hook are handed [Op] as an argument
// and are unaffected: neither is given the address.
ctx = withAddress(ctx, meta, in)
// AS SERVED, not as declared. A rule keyed on the path has to see the
// address the caller reached, and a recorder has to name the operation
// the way the route table and the registry name it. Both used to be
// handed the registration — the bare leaf for anything declared on a
// group — so a path-keyed rule silently governed an address nobody
// serves, and one definition mounted twice reported one name for two
// operations.
served := meta
if c, ok := ctx.Value(opKey{}).(*invocation); ok {
served = c.op
}
// TOLD ONCE, ON EVERY WAY OUT. This is the one contract every projection
// funnels through — REST, an MCP tools/call, the call plane, the graph and
// an in-process invoke — so a hook here is told about all of them, and a
// refusal is told about as surely as a success: [Deny] returns through the
// same named err this reads.
//
// It is told the OPERATION and the OUTCOME and nothing else. Not the
// output: a hook that could see the value would be a way for a response
// body to reach a log or a trail, and the one thing every recorder in this
// estate promises is that it never reads bodies. Not the input either, for
// the same reason — [Authorizer] already sees that, before the handler, in
// the one place a decision belongs.
//
// A held op reports a nil error. It did not fail; it did not run either,
// and which of those happened is carried by the [Approval] the caller
// receives rather than by this.
if told := op.result(); told != nil {
defer func() { told(ctx, served, err) }()
}
if err := validate(in); err != nil {
return nil, ErrBadRequest(err.Error())
}
// Authorize the DECODED input — the exact value the handler will bind — so
// the decision cannot diverge from execution. Runs for REST and MCP alike.
//
// The three-valued [Decision] is resolved HERE, at the one seam every
// projection funnels through, so allow, deny and approve reach REST, MCP,
// the call plane, the graph and the CLI without a branch in any of them.
if auth := op.rule(); auth != nil {
d, err := auth(ctx, served, in)
if err != nil {
return nil, err // the check itself failed, which is not a refusal
}
switch d.Effect {
case Deny:
return nil, d.refusal()
case Approve:
// Held: the handler does not run, and the [Approval] is the result.
// One value, and each projection renders it — a 202 over REST and
// the call plane, the value over MCP and the CLI, the error lane to
// a Go caller, who reads it with [HeldOf].
return d.approval(), nil
}
}
// The handler's result stays TYPED here. Binding it to the `any` return
// instead would wrap a nil *Out in a non-nil interface, and the nil test
// below — which is what a void op's 204 and a declared status both rest on
// — would stop being true. Caught by opstatus_test.go, which is the only
// reason the named returns above are safe to have.
res, ferr := fn(ctx, in)
if ferr != nil {
return nil, ferr
}
if res == nil {
return nil, nil
}
return res, nil
}
// The in-process seam: the caller already holds the *In, so there is nothing
// to decode and no URL to bind from. See registeredOp.direct and [Here].
op.direct = func(ctx context.Context, in any) (any, error) {
v, ok := in.(*In)
if !ok {
return nil, ErrBadRequest(fmt.Sprintf("%s takes %T, not %T", opName(op), (*In)(nil), in))
}
// No connection reaches an op this way, so a socket op is refused
// before its handler runs, as over the call plane and MCP.
if op.ans.stream == streamSocket {
return nil, Errorf(fiber.StatusNotImplemented, "%s: %s", opName(op), NotACall)
}
return run(ctx, v)
}
// The transport-agnostic core: decode the input the way the op takes it,
// bind the request's declared facts and the URL, then the contract above.
// REST, MCP, the CLI and the call plane all call THIS — one handler, many
// projections.
op.invoke = func(ctx context.Context, wire input) (any, error) {
var in In
if err := op.req.decode(&in, wire, op.Consumes); err != nil {
return nil, err
}
if !wire.sent {
op.req.defaultBodyType(&in, op.Consumes)
}
// The sources bind in increasing authority: body, then headers and
// cookies, then query, then path. Query beats the body because it is
// part of the URL; path beats query because it is the part the router
// MATCHED on.
//
// The URL is the addressing authority: PATCH /users/acme/bob updates
// acme/bob whatever the body claims. This is also what keeps the
// authorizer honest — it runs below on this same decoded value, so the
// target authorized is the target the URL named, and a body cannot
// smuggle a different one past it. Header and cookie binding is skipped
// entirely unless the input declares one, so an op that reads none
// pays nothing.
if op.readsHeaders {
bindHeaders(&in, wire.header)
}
if op.req.cookies {
clearCookies(&in)
bindCookies(&in, wire.cookie)
}
bindURL(&in, wire.query)
bindURL(&in, wire.path)
return run(ctx, &in)
}
handler := func(c fiber.Ctx) error {
// hasBody is THE rule about what a method carries, read here as well as
// by the document and the CLI's remote invoker. Reading the body for a
// method the document says has none is how a DELETE came to accept an
// input no generated client would ever send.
wire := input{dec: jsonenc.Unmarshal, sent: true}
if hasBody(method) {
wire.media = c.Get(fiber.HeaderContentType)
if op.req.whole() {
// An op that takes the body itself takes its content or a
// refusal, never fiber's text for a coding it could not undo.
body, err := content(c)
if err != nil {
return err
}
wire.body = body
} else {
wire.body = c.Body()
}
if op.req.form {
wire.form = func() (formData, error) { return readForm(c, wire.media, wire.body, op.req.names) }
}
}
if names := c.Route().Params; len(names) > 0 {
wire.path = make(map[string]string, len(names))
for _, n := range names {
wire.path[n] = segment(c.Params(n))
}
}
// The query string is the OTHER half of the URL. Without it a typed GET
// could address a collection but never filter it, which is most of a read
// API — so every route that carries `?q=` had to stay an untyped handler,
// invisible to OpenAPI and MCP. Reading it here is what makes those routes
// expressible as ops.
wire.query = c.Queries()
// The header and cookie readers are built only for an op that declares
// one: each closure captures c, so it is per-request, and almost every op
// reads neither.
if op.readsHeaders {
wire.header = func(k string) string { return c.Get(k) }
}
if op.req.cookies {
wire.cookie = func(k string) string { return c.Cookies(k) }
}
// THE MATCHED ROUTE IS THE ADDRESS. materialise mounts each occurrence at
// its absolute path, so the pattern fiber matched is where this op is
// served — including the prefix of whichever host composed it, which the
// registration-time identity cannot name.
//
// The id follows the same rule the registry uses (see occurrenceID): a
// DECLARED id is the op's own name and composition does not get a vote;
// an underived one is computed from the address it is actually served at.
served := Op{Method: meta.Method, Path: c.Route().Path, OperationID: op.OperationID}
if served.OperationID == "" {
served.OperationID = ID(meta.Method, served.Path)
}
// An op that answers a WebSocket refuses a request that cannot become one
// before its handler runs, so a handler with effects (a one-time ticket)
// runs only for a connection it will get.
if op.ans.stream == streamSocket {
if err := upgradable(c, op.Origins); err != nil {
return err
}
}
out, err := op.invoke(withOp(callerContext(c), served), wire)
if err != nil {
return err
}
// A held op renders its own answer: 202 and the [Approval], not the
// handler's 200. Read before the declared-status path because an Approval
// is a framework value and not the op's Out — statusOf would refuse a 202
// the op never declared, which is the right answer about Out and the wrong
// one about this.
if a, ok := out.(*Approval); ok {
return c.Status(202).JSON(a)
}
return writeAnswer(c, op, out)
}
// With() middleware composes around the op only when there IS any: wrapping
// unconditionally would materialise a *Ctx on every typed request to hand to
// a chain of length zero, and the typed path is measured (see LLM.md).
if scope.Middleware != nil {
// core, not handler: the closure must call the op, and `handler` is
// about to be reassigned to the wrapper that contains it.
core := handler
handler = toFiberHandler(app, scope.Middleware(func(c *Ctx) error { return core(c.fc) }))
}
// ONE entry. The route and the op are the same fact recorded once, so the
// router and all five projections read the same value and cannot disagree
// about what exists — which is what makes composition a walk rather than a
// merge of a router and a registry that were written separately.
app.addRoute(here(2+depth), route{method: method, path: path, serve: handler, op: op})
return op
}