mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
A socket op's handshake is decided before its handler runs, so a handler with effects runs only for a connection it will get: 426 for another protocol version, 400 without a key, and 403 for a browser on an origin the op does not admit — its own, plus zip.Origins(...). The origin policy is the op's, not the answer's, so Socket carries only Serve, Subprotocols and Compression, and wsx's own refusals are back to the library's. The in-process CLI refuses a socket op before invoking it. An op that answers JSON or bytes keeps JSON's media for its JSON when Produces names the bytes'. 204, 304 and a redirecting op's 3xx carry no body in the document; any other declared status, 3xx included, carries the Out and the doc comment's example, as before. An MCP read of a Reader is bounded in time as a stream is, and the reader closed. TestConnMemory counts the goroutines holding connections added, as it counts the heap they added, so goroutines other tests leave winding down are not charged to the server.
907 lines
36 KiB
Go
907 lines
36 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)
|
|
}
|
|
|
|
// 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 := ®isteredOp{
|
|
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))
|
|
}
|
|
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.body = c.Body()
|
|
wire.media = c.Get(fiber.HeaderContentType)
|
|
if op.req.form {
|
|
wire.form = func() (formData, error) { return readForm(c, wire.media) }
|
|
}
|
|
}
|
|
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
|
|
}
|