mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
A typed op could only take one JSON value and answer one, so uploads, signed webhooks, form posts, YAML and merge-patch bodies, PDFs, SSE, WebSockets, OAuth redirects and upstream relays stayed untyped routes with no schema, tool, command or SDK method. Each is now a typed op and its REST wire is unchanged. Requests: a zip.Body field takes the body and Content-Type exactly as sent; an In implementing Parser decodes itself; form: fields and *zip.File parts bind url-encoded and multipart forms; cookie: binds a request cookie as an in: cookie parameter. Consumes names the media. Answers: zip.Body (bytes, filename, its own status and headers, a Reader streams chunked), Verbatim[T] (a relay documented as T), Sse[T] (events with keep-alive), Socket[M] (an upgrade after the contract runs; 426 without one, 501 over ZAP), Redirect, CookieCoder, Produces, and unions: a type whose OneOf method's results are the alternatives, with Or[A,B] as the stock one filing each under the status it states. Projections follow from the manifest: the document publishes binary media, form schemas, x-events, x-socket with 101, 3xx with Location and oneOf; MCP takes bytes as base64 and returns bytes, text or collected events as tool content, listing a socket op in _meta as no tool; the CLI reads --body @file|-, --field name=@file and --out, prints events as they arrive and bridges a socket to stdin and stdout. tools/list is ProjectMCP over the manifest, the projection MCPTools already was. Prose reaches promoted fields (filed under the declaring struct), a type defined over another struct, and the type arguments of zip's kinds and OneOf results; an instantiated generic is published under its name followed by its argument names instead of an illegal component key. The unused reflect-only document and tool builders go.
337 lines
15 KiB
Go
337 lines
15 KiB
Go
package zip
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
)
|
|
|
|
// Op identifies a typed handler to an [Authorizer], so a decision can key on the
|
|
// operation as well as on the input.
|
|
//
|
|
// AS DECLARED, which for Path is the path written at the registration plus
|
|
// whatever prefix the registrar reported — not the address the composition
|
|
// finally answers at. A definition declared on a [App.Group] leaves its prefix to
|
|
// the walk (see [App.OpScope]), so an op written as Get(app.Group("/v1"), "/x")
|
|
// reports "/x" here and answers at "/v1/x". OperationID is the resolved id — the
|
|
// explicit [WithOperationID], else the method+path default.
|
|
//
|
|
// So a rule that must key on WHERE an op answers should take the prefix into its
|
|
// registrar (a Router that reports one) or declare the path whole, rather than
|
|
// read an absolute address off this. Method and OperationID carry no such
|
|
// qualification and are the same value everywhere.
|
|
type Op struct {
|
|
Method string
|
|
Path string
|
|
OperationID string
|
|
}
|
|
|
|
// Effect is the outcome of authorizing an op, and there are three of them: run
|
|
// it, refuse it, or hold it until a person answers. That is the whole
|
|
// vocabulary, so every projection renders exactly these three and none can
|
|
// invent a fourth the others would not know how to show.
|
|
type Effect uint8
|
|
|
|
const (
|
|
Allow Effect = iota // run the handler
|
|
Deny // refuse before the handler runs
|
|
Approve // hold the op for a person; the handler does not run
|
|
)
|
|
|
|
// Decision is what an [Authorizer] answers with.
|
|
//
|
|
// Clause names which clause of the rule reached the outcome — a stable token a
|
|
// client dispatches on, written as the refusal's `code` over REST — and Reason
|
|
// is the sentence a person reads. Both are meaningful on a Deny and on an
|
|
// Approve and are ignored on an Allow, which withheld nothing and so has
|
|
// nothing to explain.
|
|
//
|
|
// It is written as a literal, which is the whole notation:
|
|
//
|
|
// return zip.Decision{Effect: zip.Allow}, nil
|
|
// return zip.Decision{Effect: zip.Deny, Clause: "owner", Reason: "not the owner"}, nil
|
|
// return zip.Decision{Effect: zip.Approve, Clause: "spend.cap", Reason: "over the cap"}, nil
|
|
type Decision struct {
|
|
Effect Effect
|
|
Clause string
|
|
Reason string
|
|
}
|
|
|
|
// Held is the ONE word for an op a Decision stopped and nobody has answered
|
|
// yet. One spelling, so a caller — or a generated client reading the pair of
|
|
// responses the document publishes — tells a held op from a finished one
|
|
// without guessing.
|
|
const Held = "held"
|
|
|
|
// Approval is what a held op yields: the handler did NOT run, Status is [Held],
|
|
// Clause names the clause that asked for sign-off, and Reason is what a person
|
|
// reads before answering it.
|
|
//
|
|
// It carries no id, because minting and tracking an approval belongs to the
|
|
// authority that will answer it. zip says once, in one shape, that the op is
|
|
// held and why: REST and the call plane give that shape a 202, MCP and the CLI
|
|
// carry it in the value, the graph reports it beside the field, and a Go caller
|
|
// reads it off the error lane — so no projection can report a held op as a
|
|
// finished one.
|
|
type Approval struct {
|
|
// Status is always held: the op stopped for sign-off and did not run.
|
|
Status string `json:"status"`
|
|
// Clause names the clause of the rule that asked for sign-off.
|
|
Clause string `json:"clause"`
|
|
// Reason is what a person reads before answering.
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
// approvalProse is Approval's field comments, as the document carries them.
|
|
// zip's own types are not lifted by zipdoc, so the sentences are stated here
|
|
// once and TestApprovalProseIsItsComments holds them to the comments above.
|
|
var approvalProse = map[string]string{
|
|
"Approval.status": "Status is always held: the op stopped for sign-off and did not run.",
|
|
"Approval.clause": "Clause names the clause of the rule that asked for sign-off.",
|
|
"Approval.reason": "Reason is what a person reads before answering.",
|
|
}
|
|
|
|
// Error lets an Approval travel the lane every Go caller already reads. A held
|
|
// op has no Out and never will have one — the handler did not run — so a
|
|
// projection that must answer (*Out, error) has exactly one honest shape for
|
|
// it. It is not a failure: nothing broke, and the next step is a person.
|
|
func (a *Approval) Error() string {
|
|
if a.Reason == "" {
|
|
return "held for approval: " + a.Clause
|
|
}
|
|
return "held for approval: " + a.Clause + ": " + a.Reason
|
|
}
|
|
|
|
// HeldOf reads the Approval back out of an error — the ONE way a caller asks
|
|
// whether an op was held, on any projection, with no status to match and no
|
|
// string to parse.
|
|
func HeldOf(err error) (*Approval, bool) {
|
|
var a *Approval
|
|
return a, errors.As(err, &a)
|
|
}
|
|
|
|
// approval is the record a held op answers with.
|
|
func (d Decision) approval() *Approval {
|
|
return &Approval{Status: Held, Clause: d.Clause, Reason: d.Reason}
|
|
}
|
|
|
|
// refusal is the 403 a denied op answers with, carrying the clause as its code
|
|
// and the reason as its message. Reusing the error lane is what fans a refusal
|
|
// out to REST, MCP, the call plane, the graph and the CLI with no
|
|
// per-projection branch: refusal already had exactly one shape.
|
|
func (d Decision) refusal() error {
|
|
return &HTTPError{Status: 403, Code: d.Clause, Msg: d.Reason}
|
|
}
|
|
|
|
// approvalType is the type the OpenAPI projection publishes for the 202, so a
|
|
// gated service documents the held body beside every op's success response.
|
|
var approvalType = reflect.TypeOf(Approval{})
|
|
|
|
// Authorizer authorizes a decoded, validated typed request at the op-invoke
|
|
// seam — the ONE point every projection of a typed handler funnels through. It
|
|
// runs after the request is decoded into the op's typed In and validated, and
|
|
// BEFORE the handler runs, over REST and MCP alike, so the value it authorizes
|
|
// is exactly the value the handler will act on: there is no second parse of the
|
|
// body for it to diverge from. in is the *In the handler will receive.
|
|
//
|
|
// It answers a [Decision], and every projection renders the three effects the
|
|
// same way: [Allow] runs the handler, [Deny] refuses with a 403 carrying the
|
|
// clause and the reason, [Approve] holds the op and renders it held without
|
|
// running it.
|
|
//
|
|
// The error return is for a genuine failure of the CHECK ITSELF — the authority
|
|
// is unreachable, say. It is not how a refusal is spelled; a refusal is a Deny.
|
|
// A non-nil error aborts the op and is the response, so return a zip.Err* for a
|
|
// clean status.
|
|
type Authorizer func(ctx context.Context, op Op, in any) (Decision, error)
|
|
|
|
// Authorize installs fn as the op-invoke authorization rule. It is the op-level
|
|
// counterpart to Use: Use wraps the whole request with transport middleware,
|
|
// which for a body request sees only the raw bytes; Authorize runs one decision
|
|
// on the DECODED typed input of every op, REST and MCP alike — the seam a
|
|
// mounted subsystem asks so the value it authorizes is the value the handler
|
|
// binds. Call once while mounting, before Listen — the field is read on serve
|
|
// goroutines and writing it under load is a race. A nil fn declares no rule.
|
|
//
|
|
// IT COVERS EVERY OP THIS APP SERVES: the ones it registered itself, the ones
|
|
// declared on its groups, and the ones a definition it composed with [App.Use]
|
|
// brought with it. All three answer at this app's addresses, at its /mcp, at its
|
|
// call plane, in its graph and to its CLI, so all three are its ops. Composition
|
|
// is what decides that, so [adopt] settles it at build rather than each
|
|
// registration guessing.
|
|
//
|
|
// A definition may declare a TIGHTER rule of its own and it wins, because
|
|
// [App.rule] asks its own first. It cannot declare a looser one: declaring none
|
|
// falls through to whatever composed it, so composing a definition can only add
|
|
// a rule, never drop one.
|
|
//
|
|
// A definition composed under two rules that DISAGREE is refused at [App.Build]:
|
|
// its ops are one closure and one closure answers to one rule, so there is no
|
|
// honest way to serve it under both. See [adopt].
|
|
func (a *App) Authorize(fn Authorizer) { a.authorizer = fn }
|
|
|
|
// OnResult installs fn as the rule for what to do with the OUTCOME of every
|
|
// typed op this app serves. It is [App.Authorize]'s counterpart: Authorize is
|
|
// ASKED before the handler and may refuse, OnResult is TOLD after it and may
|
|
// not. Both run at the one invoke seam, so both cover every projection — the
|
|
// REST route, tools/call at /mcp, the by-name call plane, the graph, and the
|
|
// in-process CLI, which is the only one no transport middleware can wrap.
|
|
//
|
|
// That last one is why this exists. Anything a middleware records — a trail, a
|
|
// metric, a span — is recorded about a REQUEST, and an in-process invoke is not
|
|
// one. A recorder installed here is told about it under the same operation name
|
|
// as every other door.
|
|
//
|
|
// fn is told the operation and the error, and nothing else. The output is
|
|
// deliberately absent: a hook that could read it would be a way for a response
|
|
// body to reach whatever the hook writes to. Call once while mounting, before
|
|
// Listen — the field is read on serve goroutines. A nil fn declares no hook.
|
|
func (a *App) OnResult(fn func(ctx context.Context, op Op, err error)) { a.onResult = fn }
|
|
|
|
// result is the [App.OnResult] hook in force over this App's ops: its own if it
|
|
// declared one, else the one it was composed under — the same resolution
|
|
// [App.rule] uses, for the same reason.
|
|
func (a *App) result() func(context.Context, Op, error) {
|
|
if a.onResult != nil {
|
|
return a.onResult
|
|
}
|
|
if over := a.over.Load(); over != nil {
|
|
return over.onResult
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// rule is the Authorizer in force over this App's ops: its own if it declared
|
|
// one, else the one it was composed under.
|
|
//
|
|
// Asked at invoke rather than settled at registration, so [App.Authorize],
|
|
// [App.Group] and [App.Use] are independent of each other and of order: a rule
|
|
// declared after the paths were grouped still covers them, a rule declared
|
|
// before still covers a group made later, and a definition written with no idea
|
|
// who would compose it still answers to whoever did.
|
|
func (a *App) rule() Authorizer {
|
|
if a.authorizer != nil {
|
|
return a.authorizer
|
|
}
|
|
if over := a.over.Load(); over != nil {
|
|
return over.authorizer
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// adopt settles which rule governs each definition in a composition, and is the
|
|
// only place that can: a definition is written without knowing what will include
|
|
// it, and what includes it is what decides whose ops its ops become.
|
|
//
|
|
// The rule over a definition is the nearest one declared ABOVE it on the path
|
|
// the walk took to reach it. A group has no rule of its own, so the walk carries
|
|
// straight through it to the app it groups; a composed definition that declares
|
|
// none likewise answers to its includer. One traversal answers both, because a
|
|
// group and a composed definition are one mechanism here.
|
|
//
|
|
// TWO INCLUDERS THAT DISAGREE ARE REFUSED, with both paths named. A definition's
|
|
// ops are one closure — composing it does not copy them — so serving it under
|
|
// two rules would mean one closure answering to two, which no implementation can
|
|
// do. The alternative is to keep whichever composition ran last, and half the
|
|
// time that is the looser of the two: a surface that reads as governed and is
|
|
// not. A build that stops says so; a surface that quietly serves does not.
|
|
//
|
|
// A definition composed BOTH under a rule and under no rule takes the rule. That
|
|
// is not a disagreement — nothing was said in the second place — and taking it
|
|
// is the answer that cannot be too permissive.
|
|
func adopt(occ []occurrence) error {
|
|
var errs []error
|
|
for _, o := range occ {
|
|
t := o.ctx.trail
|
|
if t == nil {
|
|
continue
|
|
}
|
|
over := ruling(t)
|
|
if over == nil || over == t.def {
|
|
// Nothing above declared a rule, or this definition declared its own.
|
|
continue
|
|
}
|
|
if prev := t.def.over.Load(); prev != nil && prev != over {
|
|
errs = append(errs, fmt.Errorf(
|
|
"zip: %s is composed under two rules that disagree — %s declares one and so does %s. "+
|
|
"Its ops are one closure and answer to one rule, so name the rule on %s itself "+
|
|
"or compose it under only one of them",
|
|
t.label, over.label(), prev.label(), t.label))
|
|
continue
|
|
}
|
|
t.def.over.Store(over)
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// ruling is the nearest rule declared at or above t on the path the walk took.
|
|
// nil when nothing on that path declared one.
|
|
func ruling(t *trail) *App {
|
|
for ; t != nil; t = t.up {
|
|
if t.def.authorizer != nil {
|
|
return t.def
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Build constructs and installs a generation without starting a listener: the
|
|
// walk runs, every validation runs, the projections are rendered, and the
|
|
// router goes live — exactly what [App.Listen] does minus the sockets.
|
|
//
|
|
// It RETURNS THE VERDICT, which is the whole reason it replaced Prepare. A
|
|
// composition can be invalid — two definitions claiming one address, a cycle —
|
|
// and the old Prepare returned nothing, so the only way to learn was to start
|
|
// a server. Now a codegen step, a test or a wiring file's own main can ask
|
|
// whether the program it just wrote is a program, and get every conflict at
|
|
// once with both claimants named.
|
|
//
|
|
// Idempotent for the projections (they render once) and monotonic for the
|
|
// freeze. Listen calls it; there is no other way to build.
|
|
func (a *App) Build() error {
|
|
// VALIDATE FIRST. prepare() renders the projections, and a projection of a
|
|
// program that does not compose now panics — so validating after it would
|
|
// turn every build error into a panic escaping the one function whose whole
|
|
// job is to return it.
|
|
if err := verify(a); err != nil {
|
|
return err
|
|
}
|
|
a.prepare()
|
|
a.buildMu.Lock()
|
|
g, err := a.build()
|
|
if err == nil {
|
|
a.install(g)
|
|
}
|
|
a.buildMu.Unlock()
|
|
return err
|
|
}
|
|
|
|
// Verify answers whether this definition composes, and changes nothing about
|
|
// whether it is serving.
|
|
//
|
|
// It is [App.Build]'s first half and the same call: the walk, the structural
|
|
// rules, the derived rules, and rule adoption. What it does NOT do is the second
|
|
// half — it renders no projection, builds no generation and installs none, so it
|
|
// does not freeze the definition. A definition that is about to be composed by
|
|
// reference can therefore be checked first and still be composed.
|
|
//
|
|
// That is the case it exists for. A projection of a program that does not
|
|
// compose panics rather than lying, so asking [App.Declaration] or
|
|
// [App.Registry] about a definition whose middleware guards no routes takes the
|
|
// host down with it — while [App.Build] would answer, and freeze the definition
|
|
// in the same breath. Verify is the answer on its own: a host mounting a
|
|
// subsystem can report which group is wrong and carry on.
|
|
//
|
|
// It does settle which rule governs each definition, exactly as Build's first
|
|
// half does, because that is what the walk is for. Composing more and calling
|
|
// either again settles it again.
|
|
//
|
|
// It answers for this definition as the root of its own walk — as if it were
|
|
// the app being served. One rule reads differently from there: middleware at a
|
|
// served root wraps the routes composition brings it later, so it is exempt,
|
|
// while the same middleware one level down guards a subtree that is already
|
|
// settled. Only the host knows which of the two a definition turned out to be,
|
|
// so a clean Verify here and a refusal from the host's Build are both right.
|
|
func (a *App) Verify() error { return verify(a) }
|