mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
compose: one verb, because the eager registry was the disease
An App becomes a PROGRAM: an ordered list of entries, each carrying one of exactly three payloads — middleware, a route, or another App INCLUDED BY REFERENCE. One walk flattens it into occurrences in preorder; every projection is a reducer over that slice. Graft, Mount and Add delegate to the same walk. Five verbs existed because the op registry was a FIELD that composition appended to at call time, so composing two apps meant merging two registries and AdaptNetHTTP could destroy five projections by type-erasing an *App. A projection over a walk needs no merge rule. Prototype only: not for main, not tagged, not released. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+1
-1
@@ -119,7 +119,7 @@ func Benchmark_ChainTax(b *testing.B) {
|
||||
za := zip.New(benchConfig())
|
||||
fa := fiber.New()
|
||||
for i := 0; i < mw; i++ {
|
||||
za.Use(func(c *zip.Ctx) error { return c.Continue() })
|
||||
za.Use(zip.H(func(c *zip.Ctx) error { return c.Continue() }))
|
||||
fa.Use(func(c fiber.Ctx) error { return c.Next() })
|
||||
}
|
||||
za.Get("/v1/health", func(c *zip.Ctx) error { return c.NoContent(204) })
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
)
|
||||
|
||||
// Building is a projection, not a merge.
|
||||
//
|
||||
// Every artifact this package emits is a REDUCER over [walk]'s occurrence
|
||||
// slice: the router, the op registry (and through it the OpenAPI document, the
|
||||
// MCP tool list, the CLI commands and the by-name call plane), the conflict
|
||||
// report and the staged-composition lint. Adding a projection means writing a
|
||||
// reducer, never touching composition.
|
||||
|
||||
// methodAll is the sentinel [App.All] registers under: one entry answering
|
||||
// every method, kept as one entry so the program says what was written. The
|
||||
// router expands it; [App.Declaration] reads the expansion back off the router.
|
||||
const methodAll = "ALL"
|
||||
|
||||
// plan is the occurrence slice for this app, memoised once the app is sealed.
|
||||
//
|
||||
// Sealed: computed exactly once under sync.Once, so [App.Registry] is race-free
|
||||
// from serving goroutines — the OpenAPI endpoint and the MCP tool listing both
|
||||
// call it per request — with no mutex on the served path.
|
||||
//
|
||||
// Unsealed: recomputed every call, and deliberately does NOT seal. Sealing on
|
||||
// read would mean a codegen step, a test, or a `zipdoc` run turns the next
|
||||
// perfectly legitimate Use into a panic about a seal nobody asked for.
|
||||
func (a *App) plan() ([]occurrence, error) {
|
||||
if a.sealed.Load() {
|
||||
a.planOnce.Do(func() { a.planned, a.planErr = walk(a) })
|
||||
return a.planned, a.planErr
|
||||
}
|
||||
return walk(a)
|
||||
}
|
||||
|
||||
// seal ends composition and is the ONE primitive that does so.
|
||||
//
|
||||
// Any entry point that begins RUNTIME EXECUTION calls this — [App.Listen] does
|
||||
// today, and a future Serve, Run or Handler entry point calls this rather than
|
||||
// a copy of it, which is the whole reason it is factored out of Listen. It is
|
||||
// deliberately not exported and deliberately not called by any inspection path.
|
||||
//
|
||||
// Sealing PROPAGATES across the entire reachable graph, not just the app it was
|
||||
// called on. Sealing only the root would leave
|
||||
//
|
||||
// root.Use(users); go root.Listen(); users.Use(admin)
|
||||
//
|
||||
// racing exactly as before: the child is reachable, so the child is frozen.
|
||||
// Sealed is MONOTONIC, so a definition already sealed under one parent and then
|
||||
// included under another is fine — mount-after-seal is allowed, mutate-after-seal
|
||||
// is not.
|
||||
func (a *App) seal(site callsite) error {
|
||||
occ, err := walk(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, o := range occ {
|
||||
if child, ok := o.app(); ok {
|
||||
child.markSealed(site)
|
||||
}
|
||||
}
|
||||
a.markSealed(site)
|
||||
// The walk that validated IS the plan; sealing must not pay for a second one.
|
||||
a.planOnce.Do(func() { a.planned, a.planErr = occ, nil })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) markSealed(site callsite) {
|
||||
if a.sealed.CompareAndSwap(false, true) {
|
||||
a.sealSite = site
|
||||
}
|
||||
}
|
||||
|
||||
// Sealed reports whether composition has ended for this app.
|
||||
func (a *App) Sealed() bool { return a.sealed.Load() }
|
||||
|
||||
// Registry is the op registry as a PROJECTION: every typed op in the
|
||||
// composition, at the path and under the id its occurrence gives it.
|
||||
//
|
||||
// This is the value that replaced five verbs with one. It used to be a field
|
||||
// that [App.Graft] appended to at compose time, which is why composing an app
|
||||
// needed a verb of its own and why type-erasing one into an http.Handler
|
||||
// destroyed the OpenAPI document, the MCP tool list, the CLI commands, the
|
||||
// by-name call plane and the [Declaration] all at once. A projection cannot be
|
||||
// destroyed by composition, because composition no longer writes it.
|
||||
//
|
||||
// Keys on the OCCURRENCE for surface (path, operationId, tags) and on the
|
||||
// DEFINITION for types: the *registeredOp's InType/OutType are the definition's
|
||||
// own reflect.Types, so one Invoice struct included twice is one schema, not
|
||||
// two identical copies under two names.
|
||||
func (a *App) Registry() []*registeredOp {
|
||||
if a.sealed.Load() {
|
||||
a.regOnce.Do(func() { a.reg = a.composeOps() })
|
||||
return a.reg
|
||||
}
|
||||
return a.composeOps()
|
||||
}
|
||||
|
||||
func (a *App) composeOps() []*registeredOp {
|
||||
occ, _ := a.plan() // a conflict does not stop enumeration; seal reports it
|
||||
out := make([]*registeredOp, 0, len(occ))
|
||||
for _, o := range occ {
|
||||
r, ok := o.route()
|
||||
if !ok || r.op == nil {
|
||||
continue
|
||||
}
|
||||
if o.ctx.depth == 0 && o.ctx.prefix == "" {
|
||||
// The served app's own op, at the root: untouched, so an app that
|
||||
// composes nothing publishes byte-for-byte what it declared.
|
||||
out = append(out, r.op)
|
||||
continue
|
||||
}
|
||||
c := *r.op // a copy: composing never edits what a definition says about itself
|
||||
c.Path = o.abs(r.path)
|
||||
c.OperationID = occurrenceID(o.ctx.prefix, opName(r.op))
|
||||
if c.Origin == "" {
|
||||
// Who DECLARED the type — a property of the code, never of where it
|
||||
// was deployed. An op that already names its declarer keeps that
|
||||
// name, so a definition two levels deep still credits its author.
|
||||
c.Origin = o.in.cfg.AppName
|
||||
}
|
||||
if len(c.Tags) == 0 && c.Origin != "" {
|
||||
c.Tags = []string{c.Origin}
|
||||
}
|
||||
out = append(out, &c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// router returns the materialised fiber router, building it if the program has
|
||||
// moved since it was last built.
|
||||
//
|
||||
// Materialisation is a reducer like any other. It is rebuilt rather than
|
||||
// patched, because a program is an ordered whole: an entry appended in the
|
||||
// middle of a subtree has to land in the middle, and a router that could only
|
||||
// be appended to would put it at the end.
|
||||
//
|
||||
// Two things this has to get right, both found by -race rather than by reading:
|
||||
//
|
||||
// - Building on a READ is a write, and reads are concurrent. Before, the
|
||||
// router was constructed in New and never written again, so any number of
|
||||
// goroutines could call Fiber(); now a stale build repairs itself and that
|
||||
// repair needs the lock. It is not on the served path — Listen captures
|
||||
// Handler() once — so the lock costs nothing that matters.
|
||||
// - The version counter is PROCESS-wide, because an append to a child changes
|
||||
// a parent's meaning and a child does not know its parents. So a sealed
|
||||
// app must stop consulting it, or an unrelated App composing anything at
|
||||
// all would rebuild this one's router for no reason.
|
||||
func (a *App) router() *fiber.App {
|
||||
a.buildMu.Lock()
|
||||
defer a.buildMu.Unlock()
|
||||
if a.fiber != nil && (a.sealed.Load() || a.builtAt == version.Load()) {
|
||||
return a.fiber
|
||||
}
|
||||
a.materialise(version.Load())
|
||||
return a.fiber
|
||||
}
|
||||
|
||||
// materialise replays the occurrence slice onto a fresh router, in order.
|
||||
//
|
||||
// The replay IS the semantics, because this fiber fork resolves same-run routes
|
||||
// by SPECIFICITY and treats every Use as a barrier between runs. Flattened
|
||||
// preorder inlines an included definition's entries at its inclusion site, so
|
||||
// replaying that order reproduces snapshot semantics exactly and for free:
|
||||
// entries written after an inclusion site land after that subtree and cannot
|
||||
// reach back into it.
|
||||
//
|
||||
// Middleware is placed by DEPTH, and this is the one place the two composition
|
||||
// models actually differ on the wire:
|
||||
//
|
||||
// - depth 0 — the app being served. Registered as router middleware, so it
|
||||
// runs for every request including ones that match no route (404 logging,
|
||||
// CORS preflight, recovery). Identical to what a single-app program has
|
||||
// always done.
|
||||
// - depth > 0 — declared inside an included definition. Composed into the
|
||||
// CHAIN of that subtree's own routes instead. Router middleware here would
|
||||
// escape the definition: a child's pathless app.Use(guard) registered on the
|
||||
// host's router is a barrier for the whole host binary, which is precisely
|
||||
// the failure [App.Graft]'s delegation existed to avoid. Composing it into
|
||||
// the subtree's routes keeps it inside the definition that declared it, and
|
||||
// costs the definition's middleware its coverage of unmatched paths — a
|
||||
// definition does not answer for addresses it does not declare.
|
||||
func (a *App) materialise(v uint64) {
|
||||
occ, _ := a.plan()
|
||||
f := fiber.New(a.fiberConfig())
|
||||
for _, o := range occ {
|
||||
switch n := o.n.(type) {
|
||||
case Handler:
|
||||
if o.ctx.depth == 0 {
|
||||
f.Use(toFiberHandler(a, n))
|
||||
}
|
||||
case route:
|
||||
a.install(f, o.ctx.prefix, o.ctx.mw.included(), n)
|
||||
}
|
||||
}
|
||||
// zip's OWN routes go on last, and are not entries at all — see
|
||||
// [App.installControl].
|
||||
for _, c := range a.control_ {
|
||||
a.install(f, "", nil, c)
|
||||
}
|
||||
a.fiber = f
|
||||
a.builtAt = v
|
||||
}
|
||||
|
||||
// installControl registers one of zip's own projection routes — the document,
|
||||
// the docs page, the MCP door, the op plane, the declaration.
|
||||
//
|
||||
// These are NOT entries, and that is the whole point. A control route is a
|
||||
// PROJECTION of the program (it serves what the program computes), so putting
|
||||
// it in the program made two things wrong at once:
|
||||
//
|
||||
// - it made rendering a projection a MUTATION, so sealing the program and
|
||||
// then asking it for its document panicked — [App.prepare] installs these,
|
||||
// and a projection must be readable after the seal;
|
||||
// - it made them inheritable. Inclusion reads a definition's whole program,
|
||||
// so a definition that had ever rendered its own document would hand the
|
||||
// host ITS document at the host's well-known path, and the composition
|
||||
// would publish the part as if it were the whole. That needed a special
|
||||
// case in the walk; belonging to the build instead, it needs none.
|
||||
//
|
||||
// They are replayed after the entries on every materialisation, which is the
|
||||
// same position they have always occupied — prepare() has always run last.
|
||||
func (a *App) installControl(r route) {
|
||||
a.buildMu.Lock()
|
||||
defer a.buildMu.Unlock()
|
||||
a.control_ = append(a.control_, r)
|
||||
if a.fiber != nil {
|
||||
a.install(a.fiber, "", nil, r)
|
||||
}
|
||||
}
|
||||
|
||||
// install puts one route on one router: its absolute path, its subtree's
|
||||
// middleware composed in front of it, then the handler the definition wrote.
|
||||
//
|
||||
// fiber runs a route's handlers in ARGUMENT order and advances through them on
|
||||
// Next(), which is the same rule zip's own registration chain already uses — so
|
||||
// composed middleware is passed as leading arguments and needs no wrapper type.
|
||||
func (a *App) install(f *fiber.App, prefix string, mw []Handler, r route) {
|
||||
p := joinPath(prefix, r.path)
|
||||
args := make([]any, 0, len(mw)+len(r.chain)+1)
|
||||
for _, h := range mw {
|
||||
args = append(args, toFiberHandler(a, h))
|
||||
}
|
||||
// a, not the definition: every handler in one request must be handed the
|
||||
// same *Ctx, and requestCtx keys that on the app it was bound to.
|
||||
for _, h := range r.chain {
|
||||
args = append(args, toFiberHandler(a, h))
|
||||
}
|
||||
if r.serve != nil {
|
||||
args = append(args, r.serve)
|
||||
}
|
||||
if r.method == methodAll {
|
||||
f.All(p, args[0], args[1:]...)
|
||||
return
|
||||
}
|
||||
f.Add([]string{r.method}, p, args[0], args[1:]...)
|
||||
}
|
||||
|
||||
// addRoute is the ONE place a route entry is appended, so every route method,
|
||||
// every typed registration and zip's own control plane record the same thing in
|
||||
// the same shape with a real call site.
|
||||
func (a *App) addRoute(site callsite, r route) {
|
||||
a.appendEntry(entry{n: r, site: site})
|
||||
}
|
||||
|
||||
// Lint reports staged composition: middleware appended to a receiver AFTER an
|
||||
// App was included in it.
|
||||
//
|
||||
// It is not an error and not a warning tier. The pattern is INTENTIONAL when
|
||||
// the two lines sit together —
|
||||
//
|
||||
// app.Use(publicAPI)
|
||||
// app.Use(auth)
|
||||
// app.Use(privateAPI)
|
||||
//
|
||||
// — and a latent bug when they are written far apart, in different functions or
|
||||
// different files, where the author of the later line cannot see which subtrees
|
||||
// they just failed to cover. No walk can tell those two apart, because the
|
||||
// difference is authorial intent and the only evidence of it is co-location.
|
||||
// So this reports the fact and the two call sites and lets a human read them;
|
||||
// promoting it to an error would break the legitimate case, and hiding it would
|
||||
// leave the dangerous one silent.
|
||||
//
|
||||
// Reported per receiver, naming the included app, the middleware, and both
|
||||
// sites.
|
||||
func (a *App) Lint() []string {
|
||||
occ, _ := a.plan()
|
||||
seen := map[*App]bool{}
|
||||
var out []string
|
||||
for _, o := range occ {
|
||||
def := o.in
|
||||
if seen[def] {
|
||||
continue
|
||||
}
|
||||
seen[def] = true
|
||||
var included []entry
|
||||
for _, e := range def.entries {
|
||||
switch e.n.(type) {
|
||||
case *App:
|
||||
included = append(included, e)
|
||||
case Handler:
|
||||
for _, inc := range included {
|
||||
child, _ := inc.n.(*App)
|
||||
out = append(out, fmt.Sprintf(
|
||||
"zip: staged composition in %s: middleware at %s was appended after %s was included at %s — "+
|
||||
"it does not apply to that subtree; co-located this is intentional, far apart it is a missing seam",
|
||||
def.who(), e.site, child.who(), inc.site))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// fiberConfig is the App's Config as fiber wants it. One place, so New and
|
||||
// every rematerialisation agree.
|
||||
func (a *App) fiberConfig() fiber.Config {
|
||||
fcfg := fiber.Config{
|
||||
AppName: a.cfg.AppName,
|
||||
BodyLimit: a.cfg.BodyLimit,
|
||||
JSONEncoder: jsonMarshal,
|
||||
JSONDecoder: jsonUnmarshal,
|
||||
Concurrency: a.cfg.Concurrency,
|
||||
ReadBufferSize: a.cfg.ReadBufferSize,
|
||||
WriteBufferSize: a.cfg.WriteBufferSize,
|
||||
}
|
||||
if a.cfg.ServerHeader != "-" {
|
||||
fcfg.ServerHeader = a.cfg.ServerHeader
|
||||
}
|
||||
if a.cfg.ErrorHandler != nil {
|
||||
fcfg.ErrorHandler = a.cfg.ErrorHandler
|
||||
} else {
|
||||
fcfg.ErrorHandler = errorHandler
|
||||
}
|
||||
return fcfg
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func socketIn(dir, name string) string { return filepath.Join(dir, name+".sock")
|
||||
// installCallPlane mounts the by-name op plane when there are ops to call.
|
||||
// Called from prepare() alongside installOpenAPIRoutes and installMCP.
|
||||
func (a *App) installCallPlane() {
|
||||
if len(a.registry) == 0 {
|
||||
if len(a.Registry()) == 0 {
|
||||
return
|
||||
}
|
||||
a.control(fiber.MethodPost, CallPath+":op", func(fc fiber.Ctx) error {
|
||||
@@ -172,7 +172,7 @@ func (a *App) installCallPlane() {
|
||||
fc.Set(fiber.HeaderContentType, CallContentType)
|
||||
return fc.Send(body)
|
||||
})
|
||||
a.logger.Info("zip call plane", "path", CallPath, "ops", len(a.registry))
|
||||
a.logger.Info("zip call plane", "path", CallPath, "ops", len(a.Registry()))
|
||||
}
|
||||
|
||||
// Conn is a handle to another zip app, dialed once and used concurrently. It
|
||||
|
||||
@@ -109,8 +109,9 @@ type Flag struct {
|
||||
// Commands projects every registered typed op into a command. This is the whole
|
||||
// derivation: no registration, no list of commands, no per-endpoint code.
|
||||
func (a *App) Commands() []Command {
|
||||
cmds := make([]Command, 0, len(a.registry))
|
||||
for _, op := range a.registry {
|
||||
ops := a.Registry()
|
||||
cmds := make([]Command, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
doc, has := docFor(op.Method, op.Path)
|
||||
c := newCommand(op.Method, op.Path, opName(op), op.Summary, doc, has)
|
||||
c.op = op
|
||||
|
||||
+346
@@ -0,0 +1,346 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
)
|
||||
|
||||
// Composition is ONE verb.
|
||||
//
|
||||
// An [App] is not a server. It is a PROGRAM: an ordered list of entries, each
|
||||
// carrying one of exactly three payloads —
|
||||
//
|
||||
// Handler middleware declared at this level
|
||||
// route one address this app answers
|
||||
// *App another app INCLUDED BY REFERENCE
|
||||
//
|
||||
// — and nothing else. [App.Use] appends the first and the third; the route
|
||||
// methods append the second. There is no second composition verb, because
|
||||
// there is no second thing composition can mean.
|
||||
//
|
||||
// # Inclusion by reference, not delegation
|
||||
//
|
||||
// `parent.Use(child)` does not mount, proxy, embed or delegate. It appends a
|
||||
// REFERENCE to a definition. At build time one interpreter walks one program;
|
||||
// the child's entries are read in place, exactly as if they had been written
|
||||
// there. There is no boundary at run time to cross, so there is nothing for a
|
||||
// request to hop through: those words are reserved for [App.Mount], the one
|
||||
// operation where another runtime genuinely exists.
|
||||
//
|
||||
// The same definition may be referenced from any number of places. Each
|
||||
// reference is an ENTRY, and the entry — not the definition — carries the call
|
||||
// site, so one definition included from four places has four call sites and
|
||||
// four occurrences, each with its own path context.
|
||||
//
|
||||
// # Why this replaces four verbs
|
||||
//
|
||||
// Listen served here, Mount delegated there, Add composed a registrar and
|
||||
// Graft composed an App in process. Graft existed for exactly one reason: the
|
||||
// op registry was EAGER, so composing two apps meant merging two registries at
|
||||
// call time, and the only other way in — [AdaptNetHTTP] — type-erased an *App
|
||||
// into a closure and destroyed five projections with it. Once the registry is
|
||||
// a lazy PROJECTION over a walk of the tree, composing is appending a
|
||||
// reference, and the projections fall out of the walk.
|
||||
//
|
||||
// # The lint, not the rule
|
||||
//
|
||||
// Middleware appended AFTER an *App entry on the same receiver is legal and
|
||||
// means what it says — the stack a node sees is the stack that preceded it
|
||||
// (see [App.Use] on snapshot semantics). It is intentional when co-located
|
||||
// (staged composition) and a latent bug when the two are written far apart, and
|
||||
// no walk can tell those apart, so it is a lint with a breadcrumb rather than a
|
||||
// rule at any tier. See [App.Lint].
|
||||
|
||||
// callsite is where in the program one entry was written.
|
||||
//
|
||||
// Every entry has one, recorded by construction at [App.Use], [App.Group] and
|
||||
// every route method. It is not decoration: a conflict names both parties'
|
||||
// sites, a cycle names the sites on the ring, a post-seal mutation names the
|
||||
// site that sealed and the site that then wrote, and the staged-composition
|
||||
// lint classifies co-located against cross-scope by comparing them. None of
|
||||
// that can be retrofitted onto entries that did not record it, which is why it
|
||||
// is on the envelope rather than on any one payload.
|
||||
type callsite struct {
|
||||
file string
|
||||
line int
|
||||
}
|
||||
|
||||
// here records the caller's call site. skip is measured from here's caller, so
|
||||
// a public verb passes 1 to name ITS caller — the line the programmer wrote.
|
||||
func here(skip int) callsite {
|
||||
_, file, line, ok := runtime.Caller(skip + 1)
|
||||
if !ok {
|
||||
return callsite{}
|
||||
}
|
||||
return callsite{file: file, line: line}
|
||||
}
|
||||
|
||||
// String renders "pkg/file.go:42" — the last path segment plus the file, which
|
||||
// is enough to find the line and short enough to sit inside a message that
|
||||
// already names two of them.
|
||||
func (c callsite) String() string {
|
||||
if c.line == 0 {
|
||||
return "?"
|
||||
}
|
||||
dir, file := filepath.Split(c.file)
|
||||
return filepath.Base(strings.TrimSuffix(dir, string(filepath.Separator))) + "/" + file + ":" + strconv.Itoa(c.line)
|
||||
}
|
||||
|
||||
func (c callsite) known() bool { return c.line != 0 }
|
||||
|
||||
// node is the CLOSED set of payloads an entry can carry. Three types, one
|
||||
// unexported marker, no third-party implementations — which is what lets the
|
||||
// walk be one type switch over a set that cannot grow behind its back.
|
||||
//
|
||||
// It is deliberately NOT [Component]: a route is a node and is not a Component,
|
||||
// because routes arrive through the route methods and never through Use.
|
||||
type node interface{ node() }
|
||||
|
||||
func (Handler) node() {}
|
||||
func (route) node() {}
|
||||
func (*App) node() {}
|
||||
|
||||
// route is one address an app answers, plus everything every projection needs
|
||||
// about it. op is non-nil exactly when the route was declared typed, and IS the
|
||||
// registry entry — one registration, five projections, no second bookkeeping.
|
||||
//
|
||||
// path is the path as WRITTEN, never composed with a prefix: a definition
|
||||
// referenced under two prefixes answers at two absolute paths, so the absolute
|
||||
// path is a property of the occurrence and is computed by the walk.
|
||||
type route struct {
|
||||
method string
|
||||
path string
|
||||
// chain is the registration chain as WRITTEN — zip Handlers, unbound.
|
||||
// Binding a Handler to an App is a BUILD step, not a registration step,
|
||||
// because the same definition can be built into more than one server and a
|
||||
// handler bound to the definition would hand every request a *Ctx belonging
|
||||
// to an app that is not serving it.
|
||||
chain []Handler
|
||||
// serve is the alternative for handlers that are already app-independent:
|
||||
// a typed op's core (which reads the request and never materialises a *Ctx)
|
||||
// and zip's own control routes. Exactly one of chain/serve is set.
|
||||
serve fiber.Handler
|
||||
op *registeredOp
|
||||
}
|
||||
|
||||
// entry is one element of an App's program: a payload, and where it was
|
||||
// written. The envelope carries the call site so that per-reference metadata
|
||||
// lives on the reference and never on the definition — which is the whole
|
||||
// reason one *App included from N places yields N distinguishable occurrences
|
||||
// without a named reference type.
|
||||
type entry struct {
|
||||
n node
|
||||
site callsite
|
||||
}
|
||||
|
||||
// version counts every append across the process. An App materialises its
|
||||
// router at a version and rebuilds when the program has moved on, so
|
||||
// inspecting a half-built app never observes a stale router. It is monotonic
|
||||
// and process-wide because an append to a CHILD changes the parent's meaning,
|
||||
// and a child does not know its parents.
|
||||
var version atomic.Uint64
|
||||
|
||||
// Component is what [App.Use] accepts: middleware, or another App.
|
||||
//
|
||||
// The set is closed — a Component is a [Handler] or an [*App], and nothing
|
||||
// else can implement it from outside this package. Two of the three node kinds
|
||||
// arrive this way; a route arrives through a route method, which is why node
|
||||
// and Component are different types rather than one type doing two jobs.
|
||||
type Component interface{ component() }
|
||||
|
||||
func (Handler) component() {}
|
||||
func (*App) component() {}
|
||||
|
||||
// H adapts a bare closure to a [Component].
|
||||
//
|
||||
// It exists because Go will not implicitly convert `func(c *zip.Ctx) error` to
|
||||
// an interface that only the named type [Handler] implements. Anything already
|
||||
// typed as a Handler — every constructor in the middleware package, every
|
||||
// variable declared as zip.Handler — needs no wrapper, and neither does an
|
||||
// inline closure passed to a ROUTE method, because Get/Post/Put/Delete/All
|
||||
// still take ...Handler. The one construct that needs H is a bare closure
|
||||
// written inline at a Use call:
|
||||
//
|
||||
// app.Use(middleware.Recover()) // already a Handler
|
||||
// app.Get("/x", func(c *zip.Ctx) error {…}) // route method, still ...Handler
|
||||
// app.Use(zip.H(func(c *zip.Ctx) error {…})) // bare closure at Use
|
||||
func H(h Handler) Component { return h }
|
||||
|
||||
// Use appends components to this app's program, in order.
|
||||
//
|
||||
// # Snapshot semantics
|
||||
//
|
||||
// A node's middleware environment is the stack inherited at its INCLUSION SITE
|
||||
// plus the middleware entries preceding it at its own level. Two clauses,
|
||||
// which together are what "lexical" means here:
|
||||
//
|
||||
// (a) parent-level entries written AFTER an inclusion site do not reach that
|
||||
// subtree;
|
||||
// (b) entries written INSIDE a subtree, whenever they are written, inherit the
|
||||
// environment anchored at the inclusion site.
|
||||
//
|
||||
// The subtree's CONTENTS may grow until seal; its ENVIRONMENT may not. So
|
||||
// staged composition says exactly what it looks like:
|
||||
//
|
||||
// app.Use(public)
|
||||
// app.Use(publicAPI) // sees public
|
||||
// app.Use(auth)
|
||||
// app.Use(privateAPI) // sees public + auth
|
||||
//
|
||||
// and a late registration inside a group is still a group registration:
|
||||
//
|
||||
// v1 := app.Group("/v1")
|
||||
// app.Use(auth) // parent-level, after inclusion: does NOT reach v1
|
||||
// v1.Get("/x", h) // subtree-internal, late: sees v1's anchored stack
|
||||
//
|
||||
// This DIVERGES from Fiber, where the stack is decided by registration time
|
||||
// against a single flat list. Under Fiber the second example gates /v1/x;
|
||||
// here it does not. The divergence is the point: it makes the environment a
|
||||
// function of WHERE a thing is written rather than WHEN, so adding a line to a
|
||||
// wiring file cannot silently change the auth seam of a subtree written
|
||||
// elsewhere.
|
||||
//
|
||||
// Use returns the App so registrations chain.
|
||||
func (a *App) Use(cs ...Component) Router {
|
||||
site := here(1)
|
||||
for _, c := range cs {
|
||||
switch v := c.(type) {
|
||||
case nil:
|
||||
// A nil component is a composition root that decided not to compose
|
||||
// anything, which is a legitimate answer.
|
||||
case Handler:
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
a.appendEntry(entry{n: v, site: site})
|
||||
case *App:
|
||||
if v == nil {
|
||||
panic(fmt.Sprintf("zip: %s: Use(nil *App)", site))
|
||||
}
|
||||
a.appendEntry(entry{n: v, site: site})
|
||||
// A composition owns its child's teardown, so composing cannot leak
|
||||
// the child's store. Shutdown is once-guarded, so a definition
|
||||
// included twice is still torn down once.
|
||||
a.OnShutdown(v.ShutdownWithContext)
|
||||
default:
|
||||
panic(fmt.Sprintf("zip: %s: %T is not a Component", site, c))
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Group returns a NEW App, included in a at prefix.
|
||||
//
|
||||
// A group is not a third kind of thing. It is an App with a prefix, referenced
|
||||
// from the parent's program like any other — which is why "a scope" and "a
|
||||
// sub-application" are one mechanism here and two in every framework that grew
|
||||
// them separately. Middleware handed to Group, or added to the returned App
|
||||
// later, is scoped to it (see [App.Use] on snapshot semantics).
|
||||
//
|
||||
// One definition at two prefixes is two Group calls, so the prefix is visible
|
||||
// at each inclusion site rather than hidden inside a reference type:
|
||||
//
|
||||
// app.Group("/v1").Use(billing)
|
||||
// app.Group("/admin").Use(billing)
|
||||
func (a *App) Group(prefix string, handlers ...Handler) *App {
|
||||
site := here(1)
|
||||
g := newApp(a.groupConfig())
|
||||
g.prefix = cleanPrefix(prefix)
|
||||
for _, h := range handlers {
|
||||
if h == nil {
|
||||
continue
|
||||
}
|
||||
g.appendEntry(entry{n: h, site: site})
|
||||
}
|
||||
a.appendEntry(entry{n: g, site: site})
|
||||
return g
|
||||
}
|
||||
|
||||
// groupConfig is the parent's config with everything that belongs to a
|
||||
// DEPLOYMENT stripped out. A group inherits how bytes are handled (error
|
||||
// handler, body limit, buffers) because those describe the same server; it
|
||||
// inherits neither the name nor the projections, because a group is not an app
|
||||
// anyone deploys and must not claim a second document, MCP door or identity.
|
||||
func (a *App) groupConfig() Config {
|
||||
cfg := a.cfg
|
||||
cfg.AppName = ""
|
||||
cfg.Eager = false
|
||||
cfg.OpenAPI = OpenAPIConfig{Disabled: true}
|
||||
cfg.MCP = MCPConfig{Disabled: true}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// cleanPrefix normalises a group prefix to "" or "/x" — never "/" and never a
|
||||
// trailing slash, so composing prefixes is concatenation and "/v1" and "/v1/"
|
||||
// are one scope rather than two.
|
||||
func cleanPrefix(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || p == "/" {
|
||||
return ""
|
||||
}
|
||||
if p[0] != '/' {
|
||||
p = "/" + p
|
||||
}
|
||||
return strings.TrimSuffix(p, "/")
|
||||
}
|
||||
|
||||
// appendEntry is the ONE place an App's program grows, so it is the one place
|
||||
// the seal is enforced and the one place the version moves.
|
||||
func (a *App) appendEntry(e entry) {
|
||||
if a.sealed.Load() {
|
||||
panic(fmt.Sprintf("zip: %s: %s was sealed at %s — an App is immutable once execution begins, "+
|
||||
"so this write would change what a running server already published; compose before serving",
|
||||
e.site, a.who(), a.sealSite))
|
||||
}
|
||||
a.entries = append(a.entries, e)
|
||||
version.Add(1)
|
||||
}
|
||||
|
||||
// runtimeEntry appends past the seal, for the ONE plane that legitimately grows
|
||||
// after execution begins: a plugin loaded at run time ([App.Load]). It goes
|
||||
// straight onto the live router because the walk is frozen — a running server's
|
||||
// document is what it published, and a runtime mount is a deployment event, not
|
||||
// a program edit. It is internal by design; the public verbs all panic.
|
||||
func (a *App) runtimeEntry(e entry) {
|
||||
if !a.sealed.Load() {
|
||||
a.appendEntry(e)
|
||||
return
|
||||
}
|
||||
a.buildMu.Lock()
|
||||
defer a.buildMu.Unlock()
|
||||
if r, ok := e.n.(route); ok && a.fiber != nil {
|
||||
a.install(a.fiber, "", nil, r)
|
||||
}
|
||||
}
|
||||
|
||||
// who names this app for a diagnostic: its AppName if it has one, else the
|
||||
// prefix that identifies the group, else that it is anonymous.
|
||||
func (a *App) who() string {
|
||||
switch {
|
||||
case a.cfg.AppName != "":
|
||||
return strconv.Quote(a.cfg.AppName)
|
||||
case a.prefix != "":
|
||||
return "the group " + strconv.Quote(a.prefix)
|
||||
default:
|
||||
return "an anonymous App"
|
||||
}
|
||||
}
|
||||
|
||||
// label is who() without the quoting, for a breadcrumb where every element is
|
||||
// already separated by an arrow.
|
||||
func (a *App) label() string {
|
||||
switch {
|
||||
case a.cfg.AppName != "":
|
||||
return a.cfg.AppName
|
||||
case a.prefix != "":
|
||||
return a.prefix
|
||||
default:
|
||||
return "(anonymous)"
|
||||
}
|
||||
}
|
||||
+655
@@ -0,0 +1,655 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// These are internal tests on purpose. The walk, the seal and the occurrence
|
||||
// slice are the contract every projection is written against, and they are
|
||||
// unexported — the AST is not a compatibility surface. Testing them from
|
||||
// zip_test would mean exporting the AST to test it, which is the tail wagging
|
||||
// the dog.
|
||||
|
||||
func quiet(name string) *App {
|
||||
return New(Config{AppName: name, DisableStartupMessage: true})
|
||||
}
|
||||
|
||||
type invoiceIn struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
type invoiceOut struct {
|
||||
ID string `json:"id"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// billingApp is one DEFINITION, written once, included wherever a test needs it.
|
||||
func billingApp() *App {
|
||||
b := quiet("billing")
|
||||
Get(b, "/invoices/:id", func(_ context.Context, in *invoiceIn) (*invoiceOut, error) {
|
||||
return &invoiceOut{ID: in.ID, Total: 42}, nil
|
||||
}, WithOperationID("listInvoices"))
|
||||
return b
|
||||
}
|
||||
|
||||
func kinds(occ []occurrence) []string {
|
||||
out := make([]string, 0, len(occ))
|
||||
for _, o := range occ {
|
||||
switch n := o.n.(type) {
|
||||
case Handler:
|
||||
out = append(out, "mw")
|
||||
case route:
|
||||
out = append(out, n.method+" "+o.abs(n.path))
|
||||
case *App:
|
||||
out = append(out, "app:"+n.label()+"@"+o.ctx.prefix)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── the walk ────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestWalk_OrderIsProgramOrder is the property everything else rests on: the
|
||||
// occurrence slice is the program read top to bottom, with each included
|
||||
// definition read IN PLACE at its inclusion site. Two typed slices — one of
|
||||
// middleware, one of children — cannot express this, and the interleaving is
|
||||
// semantic: "requestID came before users, authz came after" is a fact about
|
||||
// what runs, not about how the framework stored it.
|
||||
func TestWalk_OrderIsProgramOrder(t *testing.T) {
|
||||
users := quiet("users")
|
||||
users.Get("/users", func(c *Ctx) error { return nil })
|
||||
|
||||
root := quiet("root")
|
||||
root.Use(H(func(c *Ctx) error { return c.Continue() })) // requestID
|
||||
root.Use(users)
|
||||
root.Use(H(func(c *Ctx) error { return c.Continue() })) // authz
|
||||
root.Get("/late", func(c *Ctx) error { return nil })
|
||||
|
||||
occ, err := walk(root)
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
want := []string{"mw", "app:users@", "GET /users", "mw", "GET /late"}
|
||||
got := kinds(occ)
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("walk order:\n got %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalk_AppendIsStable pins the guarantee a generated SDK depends on:
|
||||
// composing one more thing cannot reorder what was already composed. Without
|
||||
// it, adding a plugin reshuffles operation order in the OpenAPI document and
|
||||
// every downstream artifact churns for no semantic reason.
|
||||
func TestWalk_AppendIsStable(t *testing.T) {
|
||||
a, b := quiet("a"), quiet("b")
|
||||
a.Get("/a", func(c *Ctx) error { return nil })
|
||||
b.Get("/b", func(c *Ctx) error { return nil })
|
||||
|
||||
root := quiet("root")
|
||||
root.Use(a)
|
||||
before := kinds(mustWalk(t, root))
|
||||
|
||||
root.Use(b)
|
||||
after := kinds(mustWalk(t, root))
|
||||
|
||||
if len(after) <= len(before) {
|
||||
t.Fatalf("appending b did not add occurrences: %v -> %v", before, after)
|
||||
}
|
||||
for i := range before {
|
||||
if before[i] != after[i] {
|
||||
t.Fatalf("appending b reordered occurrence %d: %q -> %q\n%v\n%v",
|
||||
i, before[i], after[i], before, after)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalk_IsDeterministic: same program, identical slice. A projection that is
|
||||
// not a pure function of the program is a projection whose output diffs for
|
||||
// reasons nobody can explain.
|
||||
func TestWalk_IsDeterministic(t *testing.T) {
|
||||
root := quiet("root")
|
||||
root.Use(H(func(c *Ctx) error { return nil }))
|
||||
root.Group("/v1").Use(billingApp())
|
||||
|
||||
first := kinds(mustWalk(t, root))
|
||||
for i := 0; i < 5; i++ {
|
||||
if got := kinds(mustWalk(t, root)); strings.Join(got, ",") != strings.Join(first, ",") {
|
||||
t.Fatalf("walk %d differs:\n %v\n %v", i, got, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustWalk(t *testing.T, a *App) []occurrence {
|
||||
t.Helper()
|
||||
occ, err := walk(a)
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
return occ
|
||||
}
|
||||
|
||||
// ── snapshot semantics ──────────────────────────────────────────────────────
|
||||
|
||||
// TestSnapshot_ParentMiddlewareAfterInclusionDoesNotReachTheSubtree is clause
|
||||
// (a), and it is where this model DIVERGES from Fiber. Under Fiber the stack is
|
||||
// whatever had been registered by the time a route was, so a Use written later
|
||||
// in the wiring file reaches a group created earlier. Here the subtree's
|
||||
// environment is anchored at its inclusion site, so it does not.
|
||||
//
|
||||
// The divergence is the point: it makes a subtree's auth seam a function of
|
||||
// where it was included, not of how many lines were added to the wiring file
|
||||
// afterwards.
|
||||
func TestSnapshot_ParentMiddlewareAfterInclusionDoesNotReachTheSubtree(t *testing.T) {
|
||||
root := quiet("root")
|
||||
v1 := root.Group("/v1")
|
||||
root.Use(H(func(c *Ctx) error { return c.Continue() })) // written AFTER v1 was included
|
||||
v1.Get("/x", func(c *Ctx) error { return nil }) // written after that
|
||||
|
||||
for _, o := range mustWalk(t, root) {
|
||||
r, ok := o.route()
|
||||
if !ok || o.abs(r.path) != "/v1/x" {
|
||||
continue
|
||||
}
|
||||
if n := o.ctx.mw.len(); n != 0 {
|
||||
t.Fatalf("/v1/x sees %d middleware; the parent's later Use reached into the subtree", n)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("/v1/x not found in the walk")
|
||||
}
|
||||
|
||||
// TestSnapshot_LateSubtreeRegistrationInheritsTheAnchoredStack is clause (b):
|
||||
// the subtree's CONTENTS may grow until seal, its ENVIRONMENT may not. A route
|
||||
// written into a group long after the group was included still gets the group's
|
||||
// stack — WHERE it is written decides, not WHEN.
|
||||
func TestSnapshot_LateSubtreeRegistrationInheritsTheAnchoredStack(t *testing.T) {
|
||||
root := quiet("root")
|
||||
root.Use(H(func(c *Ctx) error { return c.Continue() })) // anchored before v1
|
||||
v1 := root.Group("/v1")
|
||||
v1.Use(H(func(c *Ctx) error { return c.Continue() })) // the group's own
|
||||
root.Get("/other", func(c *Ctx) error { return nil })
|
||||
v1.Get("/late", func(c *Ctx) error { return nil }) // written last, still in v1
|
||||
|
||||
for _, o := range mustWalk(t, root) {
|
||||
r, ok := o.route()
|
||||
if !ok || o.abs(r.path) != "/v1/late" {
|
||||
continue
|
||||
}
|
||||
if n := o.ctx.mw.len(); n != 2 {
|
||||
t.Fatalf("/v1/late sees %d middleware, want 2 (root's, anchored, + the group's)", n)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("/v1/late not found in the walk")
|
||||
}
|
||||
|
||||
// TestLint_StagedCompositionIsReportedNotRefused. Middleware appended after an
|
||||
// inclusion is legal and means what it says. It is INTENTIONAL co-located and a
|
||||
// latent bug written far apart, and no walk can tell those apart — the
|
||||
// difference is authorial intent, evidenced only by co-location. So it is a
|
||||
// lint that names both sites and lets a human read them, rather than an error
|
||||
// that would break the legitimate case or a silence that would hide the other.
|
||||
func TestLint_StagedCompositionIsReportedNotRefused(t *testing.T) {
|
||||
root := quiet("root")
|
||||
root.Use(billingApp())
|
||||
root.Use(H(func(c *Ctx) error { return c.Continue() }))
|
||||
|
||||
lints := root.Lint()
|
||||
if len(lints) != 1 {
|
||||
t.Fatalf("want 1 lint, got %d: %v", len(lints), lints)
|
||||
}
|
||||
for _, want := range []string{"staged composition", "billing", "compose_test.go:"} {
|
||||
if !strings.Contains(lints[0], want) {
|
||||
t.Errorf("lint does not mention %q: %s", want, lints[0])
|
||||
}
|
||||
}
|
||||
// And it is a lint, not a refusal: the program still builds.
|
||||
if err := root.seal(here(0)); err != nil {
|
||||
t.Fatalf("staged composition was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the diamond ─────────────────────────────────────────────────────────────
|
||||
|
||||
// TestDiamond_TwoOccurrencesTwoIdsOneType is the case the eager registry could
|
||||
// not express at all, and the one that decides whether the lazy one is usable.
|
||||
//
|
||||
// One definition, included twice. The SURFACE doubles — two paths, two
|
||||
// operation ids — and the TYPES do not: one Invoice, not two identical copies
|
||||
// under two names. Surface keys on the occurrence, types key on the definition.
|
||||
//
|
||||
// The ids are derived from the PREFIX and never from position. "First
|
||||
// occurrence wins" and "append -2" both make generated output a function of
|
||||
// mount order, so swapping two lines in a wiring file becomes a breaking change
|
||||
// in every published SDK.
|
||||
func TestDiamond_TwoOccurrencesTwoIdsOneType(t *testing.T) {
|
||||
billing := billingApp() // ONE definition
|
||||
root := quiet("cloud")
|
||||
root.Group("/v1").Use(billing)
|
||||
root.Group("/admin").Use(billing)
|
||||
|
||||
if err := root.seal(here(0)); err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
|
||||
var ids, paths []string
|
||||
for _, op := range root.Registry() {
|
||||
ids = append(ids, op.OperationID)
|
||||
paths = append(paths, op.Path)
|
||||
}
|
||||
wantIDs := []string{"v1.listInvoices", "admin.listInvoices"}
|
||||
wantPaths := []string{"/v1/invoices/:id", "/admin/invoices/:id"}
|
||||
if strings.Join(ids, ",") != strings.Join(wantIDs, ",") {
|
||||
t.Errorf("operation ids = %v, want %v", ids, wantIDs)
|
||||
}
|
||||
if strings.Join(paths, ",") != strings.Join(wantPaths, ",") {
|
||||
t.Errorf("paths = %v, want %v", paths, wantPaths)
|
||||
}
|
||||
|
||||
// ONE type. Both occurrences point at the SAME reflect.Type, because the
|
||||
// definition is shared and only the surface was copied.
|
||||
reg := root.Registry()
|
||||
if reg[0].OutType != reg[1].OutType {
|
||||
t.Errorf("two occurrences produced two types: %v vs %v", reg[0].OutType, reg[1].OutType)
|
||||
}
|
||||
schemas := root.OpenAPISpec()["components"].(map[string]any)["schemas"].(map[string]any)
|
||||
n := 0
|
||||
for name := range schemas {
|
||||
if strings.HasSuffix(name, "invoiceOut") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("invoiceOut published %d times, want 1: %v", n, keysOfSchemas(schemas))
|
||||
}
|
||||
|
||||
// And the document is valid: two operations, two distinct ids.
|
||||
if ids[0] == ids[1] {
|
||||
t.Fatal("both occurrences want one operationId — the document is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiamond_IdsDoNotDependOnMountOrder: reversing the two inclusions must
|
||||
// produce the same two ids. If it did not, reordering a wiring file would be an
|
||||
// SDK break.
|
||||
func TestDiamond_IdsDoNotDependOnMountOrder(t *testing.T) {
|
||||
idsFor := func(first, second string) map[string]bool {
|
||||
billing := billingApp()
|
||||
root := quiet("cloud")
|
||||
root.Group(first).Use(billing)
|
||||
root.Group(second).Use(billing)
|
||||
out := map[string]bool{}
|
||||
for _, op := range root.Registry() {
|
||||
out[op.OperationID] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
a := idsFor("/v1", "/admin")
|
||||
b := idsFor("/admin", "/v1")
|
||||
if len(a) != 2 || len(b) != 2 {
|
||||
t.Fatalf("want 2 ids each, got %v and %v", a, b)
|
||||
}
|
||||
for id := range a {
|
||||
if !b[id] {
|
||||
t.Fatalf("id %q appears in one order and not the other: %v vs %v", id, a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keysOfSchemas(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── cycles ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestCycle_IsAnErrorWithABreadcrumbNotAHang. The moment an App is a node, a
|
||||
// cycle is expressible, and without detection it is not a bad error message —
|
||||
// it is an infinite descent. The ancestor set is what makes it an error, and
|
||||
// the breadcrumb is what makes the error actionable.
|
||||
func TestCycle_IsAnErrorWithABreadcrumbNotAHang(t *testing.T) {
|
||||
a, b := quiet("a"), quiet("b")
|
||||
a.Use(b)
|
||||
b.Use(a)
|
||||
|
||||
err := a.seal(here(0))
|
||||
if err == nil {
|
||||
t.Fatal("a cycle sealed successfully")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cycle") || !strings.Contains(err.Error(), "a → b → a") {
|
||||
t.Fatalf("cycle error has no breadcrumb: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A definition that includes ITSELF is the one-node cycle.
|
||||
func TestCycle_SelfInclusion(t *testing.T) {
|
||||
a := quiet("a")
|
||||
a.Use(a)
|
||||
if err := a.seal(here(0)); err == nil || !strings.Contains(err.Error(), "cycle") {
|
||||
t.Fatalf("self-inclusion: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiamond_IsNotACycle: the ancestor set, not a global visited set. A
|
||||
// definition included twice as SIBLINGS is the diamond, which is legal and is
|
||||
// the whole point; only a definition included under ITSELF is a cycle.
|
||||
func TestDiamond_IsNotACycle(t *testing.T) {
|
||||
shared := billingApp()
|
||||
root := quiet("root")
|
||||
root.Group("/v1").Use(shared)
|
||||
root.Group("/admin").Use(shared)
|
||||
if err := root.seal(here(0)); err != nil {
|
||||
t.Fatalf("a diamond was refused as a cycle: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the seal ────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestSeal_PropagatesAcrossTheGraph. Sealing only the app Listen was called on
|
||||
// leaves exactly the race it was meant to close: the child is still writable
|
||||
// while the parent is already serving what it published.
|
||||
func TestSeal_PropagatesAcrossTheGraph(t *testing.T) {
|
||||
users := quiet("users")
|
||||
admin := quiet("admin")
|
||||
users.Use(admin)
|
||||
root := quiet("root")
|
||||
root.Use(users)
|
||||
|
||||
if err := root.seal(here(0)); err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
for _, a := range []*App{root, users, admin} {
|
||||
if !a.Sealed() {
|
||||
t.Errorf("%s is not sealed — the seal did not reach the whole graph", a.label())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeal_MutateAfterSealPanics_MountAfterSealSucceeds. Sealed is MONOTONIC
|
||||
// and it freezes CONTENT, not reachability: a definition already sealed under
|
||||
// one parent may still be included under another, because including it does not
|
||||
// write to it. Writing to it is what is refused.
|
||||
func TestSeal_MutateAfterSealPanics_MountAfterSealSucceeds(t *testing.T) {
|
||||
child := billingApp()
|
||||
first := quiet("first")
|
||||
first.Use(child)
|
||||
if err := first.seal(here(0)); err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
|
||||
// Mutate-after-seal: refused, and the message says who sealed and where.
|
||||
func() {
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatal("a sealed App accepted a new entry")
|
||||
}
|
||||
msg, _ := r.(string)
|
||||
for _, want := range []string{"sealed", "billing", "compose_test.go:"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("panic does not mention %q: %s", want, msg)
|
||||
}
|
||||
}
|
||||
}()
|
||||
child.Get("/sneak", func(c *Ctx) error { return nil })
|
||||
}()
|
||||
|
||||
// Mount-after-seal: allowed. A second host includes the same sealed
|
||||
// definition, and gets its own occurrence of it.
|
||||
second := quiet("second")
|
||||
second.Group("/v2").Use(child)
|
||||
if err := second.seal(here(0)); err != nil {
|
||||
t.Fatalf("including a sealed definition was refused: %v", err)
|
||||
}
|
||||
if n := len(second.Registry()); n != 1 {
|
||||
t.Fatalf("the second host got %d ops from the sealed definition, want 1", n)
|
||||
}
|
||||
if got := second.Registry()[0].Path; got != "/v2/invoices/:id" {
|
||||
t.Errorf("second host path = %q, want /v2/invoices/:id", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeal_ReadingDoesNotSeal. A codegen step, a test or a doc generator that
|
||||
// inspects the program must not turn the next legitimate Use into a panic about
|
||||
// a seal nobody asked for.
|
||||
func TestSeal_ReadingDoesNotSeal(t *testing.T) {
|
||||
root := quiet("root")
|
||||
root.Use(billingApp())
|
||||
|
||||
_ = root.Registry()
|
||||
_ = root.OpenAPISpec()
|
||||
_ = root.Declaration()
|
||||
_ = root.Fiber()
|
||||
_ = root.Lint()
|
||||
|
||||
if root.Sealed() {
|
||||
t.Fatal("inspecting the program sealed it")
|
||||
}
|
||||
root.Use(H(func(c *Ctx) error { return c.Continue() })) // must not panic
|
||||
}
|
||||
|
||||
// ── conflicts ───────────────────────────────────────────────────────────────
|
||||
|
||||
// TestConflicts_AllOfThemAtOnceWithBothPartiesNamed. Failing fast reports one
|
||||
// collision per build, so three disagreements take three builds to learn. And
|
||||
// only a walk over the whole set can name BOTH parties: eager composition could
|
||||
// attribute a collision to whichever app composed second, which is wiring-file
|
||||
// line order and means nothing.
|
||||
func TestConflicts_AllOfThemAtOnceWithBothPartiesNamed(t *testing.T) {
|
||||
iam := quiet("iam")
|
||||
iam.Get("/users", func(c *Ctx) error { return nil })
|
||||
iam.Get("/tokens", func(c *Ctx) error { return nil })
|
||||
|
||||
billing := quiet("billing")
|
||||
billing.Get("/users", func(c *Ctx) error { return nil }) // collides
|
||||
billing.Get("/tokens", func(c *Ctx) error { return nil }) // collides too
|
||||
|
||||
root := quiet("root")
|
||||
root.Use(iam)
|
||||
root.Use(billing)
|
||||
|
||||
err := root.seal(here(0))
|
||||
if err == nil {
|
||||
t.Fatal("two apps claiming two addresses each were accepted")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
"GET /users", "GET /tokens", `"iam"`, `"billing"`, "compose_test.go:",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("conflict report does not mention %q:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
// BOTH, in one report — not the first and then a second build.
|
||||
if strings.Count(msg, "declared by") != 2 {
|
||||
t.Errorf("want 2 conflicts reported together, got:\n%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConflicts_NamesTheCompositionPathNotJustTheApp. "declared by billing" is
|
||||
// not enough when billing is included twice; the breadcrumb is what says which
|
||||
// inclusion.
|
||||
func TestConflicts_NamesTheCompositionPath(t *testing.T) {
|
||||
leaf := quiet("leaf")
|
||||
leaf.Get("/x", func(c *Ctx) error { return nil })
|
||||
|
||||
root := quiet("root")
|
||||
root.Group("/v1").Use(leaf)
|
||||
root.Group("/v1").Use(leaf) // same prefix twice: the same address twice
|
||||
|
||||
err := root.seal(here(0))
|
||||
if err == nil {
|
||||
t.Fatal("one address claimed twice was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "root → /v1 → leaf") {
|
||||
t.Fatalf("conflict does not carry the composition path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── concurrency ─────────────────────────────────────────────────────────────
|
||||
|
||||
// TestRegistry_RaceCleanAfterSeal. Registry is read from serving goroutines —
|
||||
// the OpenAPI endpoint and the MCP tool listing both call it per request — so
|
||||
// it has to be safe without a mutex on the served path. Sealing is what buys
|
||||
// that: the program cannot change, so the projection is computed once under
|
||||
// sync.Once and shared.
|
||||
func TestRegistry_RaceCleanAfterSeal(t *testing.T) {
|
||||
root := quiet("root")
|
||||
root.Group("/v1").Use(billingApp())
|
||||
root.Group("/admin").Use(billingApp())
|
||||
if err := root.seal(here(0)); err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 64; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if n := len(root.Registry()); n != 2 {
|
||||
t.Errorf("Registry() = %d ops, want 2", n)
|
||||
}
|
||||
_ = root.OpenAPISpec()
|
||||
_ = root.Commands()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestRegistry_IsMemoisedOnceSealed: the same slice, not an equal one. If it
|
||||
// were rebuilt per call, every OpenAPI request would re-walk the graph.
|
||||
func TestRegistry_IsMemoisedOnceSealed(t *testing.T) {
|
||||
root := quiet("root")
|
||||
root.Use(billingApp())
|
||||
if err := root.seal(here(0)); err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
first := root.Registry()
|
||||
if &first[0] != &root.Registry()[0] {
|
||||
t.Fatal("Registry() rebuilt after seal; it must be computed once")
|
||||
}
|
||||
}
|
||||
|
||||
// ── the H footnote ──────────────────────────────────────────────────────────
|
||||
|
||||
// TestH_IsNeededOnlyForABareClosureAtUse pins the migration claim in the type
|
||||
// system rather than in prose. Everything already typed as a Handler composes
|
||||
// with no wrapper; every route method still takes ...Handler so an inline
|
||||
// closure there is untouched; the ONE construct that needs H is a bare closure
|
||||
// written at a Use call, and that is a compile-time fact, not a style note.
|
||||
func TestH_IsNeededOnlyForABareClosureAtUse(t *testing.T) {
|
||||
app := quiet("app")
|
||||
|
||||
// 1. A value already typed Handler is a Component. No wrapper.
|
||||
var typed Handler = func(c *Ctx) error { return c.Continue() }
|
||||
app.Use(typed)
|
||||
|
||||
// 2. A constructor that returns Handler — every middleware in the package —
|
||||
// is a Component. No wrapper.
|
||||
app.Use(recoverish())
|
||||
|
||||
// 3. Route methods still take ...Handler, so an inline closure is fine.
|
||||
app.Get("/x", func(c *Ctx) error { return nil })
|
||||
app.With(func(next Handler) Handler { return next }).Post("/y", func(c *Ctx) error { return nil })
|
||||
|
||||
// 4. Only the bare closure at Use needs it. The commented line below is the
|
||||
// compile error this test exists to describe:
|
||||
// app.Use(func(c *Ctx) error { return c.Continue() })
|
||||
// → func(*Ctx) error does not implement Component (missing method component)
|
||||
app.Use(H(func(c *Ctx) error { return c.Continue() }))
|
||||
|
||||
if n := len(app.entries); n != 5 {
|
||||
t.Fatalf("got %d entries, want 5", n)
|
||||
}
|
||||
}
|
||||
|
||||
func recoverish() Handler { return func(c *Ctx) error { return c.Continue() } }
|
||||
|
||||
// ── the union ───────────────────────────────────────────────────────────────
|
||||
|
||||
// TestCompose_DocumentIsTheUnion is the property behind "Graft took iam from 4
|
||||
// paths to 164": composing is what makes a host's document the whole surface
|
||||
// rather than the wildcard it hung a closure on. Under AdaptNetHTTP the child
|
||||
// went in as an http.Handler and the count stayed at the host's own.
|
||||
//
|
||||
// The number is arithmetic here, not a fixture, so it pins the RULE.
|
||||
func TestCompose_DocumentIsTheUnion(t *testing.T) {
|
||||
const own, childOps = 4, 160
|
||||
|
||||
child := quiet("iam")
|
||||
for i := 0; i < childOps; i++ {
|
||||
Get(child, "/v1/iam/r"+itoa(i), func(_ context.Context, in *invoiceIn) (*invoiceOut, error) {
|
||||
return &invoiceOut{}, nil
|
||||
}, WithOperationID("iam_r"+itoa(i)))
|
||||
}
|
||||
host := quiet("host")
|
||||
for i := 0; i < own; i++ {
|
||||
Get(host, "/v1/host/r"+itoa(i), func(_ context.Context, in *invoiceIn) (*invoiceOut, error) {
|
||||
return &invoiceOut{}, nil
|
||||
}, WithOperationID("host_r"+itoa(i)))
|
||||
}
|
||||
if n := len(host.OpenAPISpec()["paths"].(map[string]map[string]any)); n != own {
|
||||
t.Fatalf("the host alone has %d paths, want %d", n, own)
|
||||
}
|
||||
|
||||
host.Use(child)
|
||||
if err := host.seal(here(0)); err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
if n := len(host.OpenAPISpec()["paths"].(map[string]map[string]any)); n != own+childOps {
|
||||
t.Fatalf("composed document has %d paths, want %d", n, own+childOps)
|
||||
}
|
||||
// And every other projection agrees, from the same registry.
|
||||
if n := len(host.Registry()); n != own+childOps {
|
||||
t.Errorf("registry has %d ops, want %d", n, own+childOps)
|
||||
}
|
||||
if n := len(host.Commands()); n != own+childOps {
|
||||
t.Errorf("CLI has %d commands, want %d", n, own+childOps)
|
||||
}
|
||||
if n := len(host.Declaration().Routes); n != own+childOps {
|
||||
t.Errorf("declaration has %d routes, want %d", n, own+childOps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompose_ADefinitionsOwnDocumentIsUnchanged. Being included must not edit
|
||||
// what a definition says about itself: a service served standalone describes
|
||||
// its types under their plain names and its ops under the ids it declared,
|
||||
// whether or not some host somewhere composed it.
|
||||
func TestCompose_ADefinitionsOwnDocumentIsUnchanged(t *testing.T) {
|
||||
solo := billingApp()
|
||||
before := jsonOf(t, solo.OpenAPISpec())
|
||||
|
||||
host := quiet("host")
|
||||
host.Group("/v1").Use(solo)
|
||||
_ = host.OpenAPISpec() // the composed document is rendered
|
||||
|
||||
if after := jsonOf(t, solo.OpenAPISpec()); after != before {
|
||||
t.Fatalf("being composed edited the definition's own document:\n before %s\n after %s", before, after)
|
||||
}
|
||||
// Concretely: its op is still listInvoices at /invoices/{id}, not the
|
||||
// v1.listInvoices the HOST publishes for its occurrence.
|
||||
if id := solo.Registry()[0].OperationID; id != "listInvoices" {
|
||||
t.Errorf("the definition's own op id became %q", id)
|
||||
}
|
||||
if id := host.Registry()[0].OperationID; id != "v1.listInvoices" {
|
||||
t.Errorf("the host's occurrence id = %q, want v1.listInvoices", id)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonOf(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func itoa(i int) string { return strconv.Itoa(i) }
|
||||
@@ -0,0 +1,229 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The walk says what a program MEANS. These say what it DOES — the same
|
||||
// programs, driven over the wire, because a projection that is right on paper
|
||||
// and wrong at the socket is wrong.
|
||||
|
||||
func wireGET(t *testing.T, a *App, path string, headers ...string) (int, string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
req.Header.Set(headers[i], headers[i+1])
|
||||
}
|
||||
resp, err := a.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(body)
|
||||
}
|
||||
|
||||
// guarded is a definition whose entire authentication seam is a pathless
|
||||
// app.Use — the shape that made route-copying unsafe and that [App.Graft]'s
|
||||
// delegation existed to protect.
|
||||
func guarded(name string) *App {
|
||||
a := quiet(name)
|
||||
a.Use(H(func(c *Ctx) error {
|
||||
if c.Header("Authorization") == "" {
|
||||
return Errorf(401, "no credentials")
|
||||
}
|
||||
return c.Continue()
|
||||
}))
|
||||
a.Get("/thing", func(c *Ctx) error { return c.String(200, name+" thing") })
|
||||
return a
|
||||
}
|
||||
|
||||
// TestWire_IncludedMiddlewareStaysInsideItsDefinition is the property the whole
|
||||
// composition model is judged on, and inclusion by reference has to earn it the
|
||||
// hard way: there is no delegation boundary at run time to hide behind, so the
|
||||
// definition's middleware has to be composed onto the definition's OWN routes
|
||||
// and nothing else.
|
||||
//
|
||||
// The failure this prevents is not hypothetical: a child's pathless Use, placed
|
||||
// on the host's router, gates every route in the host binary.
|
||||
func TestWire_IncludedMiddlewareStaysInsideItsDefinition(t *testing.T) {
|
||||
root := quiet("host")
|
||||
root.Get("/before", func(c *Ctx) error { return c.String(200, "before") })
|
||||
root.Use(guarded("iam"))
|
||||
root.Get("/after", func(c *Ctx) error { return c.String(200, "after") })
|
||||
|
||||
// The guard DID come across: the definition's own route is gated.
|
||||
if code, _ := wireGET(t, root, "/thing"); code != 401 {
|
||||
t.Errorf("the definition's route answered %d without credentials — its guard was dropped", code)
|
||||
}
|
||||
if code, b := wireGET(t, root, "/thing", "Authorization", "yes"); code != 200 || b != "iam thing" {
|
||||
t.Errorf("the definition's route with credentials: %d %q", code, b)
|
||||
}
|
||||
// And it did NOT bleed — in EITHER direction. Routes written before the
|
||||
// inclusion and routes written after it are both untouched.
|
||||
if code, b := wireGET(t, root, "/before"); code != 200 || b != "before" {
|
||||
t.Errorf("host route written BEFORE the inclusion answered %d %q — the guard bled backwards", code, b)
|
||||
}
|
||||
if code, b := wireGET(t, root, "/after"); code != 200 || b != "after" {
|
||||
t.Errorf("host route written AFTER the inclusion answered %d %q — the guard bled forwards", code, b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWire_GroupIsLexicalScope: middleware declared in a group applies to that
|
||||
// group and to nothing else, including nothing that comes after it.
|
||||
func TestWire_GroupIsLexicalScope(t *testing.T) {
|
||||
root := quiet("host")
|
||||
v1 := root.Group("/v1")
|
||||
v1.Use(H(func(c *Ctx) error {
|
||||
if c.Header("X-Key") == "" {
|
||||
return Errorf(401, "no key")
|
||||
}
|
||||
return c.Continue()
|
||||
}))
|
||||
v1.Get("/x", func(c *Ctx) error { return c.String(200, "v1 x") })
|
||||
root.Get("/open", func(c *Ctx) error { return c.String(200, "open") })
|
||||
|
||||
if code, _ := wireGET(t, root, "/v1/x"); code != 401 {
|
||||
t.Errorf("group route answered %d without the key — the group's Use did not apply", code)
|
||||
}
|
||||
if code, b := wireGET(t, root, "/v1/x", "X-Key", "k"); code != 200 || b != "v1 x" {
|
||||
t.Errorf("group route with the key: %d %q", code, b)
|
||||
}
|
||||
if code, b := wireGET(t, root, "/open"); code != 200 || b != "open" {
|
||||
t.Errorf("a route outside the group answered %d %q — the group's Use escaped its scope", code, b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWire_DiamondServesBothOccurrences: one definition, two prefixes, and BOTH
|
||||
// answer. The router keys on the occurrence, so two inclusions are two route
|
||||
// sets, not one that the second inclusion silently lost.
|
||||
func TestWire_DiamondServesBothOccurrences(t *testing.T) {
|
||||
billing := billingApp()
|
||||
root := quiet("cloud")
|
||||
root.Group("/v1").Use(billing)
|
||||
root.Group("/admin").Use(billing)
|
||||
|
||||
for _, p := range []string{"/v1/invoices/i-1", "/admin/invoices/i-1"} {
|
||||
code, body := wireGET(t, root, p)
|
||||
if code != 200 || !strings.Contains(body, "i-1") {
|
||||
t.Errorf("%s answered %d %q", p, code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWire_DifferentGuardsPerOccurrence. The same definition legitimately runs
|
||||
// under one guard in one place and another guard elsewhere; that is the
|
||||
// semantics of an occurrence, not an approximation of it.
|
||||
func TestWire_DifferentGuardsPerOccurrence(t *testing.T) {
|
||||
shared := quiet("shared")
|
||||
shared.Get("/who", func(c *Ctx) error { return c.String(200, "shared") })
|
||||
|
||||
need := func(h string) Handler {
|
||||
return func(c *Ctx) error {
|
||||
if c.Header(h) == "" {
|
||||
return Errorf(401, "want %s", h)
|
||||
}
|
||||
return c.Continue()
|
||||
}
|
||||
}
|
||||
root := quiet("host")
|
||||
pub := root.Group("/pub")
|
||||
pub.Use(need("X-User"))
|
||||
pub.Use(shared)
|
||||
adm := root.Group("/adm")
|
||||
adm.Use(need("X-Admin"))
|
||||
adm.Use(shared)
|
||||
|
||||
if code, _ := wireGET(t, root, "/pub/who", "X-User", "u"); code != 200 {
|
||||
t.Errorf("/pub/who with a user credential answered %d", code)
|
||||
}
|
||||
if code, _ := wireGET(t, root, "/pub/who", "X-Admin", "a"); code != 401 {
|
||||
t.Errorf("/pub/who accepted the ADMIN credential (%d) — the occurrences share a guard", code)
|
||||
}
|
||||
if code, _ := wireGET(t, root, "/adm/who", "X-Admin", "a"); code != 200 {
|
||||
t.Errorf("/adm/who with an admin credential answered %d", code)
|
||||
}
|
||||
if code, _ := wireGET(t, root, "/adm/who", "X-User", "u"); code != 401 {
|
||||
t.Errorf("/adm/who accepted the USER credential (%d) — the occurrences share a guard", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWire_UndeclaredPathIsNotSwallowed. Inclusion registers exactly the
|
||||
// addresses the definition declares, so a path nobody declares is a 404 from
|
||||
// the host rather than being swallowed by a wildcard.
|
||||
func TestWire_UndeclaredPathIsNotSwallowed(t *testing.T) {
|
||||
root := quiet("host")
|
||||
root.Group("/v1").Use(billingApp())
|
||||
if code, _ := wireGET(t, root, "/v1/nobody-declares-this"); code != 404 {
|
||||
t.Errorf("undeclared path answered %d, want 404 — the inclusion is swallowing", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWire_OneCtxAcrossAnInclusionBoundary. A definition's middleware and the
|
||||
// host's middleware run in one request, so they must be handed ONE *Ctx —
|
||||
// enrichment written by either has to be visible to the other. Binding a
|
||||
// handler to the definition that declared it instead of the app that serves it
|
||||
// breaks exactly this.
|
||||
func TestWire_OneCtxAcrossAnInclusionBoundary(t *testing.T) {
|
||||
var seen []*Ctx
|
||||
inner := quiet("inner")
|
||||
inner.Use(H(func(c *Ctx) error { seen = append(seen, c); return c.Continue() }))
|
||||
inner.Get("/deep", func(c *Ctx) error {
|
||||
seen = append(seen, c)
|
||||
return c.NoContent(204)
|
||||
})
|
||||
|
||||
root := quiet("host")
|
||||
root.Use(H(func(c *Ctx) error { seen = append(seen, c); return c.Continue() }))
|
||||
root.Group("/v1").Use(inner)
|
||||
|
||||
if code, _ := wireGET(t, root, "/v1/deep"); code != 204 {
|
||||
t.Fatalf("/v1/deep answered %d", code)
|
||||
}
|
||||
if len(seen) != 3 {
|
||||
t.Fatalf("ran %d handlers, want 3", len(seen))
|
||||
}
|
||||
for i, c := range seen {
|
||||
if c != seen[0] {
|
||||
t.Fatalf("handler %d got a different *Ctx — handlers were bound to their definition, not to the server", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWire_IncludedControlPlaneIsNotAdopted. zip's own projections belong to
|
||||
// the process serving them. Inclusion reads a definition's whole program, so
|
||||
// without an explicit rule a definition that had ever rendered its own document
|
||||
// would hand the host ITS document at the host's well-known path — and the
|
||||
// composition would publish the part as if it were the whole.
|
||||
func TestWire_IncludedControlPlaneIsNotAdopted(t *testing.T) {
|
||||
child := billingApp()
|
||||
child.Prepare() // the definition renders its own control plane first
|
||||
|
||||
host := quiet("host")
|
||||
Get(host, "/own", func(context.Context, *invoiceIn) (*invoiceOut, error) { return &invoiceOut{}, nil },
|
||||
WithOperationID("hostOwn"))
|
||||
host.Group("/v1").Use(child)
|
||||
host.Prepare()
|
||||
|
||||
code, body := wireGET(t, host, SpecPath)
|
||||
if code != 200 {
|
||||
t.Fatalf("%s answered %d", SpecPath, code)
|
||||
}
|
||||
if !strings.Contains(body, `"title":"host"`) {
|
||||
t.Errorf("the included definition took the host's document: %s", firstN(body, 200))
|
||||
}
|
||||
if !strings.Contains(body, "/v1/invoices/{id}") {
|
||||
t.Errorf("the host's served document is missing the included op: %s", firstN(body, 400))
|
||||
}
|
||||
}
|
||||
|
||||
func firstN(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
+23
-4
@@ -47,7 +47,7 @@ func (a *App) control(method, path string, h fiber.Handler) {
|
||||
a.controls = map[string]bool{}
|
||||
}
|
||||
a.controls[method+" "+path] = true
|
||||
a.fiber.Add([]string{method}, path, h)
|
||||
a.installControl(route{method: method, path: path, serve: h})
|
||||
}
|
||||
|
||||
// Declaration is what a plugin tells a host: who it is, whether it must be
|
||||
@@ -70,6 +70,12 @@ type Declaration struct {
|
||||
type Route struct {
|
||||
Method string `json:"method"`
|
||||
Pattern string `json:"pattern"`
|
||||
|
||||
// Op is the operation id this pattern answers under, empty when the route is
|
||||
// untyped. It is what makes a remote [App.Mount] able to contribute OPS and
|
||||
// not merely addresses: the mounting app is handed the declaration inline and
|
||||
// reads the ids out of it, instead of asking the remote what it serves.
|
||||
Op string `json:"op,omitempty"`
|
||||
}
|
||||
|
||||
// Declaration projects the live router: every method+pattern the app will
|
||||
@@ -85,7 +91,7 @@ func (a *App) Declaration() Declaration {
|
||||
d := Declaration{Name: a.cfg.AppName, Eager: a.cfg.Eager, Routes: []Route{}}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, r := range a.fiber.GetRoutes(true) {
|
||||
for _, r := range a.router().GetRoutes(true) {
|
||||
if r.Method == "" || r.Path == "" {
|
||||
continue
|
||||
}
|
||||
@@ -112,12 +118,25 @@ func (a *App) Declaration() Declaration {
|
||||
})
|
||||
|
||||
ops := map[string]bool{}
|
||||
for _, op := range a.registry {
|
||||
if n := opName(op); n != "" && !ops[n] {
|
||||
// byAddr is what lets a declaration say which op answers WHICH address, so a
|
||||
// host that mounts this service can contribute its ops and not merely its
|
||||
// addresses. Without it a remote is a set of paths with no names, and every
|
||||
// projection but the router stops at the process boundary.
|
||||
byAddr := map[string]string{}
|
||||
for _, op := range a.Registry() {
|
||||
n := opName(op)
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
byAddr[op.Method+" "+op.Path] = n
|
||||
if !ops[n] {
|
||||
ops[n] = true
|
||||
d.Ops = append(d.Ops, n)
|
||||
}
|
||||
}
|
||||
for i := range d.Routes {
|
||||
d.Routes[i].Op = byAddr[d.Routes[i].Method+" "+d.Routes[i].Pattern]
|
||||
}
|
||||
sort.Strings(d.Ops)
|
||||
return d
|
||||
}
|
||||
|
||||
+7
-2
@@ -40,10 +40,15 @@ func TestDeclarationIsTheRouter(t *testing.T) {
|
||||
if d.Name != "demo" {
|
||||
t.Fatalf("name = %q, want demo", d.Name)
|
||||
}
|
||||
// Op names the operation this address answers under, empty for the untyped
|
||||
// door. It is what lets a host that MOUNTS this service contribute its ops
|
||||
// and not merely its addresses — without it a remote is a set of paths with
|
||||
// no names, and every projection but the router stops at the process
|
||||
// boundary.
|
||||
want := []zip.Route{
|
||||
{Method: "POST", Pattern: "/v1/demo/ingest"},
|
||||
{Method: "POST", Pattern: "/v1/demo/quote"},
|
||||
{Method: "GET", Pattern: "/v1/demo/quotes/:id"},
|
||||
{Method: "POST", Pattern: "/v1/demo/quote", Op: "demo_quote"},
|
||||
{Method: "GET", Pattern: "/v1/demo/quotes/:id", Op: "demo_quote_read"},
|
||||
}
|
||||
if len(d.Routes) != len(want) {
|
||||
t.Fatalf("routes = %+v, want %+v", d.Routes, want)
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
)
|
||||
|
||||
// Graft composes a child App into this one, IN PROCESS: the parent's router
|
||||
@@ -100,82 +97,36 @@ func (a *App) Graft(children ...*App) error {
|
||||
"and the call plane are rendered once from the registry, so an op grafted after " +
|
||||
"that would serve and never describe")
|
||||
}
|
||||
|
||||
// PASS 1 — no mutation. Every address every child wants, checked against the
|
||||
// parent's router and against its siblings, so a refusal costs nothing.
|
||||
held := map[string]string{}
|
||||
host := cmp.Or(a.cfg.AppName, "host")
|
||||
for _, r := range a.fiber.GetRoutes(true) {
|
||||
if r.Method == "" || r.Path == "" {
|
||||
continue
|
||||
}
|
||||
held[addrKey(r.Method, r.Path)] = host
|
||||
}
|
||||
|
||||
type graftee struct {
|
||||
name string
|
||||
decl Declaration
|
||||
child *App
|
||||
}
|
||||
grafts := make([]graftee, 0, len(children))
|
||||
var refused []string
|
||||
for _, child := range children {
|
||||
if child == nil {
|
||||
return fmt.Errorf("zip: Graft: nil child")
|
||||
}
|
||||
name := child.cfg.AppName
|
||||
if name == "" {
|
||||
if child.cfg.AppName == "" {
|
||||
return fmt.Errorf("zip: Graft: a child with no AppName cannot be grafted — " +
|
||||
"its name is what qualifies its types in the composed document")
|
||||
}
|
||||
decl := child.Declaration() // prepares the child; excludes zip's control plane
|
||||
for _, r := range decl.Routes {
|
||||
k := addrKey(r.Method, r.Pattern)
|
||||
if owner, dup := held[k]; dup {
|
||||
refused = append(refused, fmt.Sprintf(
|
||||
"%q is already claimed by %q — %q cannot have it too",
|
||||
r.Method+" "+r.Pattern, owner, name))
|
||||
continue
|
||||
}
|
||||
held[k] = name
|
||||
}
|
||||
grafts = append(grafts, graftee{name: name, decl: decl, child: child})
|
||||
}
|
||||
if len(refused) > 0 {
|
||||
return fmt.Errorf("zip: Graft refused, nothing registered:\n\t%s", strings.Join(refused, "\n\t"))
|
||||
}
|
||||
|
||||
// PASS 2 — register. Every check has passed, so nothing below can fail.
|
||||
for _, g := range grafts {
|
||||
// One handler value for every one of the child's patterns: the child's
|
||||
// own router, run on the request the parent already has.
|
||||
serve := g.child.fiber.Handler()
|
||||
delegate := func(c fiber.Ctx) error {
|
||||
serve(c.RequestCtx())
|
||||
return nil
|
||||
}
|
||||
for _, r := range g.decl.Routes {
|
||||
a.fiber.Add([]string{r.Method}, r.Pattern, delegate)
|
||||
}
|
||||
for _, op := range g.child.registry {
|
||||
// A COPY: the parent's registry is its own value, so composing a
|
||||
// child never edits what the child says about itself. An op that
|
||||
// already names its declarer keeps that name — a graft two levels
|
||||
// deep still credits the app that wrote the type.
|
||||
c := *op
|
||||
if c.Origin == "" {
|
||||
c.Origin = g.name
|
||||
}
|
||||
if len(c.Tags) == 0 {
|
||||
// Derived, never invented: a grafted product is a named group
|
||||
// in the parent's document instead of an untagged blob.
|
||||
c.Tags = []string{c.Origin}
|
||||
}
|
||||
a.registry = append(a.registry, &c)
|
||||
}
|
||||
a.OnShutdown(g.child.ShutdownWithContext)
|
||||
a.logger.Info("zip grafting", "child", g.name,
|
||||
"routes", len(g.decl.Routes), "ops", len(g.child.registry))
|
||||
// The whole verb, now: append the references and ask the walk whether the
|
||||
// result is a program. Every check Graft used to run by hand — the address
|
||||
// collisions against the parent AND between siblings — is one property of
|
||||
// one walk, and it reports every collision instead of the first.
|
||||
mark := len(a.entries)
|
||||
site := here(1)
|
||||
for _, child := range children {
|
||||
a.appendEntry(entry{n: child, site: site})
|
||||
a.OnShutdown(child.ShutdownWithContext)
|
||||
}
|
||||
if _, err := walk(a); err != nil {
|
||||
// All-or-nothing: a half-grafted app is worse than a refused one, and
|
||||
// under a lazy registry "nothing registered" is literally truncating the
|
||||
// program back to where it was.
|
||||
a.entries = a.entries[:mark]
|
||||
version.Add(1)
|
||||
return fmt.Errorf("zip: Graft refused, nothing registered:\n\t%w", err)
|
||||
}
|
||||
for _, child := range children {
|
||||
a.logger.Info("zip grafting", "child", child.cfg.AppName, "ops", len(child.Registry()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -61,10 +61,10 @@ func orgOf(ctx context.Context) string { org, _ := ctx.Value(orgKey{}).(string);
|
||||
func main() {
|
||||
app := zip.New(zip.Config{AppName: "testplugin", DisableStartupMessage: true,
|
||||
MCP: zip.MCPConfig{Source: tenant{}}})
|
||||
app.Use(func(c *zip.Ctx) error {
|
||||
app.Use(zip.H(func(c *zip.Ctx) error {
|
||||
c.SetContext(context.WithValue(c.Context(), orgKey{}, c.Org()))
|
||||
return c.Continue()
|
||||
})
|
||||
}))
|
||||
|
||||
// Typed, like any other route worth having: a plugin is an ordinary zip
|
||||
// app, so its ops project into the host's document, tools and call plane
|
||||
|
||||
+4
-4
@@ -35,15 +35,15 @@ func TestTwoAppsNoStateBleed(t *testing.T) {
|
||||
})
|
||||
|
||||
// Middleware only on A — sets a marker header on every A request.
|
||||
a.Use(func(c *zip.Ctx) error {
|
||||
a.Use(zip.H(func(c *zip.Ctx) error {
|
||||
c.SetHeader("X-App", "a")
|
||||
return c.Continue()
|
||||
})
|
||||
}))
|
||||
// Middleware only on B — sets a different marker on B.
|
||||
b.Use(func(c *zip.Ctx) error {
|
||||
b.Use(zip.H(func(c *zip.Ctx) error {
|
||||
c.SetHeader("X-App", "b")
|
||||
return c.Continue()
|
||||
})
|
||||
}))
|
||||
|
||||
// A's route is reachable on A.
|
||||
reqA, _ := http.NewRequest("GET", "/from-a", nil)
|
||||
|
||||
@@ -96,12 +96,12 @@ func (a *App) mcpName() string {
|
||||
// must answer it without touching a single child. A pluginTools entry is a
|
||||
// build-time catalogue, so the answer is a memcpy and the process count is zero.
|
||||
func (a *App) installMCP() {
|
||||
if a.cfg.MCP.Disabled || (len(a.registry) == 0 && len(a.pluginTools) == 0 && !a.hasCaller()) {
|
||||
if a.cfg.MCP.Disabled || (len(a.Registry()) == 0 && len(a.pluginTools) == 0 && !a.hasCaller()) {
|
||||
return
|
||||
}
|
||||
a.renderTools()
|
||||
a.control(fiber.MethodPost, a.mcpPath(), a.handleMCP)
|
||||
a.logger.Info("zip mcp", "path", a.mcpPath(), "ops", len(a.registry),
|
||||
a.logger.Info("zip mcp", "path", a.mcpPath(), "ops", len(a.Registry()),
|
||||
"plugin tools", len(a.pluginTools), "per-caller", a.hasCaller())
|
||||
}
|
||||
|
||||
@@ -141,8 +141,9 @@ type mcpTool struct {
|
||||
// per-caller half has to know what the build-time half already claims and reading
|
||||
// that back out of the bytes would re-parse hundreds of schemas per request.
|
||||
func (a *App) composeTools() (json.RawMessage, map[string]bool) {
|
||||
all := make([]mcpTool, 0, len(a.registry)+len(a.pluginTools))
|
||||
for _, op := range a.registry {
|
||||
own := a.Registry()
|
||||
all := make([]mcpTool, 0, len(own)+len(a.pluginTools))
|
||||
for _, op := range own {
|
||||
b, err := json.Marshal(mcpToolOf(op))
|
||||
if err != nil {
|
||||
a.logger.Warn("zip mcp: op has no renderable schema", "op", opName(op), "err", err)
|
||||
@@ -423,8 +424,9 @@ func (a *App) MCPTools() []map[string]any { return a.mcpTools() }
|
||||
// build-time catalogue — and an artifact ordered by registration churns on an
|
||||
// edit that changed nothing a client can see.
|
||||
func (a *App) mcpTools() []map[string]any {
|
||||
tools := make([]map[string]any, 0, len(a.registry))
|
||||
for _, op := range a.registry {
|
||||
reg := a.Registry()
|
||||
tools := make([]map[string]any, 0, len(reg))
|
||||
for _, op := range reg {
|
||||
tools = append(tools, mcpToolOf(op))
|
||||
}
|
||||
sort.Slice(tools, func(i, j int) bool {
|
||||
@@ -539,7 +541,7 @@ func (a *App) mcpForward(fc fiber.Ctx, req mcpRequest, p *plugin) error {
|
||||
}
|
||||
|
||||
func (a *App) opByName(name string) *registeredOp {
|
||||
for _, op := range a.registry {
|
||||
for _, op := range a.Registry() {
|
||||
if opName(op) == name {
|
||||
return op
|
||||
}
|
||||
|
||||
+2
-2
@@ -58,10 +58,10 @@ type orgKey struct{}
|
||||
func sourceApp(t *testing.T, src Source) *App {
|
||||
t.Helper()
|
||||
app := New(Config{AppName: "src", MCP: MCPConfig{Source: src}})
|
||||
app.Use(func(c *Ctx) error {
|
||||
app.Use(H(func(c *Ctx) error {
|
||||
c.SetContext(context.WithValue(c.Context(), orgKey{}, c.Org()))
|
||||
return c.Continue()
|
||||
})
|
||||
}))
|
||||
Get(app, "/ping", ping)
|
||||
app.Prepare()
|
||||
return app
|
||||
|
||||
+16
-5
@@ -53,11 +53,11 @@ func Chain(mw ...Middleware) Middleware {
|
||||
// through the same fiber path as any other route, so specificity precedence is
|
||||
// unchanged — only the leaf handler is pre-wrapped.
|
||||
type wrapRouter struct {
|
||||
inner Router
|
||||
inner *App
|
||||
wrap Middleware
|
||||
}
|
||||
|
||||
func (w *wrapRouter) Use(handlers ...Handler) Router { w.inner.Use(handlers...); return w }
|
||||
func (w *wrapRouter) Use(cs ...Component) Router { w.inner.Use(cs...); return w }
|
||||
|
||||
func (w *wrapRouter) Get(p string, hs ...Handler) Router {
|
||||
w.inner.Get(p, w.wrapChain(hs)...)
|
||||
@@ -119,8 +119,19 @@ func (w *wrapRouter) OpScope() OpScope {
|
||||
return s
|
||||
}
|
||||
|
||||
func (w *wrapRouter) Group(prefix string, handlers ...Handler) Router {
|
||||
return &wrapRouter{inner: w.inner.Group(prefix, handlers...), wrap: w.wrap}
|
||||
// Group carries the wrap ONTO the group rather than around a decorator of it,
|
||||
// because a group is an App and an App can hold its own wrap. Every leaf in the
|
||||
// scope is wrapped, including ones registered on the returned App later — which
|
||||
// is what a scoped With has to mean, and is why this cannot just delegate and
|
||||
// drop the chain the way a decorator that returned a bare group would.
|
||||
func (w *wrapRouter) Group(prefix string, handlers ...Handler) *App {
|
||||
g := w.inner.Group(prefix, handlers...)
|
||||
if g.wrap == nil {
|
||||
g.wrap = w.wrap
|
||||
} else {
|
||||
g.wrap = Chain(g.wrap, w.wrap)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func (w *wrapRouter) Fiber() fiber.Router { return w.inner.Fiber() }
|
||||
func (w *wrapRouter) Fiber() *fiber.App { return w.inner.Fiber() }
|
||||
|
||||
+1
-1
@@ -139,7 +139,7 @@ func TestWith_CloudRateLimit_ShortCircuits(t *testing.T) {
|
||||
func TestWith_CoexistsWithUse(t *testing.T) {
|
||||
app := mwApp()
|
||||
var rec []string
|
||||
app.Use(func(c *zip.Ctx) error { rec = append(rec, "use"); return c.Next() })
|
||||
app.Use(zip.H(func(c *zip.Ctx) error { rec = append(rec, "use"); return c.Next() }))
|
||||
app.With(order(&rec, "with")).Get("/z", func(c *zip.Ctx) error {
|
||||
rec = append(rec, "handler")
|
||||
return c.String(200, "ok")
|
||||
|
||||
+2
-2
@@ -42,7 +42,7 @@ type OpenAPIConfig struct {
|
||||
// installOpenAPIRoutes wires /.well-known/openapi.json and /docs.
|
||||
// Called from Listen / Serve. Idempotent if there are no typed ops.
|
||||
func (a *App) installOpenAPIRoutes() {
|
||||
if a.cfg.OpenAPI.Disabled || len(a.registry) == 0 {
|
||||
if a.cfg.OpenAPI.Disabled || len(a.Registry()) == 0 {
|
||||
return
|
||||
}
|
||||
spec := a.buildOpenAPI()
|
||||
@@ -78,7 +78,7 @@ func (a *App) buildOpenAPI() map[string]any {
|
||||
reg := newSchemaRegistry(specDefs)
|
||||
|
||||
// Sort ops by path,method for deterministic output.
|
||||
ops := append([]*registeredOp{}, a.registry...)
|
||||
ops := append([]*registeredOp{}, a.Registry()...)
|
||||
sort.Slice(ops, func(i, j int) bool {
|
||||
if ops[i].Path != ops[j].Path {
|
||||
return ops[i].Path < ops[j].Path
|
||||
|
||||
@@ -117,9 +117,9 @@ func (a *App) metrics() string {
|
||||
b.WriteString("# TYPE zip_uptime_seconds gauge\nzip_uptime_seconds ")
|
||||
b.WriteString(strconv.FormatFloat(time.Since(a.born).Seconds(), 'f', 3, 64))
|
||||
b.WriteString("\n# HELP zip_ops Registered typed operations.\n# TYPE zip_ops gauge\nzip_ops ")
|
||||
b.WriteString(strconv.Itoa(len(a.registry)))
|
||||
b.WriteString(strconv.Itoa(len(a.Registry())))
|
||||
b.WriteString("\n# HELP zip_routes Registered routes.\n# TYPE zip_routes gauge\nzip_routes ")
|
||||
b.WriteString(strconv.Itoa(len(a.fiber.GetRoutes(false))))
|
||||
b.WriteString(strconv.Itoa(len(a.router().GetRoutes(false))))
|
||||
b.WriteString("\n")
|
||||
if st := a.Plugins(); len(st) > 0 {
|
||||
b.WriteString("# HELP zip_plugin_running 1 when a composed plugin has a live instance.\n")
|
||||
|
||||
@@ -47,7 +47,7 @@ func (a *App) Peer() *App {
|
||||
// plugin that answers no peer op should not create a socket other processes can
|
||||
// dial and get nothing from.
|
||||
func (a *App) servePeer() {
|
||||
if a.sibling || a.peer == nil || len(a.peer.registry) == 0 {
|
||||
if a.sibling || a.peer == nil || len(a.peer.Registry()) == 0 {
|
||||
return
|
||||
}
|
||||
if a.cfg.AppName == "" {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
)
|
||||
|
||||
// A remote service is a LEAF, not a verb.
|
||||
//
|
||||
// [App.Mount] used to be one of five composition verbs and the only one that
|
||||
// pointed at another process. Under one program it stops being special: it
|
||||
// appends an App like any other inclusion, whose routes proxy and whose ops
|
||||
// forward. Every projection then reads it through the same walk — a mounted
|
||||
// service appears in the OpenAPI document, the MCP tool list, the CLI commands
|
||||
// and the by-name call plane because it is in the registry, not because four
|
||||
// projections each learned about mounting.
|
||||
//
|
||||
// # The declaration is an INPUT, never a fetch
|
||||
//
|
||||
// The remote is described by a [Declaration] the caller supplies. Nothing here
|
||||
// dials at build time, and that is deliberate to the point of being the main
|
||||
// design constraint:
|
||||
//
|
||||
// - a walk that did I/O would make [App.Registry] fallible, slow and
|
||||
// untestable, and every projection downstream of it too;
|
||||
// - the OpenAPI document would depend on some other process being reachable
|
||||
// at boot, so a cold start in the wrong order publishes a smaller API;
|
||||
// - the seal could not mean anything, because what the program IS would
|
||||
// depend on what the network said at the moment it was asked.
|
||||
//
|
||||
// The spec is a build input; the server is not asked what it serves. Without a
|
||||
// declaration a mount contributes its two proxy addresses and no ops, which is
|
||||
// exactly what it contributed before and says honestly that nothing is known
|
||||
// about the shape behind it.
|
||||
func remoteApp(parent *App, prefix, addr string, d Declaration) (*App, error) {
|
||||
scheme, hostport, t, err := transportFor(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.Dial == nil {
|
||||
return nil, Errorf(500, "zip: transport %q cannot dial (serve-only)", scheme)
|
||||
}
|
||||
client := t.Dial(hostport)
|
||||
|
||||
cfg := parent.groupConfig()
|
||||
cfg.AppName = cmp.Or(d.Name, addr)
|
||||
r := newApp(cfg)
|
||||
|
||||
proxy := func(c *Ctx) error {
|
||||
return forward(c.fc.Request(), c.fc.Response(), client, hostport, "", "mount "+prefix)
|
||||
}
|
||||
if len(d.Routes) == 0 {
|
||||
// Nothing declared: the prefix and everything under it, which is what a
|
||||
// bare Mount has always registered.
|
||||
p := trimPrefix(prefix)
|
||||
r.All(p, proxy)
|
||||
r.All(p+"/*", proxy)
|
||||
return r, nil
|
||||
}
|
||||
// Declared: exactly the addresses the remote says it answers, so an
|
||||
// undeclared path falls through to the host instead of being swallowed.
|
||||
for _, rt := range d.Routes {
|
||||
if rt.Op == "" {
|
||||
r.method(rt.Method, rt.Pattern, []Handler{proxy})
|
||||
continue
|
||||
}
|
||||
r.addRoute(here(1), route{
|
||||
method: rt.Method,
|
||||
path: rt.Pattern,
|
||||
chain: []Handler{proxy},
|
||||
// The op the remote NAMED, forwarding by name over the same
|
||||
// transport the routes proxy over. It is a real registry entry, so
|
||||
// an MCP tools/call or a zip.Call on a mounted op reaches the
|
||||
// service that owns it rather than finding a hole where a projection
|
||||
// stopped at the process boundary.
|
||||
op: ®isteredOp{
|
||||
Method: rt.Method,
|
||||
Path: rt.Pattern,
|
||||
OperationID: rt.Op,
|
||||
Origin: cfg.AppName,
|
||||
invoke: remoteInvoke(client, hostport, rt.Method, rt.Pattern),
|
||||
},
|
||||
})
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// remoteInvoke runs one mounted op by calling the REST route the declaration
|
||||
// named for it.
|
||||
//
|
||||
// Not the remote's by-name call plane, which would have been the tidier
|
||||
// symmetry: that plane is ZAP end to end, and ZAP encodes STRUCTS. This side
|
||||
// has no struct — the remote owns the type, which is the entire reason this is
|
||||
// a mount and not a link — so the only value available here is a free-form map,
|
||||
// and `zapenc: map[string]interface {} is not a struct` is what asking for the
|
||||
// symmetry actually gets you.
|
||||
//
|
||||
// The declaration already carries the method and the pattern, so the REST route
|
||||
// is fully addressable from a build input, and JSON is the encoding that can
|
||||
// carry a value whose shape is not known on this side. It is also the boundary
|
||||
// encoding by design (HIP-0106: JSON at the edge, ZAP between services) and a
|
||||
// process boundary crossed without a shared type IS an edge.
|
||||
//
|
||||
// The rules it obeys are zip's own, read from the one place each lives: path
|
||||
// parameters are substituted out of the arguments, and hasBody decides whether
|
||||
// what remains rides as a body or as a query string.
|
||||
func remoteInvoke(client Client, host, method, pattern string) func(context.Context, decoder, []byte, map[string]string, map[string]string) (any, error) {
|
||||
return func(ctx context.Context, dec decoder, rawIn []byte, query, path map[string]string) (any, error) {
|
||||
args := map[string]any{}
|
||||
if len(rawIn) > 0 {
|
||||
if err := dec(rawIn, &args); err != nil {
|
||||
return nil, ErrBadRequest("invalid body: " + err.Error())
|
||||
}
|
||||
}
|
||||
for k, v := range query {
|
||||
args[k] = v
|
||||
}
|
||||
for k, v := range path {
|
||||
args[k] = v
|
||||
}
|
||||
|
||||
// The URL is the addressing authority, so a path parameter comes OUT of
|
||||
// the arguments and goes INTO the path — sending it in both places would
|
||||
// let the body contradict the address.
|
||||
url := pattern
|
||||
for _, name := range colonParams(pattern) {
|
||||
v, ok := args[name]
|
||||
if !ok {
|
||||
return nil, ErrBadRequest("missing path parameter: " + name)
|
||||
}
|
||||
url = strings.ReplaceAll(url, ":"+name, urlEscape(toStr(v)))
|
||||
delete(args, name)
|
||||
}
|
||||
|
||||
var body []byte
|
||||
if len(args) > 0 {
|
||||
b, err := jsonMarshal(args)
|
||||
if err != nil {
|
||||
return nil, ErrBadRequest("zip: encode " + pattern + ": " + err.Error())
|
||||
}
|
||||
if hasBody(method) {
|
||||
body = b
|
||||
} else if q, qerr := queryOf(b); qerr == nil && q != "" {
|
||||
url += "?" + q
|
||||
}
|
||||
}
|
||||
|
||||
req := fasthttp.AcquireRequest()
|
||||
resp := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
defer fasthttp.ReleaseResponse(resp)
|
||||
|
||||
req.SetRequestURI(url)
|
||||
req.SetHost(host)
|
||||
req.Header.SetMethod(method)
|
||||
if len(body) > 0 {
|
||||
req.SetBody(body)
|
||||
req.Header.SetContentType(fiber.MIMEApplicationJSON)
|
||||
}
|
||||
forwardIdentity(ctx, req)
|
||||
|
||||
if err := client.Do(req, resp); err != nil {
|
||||
return nil, Errorf(502, "zip: call %s %s at %s: %v", method, url, host, err)
|
||||
}
|
||||
switch code := resp.StatusCode(); {
|
||||
case code == fasthttp.StatusNoContent:
|
||||
return nil, nil
|
||||
case code < 200 || code > 299:
|
||||
return nil, remoteError(code, method+" "+url, resp.Body())
|
||||
}
|
||||
if len(resp.Body()) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// The bytes the remote wrote, handed on unchanged: re-decoding into a map
|
||||
// and re-encoding would round-trip every number through float64 and lose
|
||||
// the shape the remote chose.
|
||||
return json.RawMessage(append([]byte(nil), resp.Body()...)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// toStr renders one decoded argument for a URL. Only scalars address a
|
||||
// resource; anything else is a caller error the remote will reject with its own
|
||||
// message, which is more useful than a guess made here.
|
||||
func toStr(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
|
||||
// trimPrefix is the prefix as the router spells it: "/x/" and "/x" are one
|
||||
// address, and "" is the root.
|
||||
func trimPrefix(p string) string {
|
||||
return strings.TrimSuffix(normPath(p), "/")
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// remoteService starts a real zip app on a unix socket and returns its address
|
||||
// and its own declaration — the two things a host needs, and the second of
|
||||
// which it must be HANDED rather than go and ask for.
|
||||
func remoteService(t *testing.T) (string, Declaration) {
|
||||
t.Helper()
|
||||
svc := quiet("ledger")
|
||||
Get(svc, "/v1/ledger/entries/:id", func(_ context.Context, in *invoiceIn) (*invoiceOut, error) {
|
||||
return &invoiceOut{ID: in.ID, Total: 7}, nil
|
||||
}, WithOperationID("getEntry"))
|
||||
svc.Get("/v1/ledger/raw", func(c *Ctx) error { return c.String(200, "raw ledger") })
|
||||
|
||||
sock := filepath.Join(t.TempDir(), "ledger.sock")
|
||||
go func() { _ = svc.Listen(sock) }()
|
||||
t.Cleanup(func() { _ = svc.Shutdown() })
|
||||
for i := 0; i < 200; i++ {
|
||||
if svc.listening.Load() > 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
return sock, svc.Declaration()
|
||||
}
|
||||
|
||||
// TestMount_DeclarationIsAnInputNotAFetch is the rule the whole design hangs
|
||||
// on: building a program does no I/O. The address here goes nowhere, and the
|
||||
// registry, the document and the tool list are still complete — because the
|
||||
// declaration was handed in.
|
||||
//
|
||||
// If the walk dialled instead, this test could not exist: Registry() would be
|
||||
// fallible and slow, and an OpenAPI document would depend on some other process
|
||||
// being up at boot.
|
||||
func TestMount_DeclarationIsAnInputNotAFetch(t *testing.T) {
|
||||
host := quiet("cloud")
|
||||
dead := "/nonexistent/nothing-listens-here.sock"
|
||||
err := host.Mount("/v1/ledger", dead, Declaration{
|
||||
Name: "ledger",
|
||||
Routes: []Route{
|
||||
{Method: "GET", Pattern: "/v1/ledger/entries/:id", Op: "getEntry"},
|
||||
{Method: "GET", Pattern: "/v1/ledger/raw"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
if err := host.seal(here(0)); err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
|
||||
reg := host.Registry()
|
||||
if len(reg) != 1 {
|
||||
t.Fatalf("registry has %d ops, want 1 (the remote's declared op)", len(reg))
|
||||
}
|
||||
if reg[0].OperationID != "getEntry" || reg[0].Path != "/v1/ledger/entries/:id" {
|
||||
t.Errorf("remote op = %s %s, want getEntry /v1/ledger/entries/:id", reg[0].OperationID, reg[0].Path)
|
||||
}
|
||||
if reg[0].Origin != "ledger" {
|
||||
t.Errorf("remote op origin = %q, want ledger", reg[0].Origin)
|
||||
}
|
||||
// The document names it too — a mounted service is in the projections
|
||||
// because it is in the registry, not because the document learned to mount.
|
||||
spec := host.OpenAPISpec()
|
||||
if _, ok := spec["paths"].(map[string]map[string]any)["/v1/ledger/entries/{id}"]; !ok {
|
||||
t.Errorf("the document does not carry the mounted op")
|
||||
}
|
||||
// And nothing was dialled: an unreachable address built a complete program.
|
||||
}
|
||||
|
||||
// TestMount_ServesAndCallsTheRemote: the leaf's routes proxy and its ops
|
||||
// forward, over the same transport, to a service that really is somewhere else.
|
||||
func TestMount_ServesAndCallsTheRemote(t *testing.T) {
|
||||
sock, decl := remoteService(t)
|
||||
if len(decl.Routes) != 2 {
|
||||
t.Fatalf("the service declared %d routes, want 2: %+v", len(decl.Routes), decl.Routes)
|
||||
}
|
||||
var typedOp string
|
||||
for _, r := range decl.Routes {
|
||||
if r.Pattern == "/v1/ledger/entries/:id" {
|
||||
typedOp = r.Op
|
||||
}
|
||||
}
|
||||
if typedOp != "getEntry" {
|
||||
t.Fatalf("the declaration does not name the op for its typed route: %+v", decl.Routes)
|
||||
}
|
||||
|
||||
host := quiet("cloud")
|
||||
if err := host.Mount("/v1/ledger", sock, decl); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
host.Prepare()
|
||||
|
||||
// 1. The proxied route reaches the remote's own handler.
|
||||
if code, body := wireGET(t, host, "/v1/ledger/raw"); code != 200 || body != "raw ledger" {
|
||||
t.Errorf("proxied route: %d %q", code, body)
|
||||
}
|
||||
if code, body := wireGET(t, host, "/v1/ledger/entries/e-9"); code != 200 || !strings.Contains(body, "e-9") {
|
||||
t.Errorf("proxied typed route: %d %q", code, body)
|
||||
}
|
||||
// 2. An address the remote did NOT declare falls through to the host.
|
||||
if code, _ := wireGET(t, host, "/v1/ledger/undeclared"); code != 404 {
|
||||
t.Errorf("undeclared remote path answered %d, want 404 — the mount is swallowing", code)
|
||||
}
|
||||
// 3. The op forwards BY NAME, so the tool an agent sees runs the remote's
|
||||
// handler rather than finding a hole at the process boundary.
|
||||
out := rpcOn(t, host,
|
||||
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"getEntry","arguments":{"id":"e-3"}}}`)
|
||||
if !strings.Contains(out, "e-3") {
|
||||
t.Errorf("tools/call on a mounted op did not reach the remote: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func rpcOn(t *testing.T, a *App, body string) string {
|
||||
t.Helper()
|
||||
req := postReq("/mcp", "application/json", []byte(body))
|
||||
resp, err := a.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST /mcp: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
return readAll(t, resp.Body)
|
||||
}
|
||||
|
||||
func postReq(path, ctype string, body []byte) *http.Request {
|
||||
req := httptest.NewRequest("POST", path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ctype)
|
||||
return req
|
||||
}
|
||||
|
||||
func readAll(t *testing.T, r io.Reader) string {
|
||||
t.Helper()
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -46,7 +46,10 @@ type Router interface {
|
||||
// takes a Group as readily as it takes the App.
|
||||
OpTarget
|
||||
|
||||
Use(handlers ...Handler) Router
|
||||
// Use is the ONE composition verb, so it takes a [Component] — middleware,
|
||||
// or another App included by reference. It is the only signature that
|
||||
// widened; every route method below still takes ...Handler.
|
||||
Use(cs ...Component) Router
|
||||
|
||||
// Route registration takes ONE chain in wrapping order: zero or more
|
||||
// middleware first, the final handler LAST. fiber wants handler-first;
|
||||
@@ -60,93 +63,14 @@ type Router interface {
|
||||
Options(path string, handlers ...Handler) Router
|
||||
All(path string, handlers ...Handler) Router
|
||||
|
||||
Group(prefix string, handlers ...Handler) Router
|
||||
// Group returns the *App it creates, because a group IS an app with a
|
||||
// prefix — the same definition kind, included by reference like any other.
|
||||
// One mechanism covers "a scope" and "a sub-application"; frameworks that
|
||||
// grew them separately have two.
|
||||
Group(prefix string, handlers ...Handler) *App
|
||||
|
||||
// Fiber returns the underlying fiber.Router for one-off escape.
|
||||
Fiber() fiber.Router
|
||||
}
|
||||
|
||||
// routerAdapter wraps a fiber.Router (App or Group) and exposes the
|
||||
// zip-style Handler signature.
|
||||
type routerAdapter struct {
|
||||
r fiber.Router
|
||||
app *App
|
||||
}
|
||||
|
||||
func (a *routerAdapter) Use(handlers ...Handler) Router {
|
||||
for _, h := range handlers {
|
||||
a.r.Use(toFiberHandler(a.app, h))
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *routerAdapter) Get(path string, handlers ...Handler) Router {
|
||||
h, mw := splitChain(a.app, handlers)
|
||||
a.r.Get(normPath(path), h, mw...)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *routerAdapter) Post(path string, handlers ...Handler) Router {
|
||||
h, mw := splitChain(a.app, handlers)
|
||||
a.r.Post(normPath(path), h, mw...)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *routerAdapter) Put(path string, handlers ...Handler) Router {
|
||||
h, mw := splitChain(a.app, handlers)
|
||||
a.r.Put(normPath(path), h, mw...)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *routerAdapter) Patch(path string, handlers ...Handler) Router {
|
||||
h, mw := splitChain(a.app, handlers)
|
||||
a.r.Patch(normPath(path), h, mw...)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *routerAdapter) Delete(path string, handlers ...Handler) Router {
|
||||
h, mw := splitChain(a.app, handlers)
|
||||
a.r.Delete(normPath(path), h, mw...)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *routerAdapter) Head(path string, handlers ...Handler) Router {
|
||||
h, mw := splitChain(a.app, handlers)
|
||||
a.r.Head(normPath(path), h, mw...)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *routerAdapter) Options(path string, handlers ...Handler) Router {
|
||||
h, mw := splitChain(a.app, handlers)
|
||||
a.r.Options(normPath(path), h, mw...)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *routerAdapter) All(path string, handlers ...Handler) Router {
|
||||
h, mw := splitChain(a.app, handlers)
|
||||
a.r.All(normPath(path), h, mw...)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *routerAdapter) Group(prefix string, handlers ...Handler) Router {
|
||||
args := make([]any, 0, len(handlers))
|
||||
for _, h := range handlers {
|
||||
args = append(args, toFiberHandler(a.app, h))
|
||||
}
|
||||
g := a.r.Group(prefix, args...)
|
||||
return &routerAdapter{r: g, app: a.app}
|
||||
}
|
||||
|
||||
func (a *routerAdapter) Fiber() fiber.Router { return a.r }
|
||||
|
||||
// OpScope reads the prefix off the ROUTER rather than keeping a second copy of
|
||||
// it: fiber composes a nested group's prefix when the group is made, so asking
|
||||
// it is the one reading that cannot drift from where the routes actually land.
|
||||
func (a *routerAdapter) OpScope() OpScope {
|
||||
if g, ok := a.r.(*fiber.Group); ok {
|
||||
return OpScope{App: a.app, Prefix: g.Prefix}
|
||||
}
|
||||
return OpScope{App: a.app}
|
||||
// Fiber returns the underlying router for one-off escape.
|
||||
Fiber() *fiber.App
|
||||
}
|
||||
|
||||
// joinPath composes a group's prefix with a leaf path the way the router does,
|
||||
|
||||
+5
-1
@@ -60,7 +60,11 @@ func (a *App) shutdown(ctx context.Context) error {
|
||||
// 1. Stop accepting new connections on every transport listener.
|
||||
a.closeServers()
|
||||
// 2. Drain in-flight requests (graceful; bounded by ctx).
|
||||
errs := []error{a.fiber.ShutdownWithContext(ctx)}
|
||||
// A program that was never served has no router to drain.
|
||||
var errs []error
|
||||
if a.fiber != nil {
|
||||
errs = append(errs, a.fiber.ShutdownWithContext(ctx))
|
||||
}
|
||||
// 3. Tear subsystems down in reverse mount order (LIFO). Run ALL hooks
|
||||
// even if some fail; aggregate every error.
|
||||
for i := len(hooks) - 1; i >= 0; i-- {
|
||||
|
||||
+20
-6
@@ -86,22 +86,36 @@ func TestSpecificity_DeeperStaticBeatsShallowWildcard(t *testing.T) {
|
||||
}
|
||||
|
||||
// Two distinct, equally specific, unconstrained patterns are an ambiguous
|
||||
// overlap: registration must panic loudly, never silently shadow.
|
||||
func TestSpecificity_AmbiguousConflictPanics(t *testing.T) {
|
||||
// overlap: it must be refused loudly, never silently shadow.
|
||||
//
|
||||
// CHANGED BY THE LAZY MODEL, deliberately. This used to panic at the second
|
||||
// app.Get, because registration WAS insertion into the router. A route is now
|
||||
// an entry in a program and the router is a BUILD of that program, so the
|
||||
// refusal moves to the build — app.Fiber() here, App.Listen in a real one.
|
||||
//
|
||||
// The deferral is not an oversight and cannot be undone. An App may be included
|
||||
// in another App AFTER its routes are written, so at the moment of any single
|
||||
// registration the set of patterns it will have to coexist with is not yet
|
||||
// known; a check at registration time could only ever see part of the program.
|
||||
// Detecting it at build time is what makes the answer complete — and is the
|
||||
// same reason conflicting ADDRESSES are now reported all at once with both
|
||||
// claimants named, instead of one per build.
|
||||
func TestSpecificity_AmbiguousConflictRefusedAtBuild(t *testing.T) {
|
||||
app := specApp()
|
||||
app.Get("/x/:id", mark("id"))
|
||||
app.Get("/x/:name", mark("name")) // no longer panics HERE
|
||||
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatal("registering /x/:name after /x/:id did not panic")
|
||||
t.Fatal("building a router with /x/:name over /x/:id did not panic")
|
||||
}
|
||||
msg, _ := r.(string)
|
||||
if !strings.Contains(msg, "route conflict") {
|
||||
t.Fatalf("panic %q, want it to name the route conflict", r)
|
||||
}
|
||||
}()
|
||||
app.Get("/x/:name", mark("name"))
|
||||
app.Fiber()
|
||||
}
|
||||
|
||||
// Same pattern, different methods: no conflict — method stacks are independent.
|
||||
@@ -120,8 +134,8 @@ func TestSpecificity_MethodsIndependent(t *testing.T) {
|
||||
func TestSpecificity_MiddlewareOrderPreserved(t *testing.T) {
|
||||
app := specApp()
|
||||
var order []string
|
||||
app.Use(func(c *zip.Ctx) error { order = append(order, "mw1"); return c.Next() })
|
||||
app.Use(func(c *zip.Ctx) error { order = append(order, "mw2"); return c.Next() })
|
||||
app.Use(zip.H(func(c *zip.Ctx) error { order = append(order, "mw1"); return c.Next() }))
|
||||
app.Use(zip.H(func(c *zip.Ctx) error { order = append(order, "mw2"); return c.Next() }))
|
||||
app.Get("/z", func(c *zip.Ctx) error {
|
||||
order = append(order, "handler")
|
||||
return c.JSON(200, map[string]string{"via": "z"})
|
||||
|
||||
+1
-1
@@ -11,5 +11,5 @@ func (a *App) TestCtx(method, path string) *Ctx {
|
||||
fctx := &fasthttp.RequestCtx{}
|
||||
fctx.Request.Header.SetMethod(method)
|
||||
fctx.Request.URI().SetPath(path)
|
||||
return &Ctx{fc: a.fiber.AcquireCtx(fctx), app: a, log: a.logger}
|
||||
return &Ctx{fc: a.router().AcquireCtx(fctx), app: a, log: a.logger}
|
||||
}
|
||||
|
||||
+28
-5
@@ -164,7 +164,15 @@ func (a *App) Listen(addrs ...string) error {
|
||||
return fmt.Errorf("zip: Listen needs at least one address")
|
||||
}
|
||||
a.prepare()
|
||||
h := a.fiber.Handler()
|
||||
// Composition ends HERE — at the one call that begins runtime execution, and
|
||||
// nowhere else. Sealing propagates across the whole reachable graph, so a
|
||||
// child cannot be edited out from under a server that has already published
|
||||
// what it serves. Everything up to this line was inspection; nothing before
|
||||
// it sealed anything.
|
||||
if err := a.seal(here(1)); err != nil {
|
||||
return err
|
||||
}
|
||||
h := a.router().Handler()
|
||||
|
||||
servers := make([]Server, 0, len(addrs))
|
||||
for _, raw := range addrs {
|
||||
@@ -238,12 +246,23 @@ func (a *App) Listen(addrs ...string) error {
|
||||
// link time does not grow when that service does, and the service redeploys
|
||||
// without relinking it. Moving a service between Mount and a linked-in route
|
||||
// tree is a deployment decision, not a code change.
|
||||
func (a *App) Mount(prefix, addr string) error {
|
||||
func (a *App) Mount(prefix, addr string, decl ...Declaration) error {
|
||||
// A bare Mount is its own claimant, and the address is the only name it has.
|
||||
if err := a.claim(prefix, addr); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.mount(prefix, addr)
|
||||
var d Declaration
|
||||
if len(decl) > 0 {
|
||||
d = decl[0]
|
||||
}
|
||||
leaf, err := remoteApp(a, prefix, addr, d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.logger.Info("zip mounting", "prefix", prefix, "addr", addr,
|
||||
"routes", len(d.Routes), "ops", len(leaf.Registry()))
|
||||
a.appendEntry(entry{n: leaf, site: here(1)})
|
||||
return nil
|
||||
}
|
||||
|
||||
// mount registers prefix onto a dialled address. The prefix is already CLAIMED
|
||||
@@ -279,8 +298,12 @@ func (a *App) mountVia(prefix string, to func() (Client, string)) {
|
||||
return forward(c.fc.Request(), c.fc.Response(), client, host, "", "mount "+prefix)
|
||||
}
|
||||
prefix = strings.TrimSuffix(normPath(prefix), "/")
|
||||
a.All(prefix, h)
|
||||
a.All(prefix+"/*", h)
|
||||
site := here(1)
|
||||
// runtimeEntry, because a plugin may be loaded AFTER Listen: a runtime mount
|
||||
// is a deployment event, not a program edit, so it goes onto the live router
|
||||
// without reopening a sealed program.
|
||||
a.runtimeEntry(entry{n: route{method: methodAll, path: prefix, chain: []Handler{h}}, site: site})
|
||||
a.runtimeEntry(entry{n: route{method: methodAll, path: prefix + "/*", chain: []Handler{h}}, site: site})
|
||||
}
|
||||
|
||||
// forward is THE proxy hop: the inbound fasthttp request object is handed to the
|
||||
|
||||
@@ -279,8 +279,6 @@ func registerTyped[In, Out any](on OpTarget, method, path string, fn TypedHandle
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
app.registry = append(app.registry, op)
|
||||
|
||||
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
|
||||
@@ -326,5 +324,9 @@ func registerTyped[In, Out any](on OpTarget, method, path string, fn TypedHandle
|
||||
core := handler
|
||||
handler = toFiberHandler(app, scope.Middleware(func(c *Ctx) error { return core(c.fc) }))
|
||||
}
|
||||
app.fiber.Add([]string{method}, path, handler)
|
||||
// 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), route{method: method, path: path, serve: handler, op: op})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The walk is the contract.
|
||||
//
|
||||
// ONE function flattens a composition into an ordered slice of OCCURRENCES —
|
||||
// one event per entry visit, in preorder, with the full path context — and
|
||||
// every projection and every diagnostic in this package is a REDUCER over that
|
||||
// slice. The router, the OpenAPI document, the MCP tool list, the CLI commands,
|
||||
// the by-name call plane, the conflict report and the staged-composition lint
|
||||
// all read the same slice. None of them walks the tree itself; none of them
|
||||
// keeps traversal logic of its own.
|
||||
//
|
||||
// That is the entire reason this design has one composition verb and the old
|
||||
// one had five. Graft existed because the op registry was computed EAGERLY, at
|
||||
// compose time, so composing two apps meant merging two registries and every
|
||||
// projection needed its own merge rule. A projection over a walk needs no merge
|
||||
// rule at all.
|
||||
//
|
||||
// Properties the reducers are entitled to rely on:
|
||||
//
|
||||
// - DEFINITION IDENTITY IS POINTER IDENTITY. Sealing makes a definition
|
||||
// immutable, so a pointer can never come to name different content, so
|
||||
// nothing needs a synthetic id. It is load-bearing only for *App and for
|
||||
// ops; nothing dedupes middleware, which is also why [Handler] being a
|
||||
// non-comparable func type costs nothing.
|
||||
// - PURE. No global state, no projection-specific logic, no I/O. In
|
||||
// particular a remote [App.Mount] contributes its declaration INLINE and is
|
||||
// never asked over the network what it serves: a walk that did I/O would
|
||||
// make the document fallible, slow, untestable, and a function of whether
|
||||
// some other process happened to be up at boot.
|
||||
// - EAGER, NOT LAZY. Materialised once at seal under sync.Once; before seal
|
||||
// every inspection recomputes. Cycle and conflict detection therefore
|
||||
// complete BEFORE any reducer runs, so no reducer handles errors mid-loop.
|
||||
// - DETERMINISTIC. Same program, identical slice, identical output.
|
||||
// - APPEND-STABLE. Appending an entry cannot reorder occurrences inside
|
||||
// previously composed subtrees: compose A then B then C and the order
|
||||
// within A and within B is untouched. This is what keeps a generated SDK,
|
||||
// an OpenAPI document and a CLI diff-stable as a program grows. It is an
|
||||
// ORDER guarantee only — appending C can still change VALIDITY, because C's
|
||||
// patterns may collide with A's and fail the build.
|
||||
//
|
||||
// Pre-seal inspection is explicitly NOT goroutine-safe and does not try to be.
|
||||
// A program under construction has one writer by definition; adding a mutex
|
||||
// would buy nothing and would put a lock on the served path.
|
||||
|
||||
// occurrence is ONE event: one entry, of one definition, visited in one place.
|
||||
//
|
||||
// The definition is shared; the occurrence is not. A definition included from
|
||||
// two places yields two occurrences of every entry it holds, each with its own
|
||||
// prefix, middleware stack and breadcrumb — which is exactly why the projections
|
||||
// can key SURFACE on the occurrence and TYPES on the definition without either
|
||||
// choice leaking into the walk.
|
||||
type occurrence struct {
|
||||
// n is the payload: [Handler], [route] or [*App]. The three kinds.
|
||||
n node
|
||||
// in is the definition whose entry list this event came from — the app that
|
||||
// WROTE the entry, which is never the same question as where it now runs.
|
||||
in *App
|
||||
// site is where the entry was written.
|
||||
site callsite
|
||||
// ctx is where this event sits in the composition.
|
||||
ctx scope
|
||||
}
|
||||
|
||||
// isMiddleware / isRoute / isApp read the kind. A type switch is the normal
|
||||
// way; these exist for reducers that want one kind and no switch.
|
||||
func (o occurrence) route() (route, bool) { r, ok := o.n.(route); return r, ok }
|
||||
func (o occurrence) app() (*App, bool) { a, ok := o.n.(*App); return a, ok }
|
||||
|
||||
// abs is the absolute path this occurrence answers at: the definition's own
|
||||
// path composed with the prefix accumulated along the ancestor chain.
|
||||
func (o occurrence) abs(path string) string { return joinPath(o.ctx.prefix, path) }
|
||||
|
||||
// scope is an occurrence's path context: everything that is a property of WHERE
|
||||
// rather than of WHAT.
|
||||
type scope struct {
|
||||
// prefix is the concatenated prefix of every App on the ancestor chain.
|
||||
prefix string
|
||||
// mw is the middleware stack in force — inherited at the inclusion site,
|
||||
// plus the middleware entries preceding this one at its own level. It is a
|
||||
// PERSISTENT stack: pushing allocates one cell, and descending into an
|
||||
// included App copies a pointer rather than a slice, so a definition
|
||||
// referenced from N places does not cost N copies of its environment.
|
||||
mw *mwStack
|
||||
// trail is the composition breadcrumb, likewise persistent.
|
||||
trail *trail
|
||||
// depth is the number of inclusions between the root and here.
|
||||
depth int
|
||||
}
|
||||
|
||||
// mwStack is a persistent middleware stack, innermost cell first, shared
|
||||
// structurally between every occurrence that inherits it.
|
||||
type mwStack struct {
|
||||
h Handler
|
||||
site callsite
|
||||
depth int // the inclusion depth this cell was DECLARED at
|
||||
up *mwStack
|
||||
n int
|
||||
}
|
||||
|
||||
func (s *mwStack) push(h Handler, site callsite, depth int) *mwStack {
|
||||
n := 1
|
||||
if s != nil {
|
||||
n = s.n + 1
|
||||
}
|
||||
return &mwStack{h: h, site: site, depth: depth, up: s, n: n}
|
||||
}
|
||||
|
||||
func (s *mwStack) len() int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
return s.n
|
||||
}
|
||||
|
||||
// included returns the cells declared INSIDE an included definition
|
||||
// (depth > 0), outermost-first. Those are the ones materialisation composes
|
||||
// into a route's own chain instead of registering as router middleware — see
|
||||
// [App.materialise]. Cells at depth 0 belong to the app being served and stay
|
||||
// router middleware, so a single-app program's behaviour is untouched.
|
||||
func (s *mwStack) included() []Handler {
|
||||
n := 0
|
||||
for c := s; c != nil; c = c.up {
|
||||
if c.depth > 0 {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Handler, n)
|
||||
for c, i := s, n-1; c != nil; c = c.up {
|
||||
if c.depth > 0 {
|
||||
out[i] = c.h
|
||||
i--
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trail is a persistent composition breadcrumb: root → plugin → group.
|
||||
type trail struct {
|
||||
label string
|
||||
site callsite
|
||||
up *trail
|
||||
n int
|
||||
}
|
||||
|
||||
func (t *trail) push(label string, site callsite) *trail {
|
||||
n := 1
|
||||
if t != nil {
|
||||
n = t.n + 1
|
||||
}
|
||||
return &trail{label: label, site: site, up: t, n: n}
|
||||
}
|
||||
|
||||
// String renders the breadcrumb root-first.
|
||||
func (t *trail) String() string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, t.n)
|
||||
for i := t.n - 1; t != nil; t = t.up {
|
||||
parts[i] = t.label
|
||||
i--
|
||||
}
|
||||
return strings.Join(parts, " → ")
|
||||
}
|
||||
|
||||
// walk flattens a composition into its occurrences and validates it.
|
||||
//
|
||||
// It returns the occurrences AND the error, always both: a conflict does not
|
||||
// stop enumeration (every projection is still computable, and refusing to
|
||||
// enumerate would hide the other conflicts), while a cycle does, because there
|
||||
// is nothing to enumerate past it.
|
||||
func walk(root *App) ([]occurrence, error) {
|
||||
out := make([]occurrence, 0, 16)
|
||||
sc := scope{trail: (*trail)(nil).push(nameOr(root, "root"), callsite{})}
|
||||
if err := descend(root, sc, nil, &out); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, conflicts(out)
|
||||
}
|
||||
|
||||
// descend is the one type switch over the closed node set. Every semantic this
|
||||
// package claims about composition is a readable property of this function
|
||||
// rather than behaviour smeared across five verbs:
|
||||
//
|
||||
// - middleware accumulates as it is met, so an entry's environment is what
|
||||
// preceded it AT ITS LEVEL (snapshot semantics, clause (b));
|
||||
// - an included App is visited with the stack as it stood AT THE INCLUSION
|
||||
// SITE, so parent entries written later cannot reach it (clause (a));
|
||||
// - the prefix composes down the ancestor chain, so one definition under two
|
||||
// prefixes answers at two absolute paths;
|
||||
// - the ancestor set is the cycle check, so `a.Use(b); b.Use(a)` is an error
|
||||
// with a breadcrumb instead of a hang.
|
||||
func descend(a *App, sc scope, ancestors []*App, out *[]occurrence) error {
|
||||
for _, up := range ancestors {
|
||||
if up == a {
|
||||
return fmt.Errorf("zip: cycle: %s — an App cannot include itself, "+
|
||||
"directly or through a chain", sc.trail.String())
|
||||
}
|
||||
}
|
||||
ancestors = append(ancestors, a)
|
||||
|
||||
mw := sc.mw
|
||||
for _, e := range a.entries {
|
||||
switch n := e.n.(type) {
|
||||
case Handler:
|
||||
mw = mw.push(n, e.site, sc.depth)
|
||||
*out = append(*out, occurrence{n: n, in: a, site: e.site,
|
||||
ctx: scope{prefix: sc.prefix, mw: mw, trail: sc.trail, depth: sc.depth}})
|
||||
|
||||
case route:
|
||||
*out = append(*out, occurrence{n: n, in: a, site: e.site,
|
||||
ctx: scope{prefix: sc.prefix, mw: mw, trail: sc.trail, depth: sc.depth}})
|
||||
|
||||
case *App:
|
||||
// The included app's environment is anchored HERE. Everything the
|
||||
// parent appends after this line is invisible to it, and everything
|
||||
// the child appends later still lands inside this anchor.
|
||||
inner := scope{
|
||||
prefix: sc.prefix + n.prefix,
|
||||
mw: mw,
|
||||
trail: sc.trail.push(n.label(), e.site),
|
||||
depth: sc.depth + 1,
|
||||
}
|
||||
*out = append(*out, occurrence{n: n, in: a, site: e.site, ctx: inner})
|
||||
if err := descend(n, inner, ancestors, out); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// conflicts reports EVERY duplicated address, not the first.
|
||||
//
|
||||
// Failing fast would report one collision per build, so composing two apps that
|
||||
// disagree about three paths takes three builds to learn three facts. And the
|
||||
// walk is the only place with enough context to name both parties: it sees the
|
||||
// whole set symmetrically, so it can say which two definitions claimed an
|
||||
// address and where each was written. Eager composition could only ever
|
||||
// attribute a collision to whichever app happened to compose SECOND, which is
|
||||
// wiring-file line order and means nothing.
|
||||
func conflicts(occ []occurrence) error {
|
||||
type claim struct {
|
||||
by string
|
||||
site callsite
|
||||
via string
|
||||
}
|
||||
held := make(map[string]claim, len(occ))
|
||||
var errs []error
|
||||
for _, o := range occ {
|
||||
r, ok := o.route()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key := addrKey(r.method, o.abs(r.path))
|
||||
mine := claim{by: o.in.label(), site: o.site, via: o.ctx.trail.String()}
|
||||
if prev, dup := held[key]; dup {
|
||||
errs = append(errs, fmt.Errorf("zip: %s: declared by %q at %s (via %s) and by %q at %s (via %s)",
|
||||
key, prev.by, prev.site, prev.via, mine.by, mine.site, mine.via))
|
||||
continue
|
||||
}
|
||||
held[key] = mine
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// nameOr is an app's name, or fallback when it has none.
|
||||
func nameOr(a *App, fallback string) string {
|
||||
if a.cfg.AppName != "" {
|
||||
return a.cfg.AppName
|
||||
}
|
||||
if a.prefix != "" {
|
||||
return a.prefix
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// occurrenceID is an op's id AT ONE OCCURRENCE: the declared id qualified by the
|
||||
// prefix that occurrence answers under.
|
||||
//
|
||||
// This is the landmine the lazy model steps on and the eager one never reached.
|
||||
// A definition included twice declares ONE id and produces TWO operations, and
|
||||
// an OpenAPI document with two operations under one operationId is invalid.
|
||||
// The qualification must therefore be deterministic and derived from the
|
||||
// composition's SHAPE:
|
||||
//
|
||||
// /v1/billing + listInvoices -> v1.billing.listInvoices
|
||||
// /admin/billing + listInvoices -> admin.billing.listInvoices
|
||||
//
|
||||
// Never positional. "First occurrence wins" and "append -2" both make the
|
||||
// generated output a function of MOUNT ORDER, so reordering two Use calls in a
|
||||
// wiring file becomes a breaking change in every published SDK. This rule binds
|
||||
// every downstream artifact — document, tool name, command, call-plane name —
|
||||
// because they all read the same id.
|
||||
//
|
||||
// An occurrence at the root prefix is unqualified, so an app that composes
|
||||
// nothing publishes exactly the ids it declared, byte for byte.
|
||||
func occurrenceID(prefix, id string) string {
|
||||
d := dotted(prefix)
|
||||
if d == "" {
|
||||
return id
|
||||
}
|
||||
return d + "." + id
|
||||
}
|
||||
|
||||
// dotted turns a path prefix into an id qualifier: "/v1/billing" -> "v1.billing".
|
||||
// Path-parameter and wildcard punctuation is dropped rather than encoded — a
|
||||
// prefix with a parameter in it names a family of deployments, and an id must
|
||||
// name one method.
|
||||
func dotted(prefix string) string {
|
||||
var b strings.Builder
|
||||
for _, seg := range strings.Split(prefix, "/") {
|
||||
seg = strings.Trim(seg, ":*{}")
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('.')
|
||||
}
|
||||
for _, r := range seg {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -152,12 +152,44 @@ type App struct {
|
||||
cfg Config
|
||||
logger luxlog.Logger
|
||||
loader Loader
|
||||
fiber *fiber.App
|
||||
// registry is THE op registry — the one entry a typed op makes, from which
|
||||
// the OpenAPI document, the MCP tool, the CLI command and the plane op are
|
||||
// all projected. Four documents, one registration.
|
||||
registry []*registeredOp
|
||||
servers []Server // the running transport listeners, set by Listen
|
||||
|
||||
// The PROGRAM. entries is this app's ordered composition — middleware,
|
||||
// routes and other Apps included by reference — and prefix is the path this
|
||||
// app answers under when it is included somewhere ([App.Group] sets it).
|
||||
// There is no op-registry field: the registry is a PROJECTION over a walk of
|
||||
// this list (see [App.Registry]), which is what let one verb replace five.
|
||||
prefix string
|
||||
entries []entry
|
||||
|
||||
// The BUILD. fiber is materialised from entries, lazily, and rebuilt when
|
||||
// the program has moved since builtAt. buildMu guards both: materialising is
|
||||
// a WRITE performed on a read path, and inspecting a router is something
|
||||
// several goroutines legitimately do at once.
|
||||
fiber *fiber.App
|
||||
builtAt uint64
|
||||
buildMu sync.Mutex
|
||||
// control_ is zip's own projection routes. They are part of the BUILD, not
|
||||
// of the program — see [App.installControl].
|
||||
control_ []route
|
||||
|
||||
// The SEAL. Mutable until execution begins, immutable forever after, across
|
||||
// the whole reachable graph. planned/reg are the memoised projections, safe
|
||||
// to read from serving goroutines precisely because the program can no
|
||||
// longer change underneath them.
|
||||
sealed atomic.Bool
|
||||
sealSite callsite
|
||||
planOnce sync.Once
|
||||
planned []occurrence
|
||||
planErr error
|
||||
regOnce sync.Once
|
||||
reg []*registeredOp
|
||||
|
||||
// wrap is the Middleware a scoped [App.With] installed on this App (see
|
||||
// wrapRouter.Group). nil for every ordinary App, and the common case pays
|
||||
// nothing for it.
|
||||
wrap Middleware
|
||||
|
||||
servers []Server // the running transport listeners, set by Listen
|
||||
|
||||
// authorizer, when set via Authorize, runs at every typed op's invoke seam
|
||||
// on the decoded In — the one place REST and MCP both funnel the value the
|
||||
@@ -236,6 +268,19 @@ type App struct {
|
||||
// New constructs an App with the given config. Defaults are applied
|
||||
// for any zero-valued field.
|
||||
func New(cfg Config) *App {
|
||||
a := newApp(cfg)
|
||||
a.logger.Info("zip new", "json_variant", jsonenc.Variant)
|
||||
return a
|
||||
}
|
||||
|
||||
// newApp is New without the line in the log. [App.Group] builds an App per
|
||||
// scope, and a group is a lexical construct rather than a deployment, so it
|
||||
// does not announce itself.
|
||||
//
|
||||
// No router is constructed here. A router is a BUILD of the program, and the
|
||||
// program is empty — materialising one now would mean materialising one per
|
||||
// group, and rebuilding every one of them the moment anything is appended.
|
||||
func newApp(cfg Config) *App {
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = luxlog.New("module", "zip")
|
||||
}
|
||||
@@ -245,51 +290,31 @@ func New(cfg Config) *App {
|
||||
if cfg.ServerHeader == "" {
|
||||
cfg.ServerHeader = "zip"
|
||||
}
|
||||
|
||||
fcfg := fiber.Config{
|
||||
AppName: cfg.AppName,
|
||||
BodyLimit: cfg.BodyLimit,
|
||||
// Route every Fiber JSON path through zip's jsonenc package: this
|
||||
// covers c.JSON(), c.Bind().Body(), and the default error
|
||||
// handler when it serializes HTTPError. With GOEXPERIMENT=jsonv2
|
||||
// the underlying impl is encoding/json/v2; otherwise it falls
|
||||
// back to encoding/json. Same call site, different bytes-out.
|
||||
JSONEncoder: jsonenc.Marshal,
|
||||
JSONDecoder: jsonenc.Unmarshal,
|
||||
// Per-conn scale knobs from SCALE_STANDARD.md §6 — zero values
|
||||
// fall through to fasthttp defaults (256k concurrent / 4 KiB
|
||||
// read+write buffers).
|
||||
Concurrency: cfg.Concurrency,
|
||||
ReadBufferSize: cfg.ReadBufferSize,
|
||||
WriteBufferSize: cfg.WriteBufferSize,
|
||||
}
|
||||
if cfg.ServerHeader != "-" {
|
||||
fcfg.ServerHeader = cfg.ServerHeader
|
||||
}
|
||||
if cfg.ErrorHandler != nil {
|
||||
fcfg.ErrorHandler = cfg.ErrorHandler
|
||||
} else {
|
||||
fcfg.ErrorHandler = errorHandler
|
||||
}
|
||||
|
||||
cfg.Logger.Info("zip new", "json_variant", jsonenc.Variant)
|
||||
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
logger: cfg.Logger,
|
||||
loader: cfg.Loader,
|
||||
fiber: fiber.New(fcfg),
|
||||
born: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// OpScope makes the App itself a place a typed op can be declared — the root,
|
||||
// with no prefix and no middleware wrapped around the handler.
|
||||
func (a *App) OpScope() OpScope { return OpScope{App: a} }
|
||||
// jsonMarshal / jsonUnmarshal route every fiber JSON path through zip's jsonenc
|
||||
// package: c.JSON(), c.Bind().Body(), and the default error handler when it
|
||||
// serialises an HTTPError. With GOEXPERIMENT=jsonv2 the underlying impl is
|
||||
// encoding/json/v2; otherwise encoding/json. Same call site, different
|
||||
// bytes-out. Named here so [App.fiberConfig] is the one place that says it.
|
||||
var (
|
||||
jsonMarshal = jsonenc.Marshal
|
||||
jsonUnmarshal = jsonenc.Unmarshal
|
||||
)
|
||||
|
||||
// Fiber returns the underlying *fiber.App. Use for one-off escape into
|
||||
// Fiber-only APIs (rare). Prefer staying on the zip surface.
|
||||
func (a *App) Fiber() *fiber.App { return a.fiber }
|
||||
// Fiber returns the underlying *fiber.App, materialising the program if it has
|
||||
// changed since the last build. Use for one-off escape into Fiber-only APIs
|
||||
// (rare). Prefer staying on the zip surface.
|
||||
//
|
||||
// It does NOT seal: inspecting the router is not executing it, and a test or a
|
||||
// codegen step that looks must not turn the next legitimate Use into a panic.
|
||||
func (a *App) Fiber() *fiber.App { return a.router() }
|
||||
|
||||
// Logger returns the App's logger.
|
||||
func (a *App) Logger() luxlog.Logger { return a.logger }
|
||||
@@ -309,15 +334,6 @@ func (a *App) ShutdownWithContext(ctx context.Context) error {
|
||||
return a.shutdown(ctx)
|
||||
}
|
||||
|
||||
// Use registers zip-style middleware. Each Handler runs in order; calling
|
||||
// c.Next() (via c.Continue) chains to the next handler.
|
||||
func (a *App) Use(handlers ...Handler) Router {
|
||||
for _, h := range handlers {
|
||||
a.fiber.Use(toFiberHandler(a, h))
|
||||
}
|
||||
return &routerAdapter{r: a.fiber, app: a}
|
||||
}
|
||||
|
||||
// With returns a Router whose subsequent leaf registrations (Get/Post/…/All)
|
||||
// have mw wrapped around the handler at registration time — pure
|
||||
// composition (RateLimit(CSRF(handler))). It does NOT touch the global Use
|
||||
@@ -327,14 +343,15 @@ func (a *App) Use(handlers ...Handler) Router {
|
||||
//
|
||||
// app.With(RateLimit, CSRF).Post("/v1/keys", mintKey)
|
||||
func (a *App) With(mw ...Middleware) Router {
|
||||
return &wrapRouter{
|
||||
inner: &routerAdapter{r: a.fiber, app: a},
|
||||
wrap: Chain(mw...),
|
||||
}
|
||||
return &wrapRouter{inner: a, wrap: Chain(mw...)}
|
||||
}
|
||||
|
||||
// Get / Post / Put / Patch / Delete / Head / Options / All register routes.
|
||||
// Chains are in wrapping order: middleware first, the final handler last.
|
||||
//
|
||||
// These still take ...Handler and always will. A bare closure written inline is
|
||||
// a *Handler by conversion, so nothing about route registration changes when
|
||||
// composition widens — [zip.H] is needed only at [App.Use].
|
||||
func (a *App) Get(path string, handlers ...Handler) Router { return a.method("GET", path, handlers) }
|
||||
func (a *App) Post(path string, handlers ...Handler) Router { return a.method("POST", path, handlers) }
|
||||
func (a *App) Put(path string, handlers ...Handler) Router { return a.method("PUT", path, handlers) }
|
||||
@@ -351,28 +368,31 @@ func (a *App) Options(path string, handlers ...Handler) Router {
|
||||
|
||||
// All registers a handler for any HTTP method.
|
||||
func (a *App) All(path string, handlers ...Handler) Router {
|
||||
h, mw := splitChain(a, handlers)
|
||||
a.fiber.All(normPath(path), h, mw...)
|
||||
return &routerAdapter{r: a.fiber, app: a}
|
||||
return a.method(methodAll, path, handlers)
|
||||
}
|
||||
|
||||
func (a *App) method(method, path string, handlers []Handler) Router {
|
||||
h, mw := splitChain(a, handlers)
|
||||
a.fiber.Add([]string{method}, normPath(path), h, mw...)
|
||||
return &routerAdapter{r: a.fiber, app: a}
|
||||
// A scoped [App.With] lives on the App itself, so a leaf registered on this
|
||||
// scope at any time is wrapped — including one registered after the scope was
|
||||
// handed out. A decorator could only wrap what passed through it.
|
||||
if len(handlers) == 0 {
|
||||
panic("zip: route registered with no handler")
|
||||
}
|
||||
if a.wrap != nil {
|
||||
handlers = append([]Handler(nil), handlers...)
|
||||
handlers[len(handlers)-1] = a.wrap(handlers[len(handlers)-1])
|
||||
} else {
|
||||
handlers = append([]Handler(nil), handlers...)
|
||||
}
|
||||
a.addRoute(here(2), route{method: method, path: normPath(path), chain: handlers})
|
||||
return a
|
||||
}
|
||||
|
||||
// Group creates a path-prefixed router group. The returned Router is the one
|
||||
// way to register nested routes under a prefix — register leaves and further
|
||||
// Groups directly on it; middleware scoped to the group goes on via its Use.
|
||||
func (a *App) Group(prefix string, handlers ...Handler) Router {
|
||||
args := make([]any, 0, len(handlers))
|
||||
for _, h := range handlers {
|
||||
args = append(args, toFiberHandler(a, h))
|
||||
}
|
||||
g := a.fiber.Group(prefix, args...)
|
||||
return &routerAdapter{r: g, app: a}
|
||||
}
|
||||
// OpScope makes the App itself a place a typed op can be declared. The prefix
|
||||
// is not reported here: a group's prefix is a property of WHERE the group is
|
||||
// included, and one definition may be included in two places, so the absolute
|
||||
// path is computed by the walk and never baked into the op.
|
||||
func (a *App) OpScope() OpScope { return OpScope{App: a, Middleware: a.wrap} }
|
||||
|
||||
// errors.As helper for HTTPError unwrapping in tests / external callers.
|
||||
func asHTTPError(err error) (*HTTPError, bool) {
|
||||
|
||||
+5
-5
@@ -127,9 +127,9 @@ func TestGroup(t *testing.T) {
|
||||
func TestOneCtxPerRequest(t *testing.T) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
var seen []*zip.Ctx
|
||||
app.Use(func(c *zip.Ctx) error { seen = append(seen, c); return c.Continue() })
|
||||
app.Use(zip.H(func(c *zip.Ctx) error { seen = append(seen, c); return c.Continue() }))
|
||||
v1 := app.Group("/v1")
|
||||
v1.Use(func(c *zip.Ctx) error { seen = append(seen, c); return c.Continue() })
|
||||
v1.Use(zip.H(func(c *zip.Ctx) error { seen = append(seen, c); return c.Continue() }))
|
||||
v1.Get("/ping", func(c *zip.Ctx) error {
|
||||
seen = append(seen, c)
|
||||
return c.NoContent(204)
|
||||
@@ -170,11 +170,11 @@ func TestOneCtxPerRequest(t *testing.T) {
|
||||
func TestSetLogReachesDownstream(t *testing.T) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
var scoped, downstream luxlog.Logger
|
||||
app.Use(func(c *zip.Ctx) error {
|
||||
app.Use(zip.H(func(c *zip.Ctx) error {
|
||||
scoped = c.Log().New("request_id", "rid-42")
|
||||
c.SetLog(scoped)
|
||||
return c.Continue()
|
||||
})
|
||||
}))
|
||||
app.Get("/x", func(c *zip.Ctx) error {
|
||||
downstream = c.Log()
|
||||
return c.NoContent(204)
|
||||
@@ -204,7 +204,7 @@ func TestServePathAllocsAreChainDepthInvariant(t *testing.T) {
|
||||
for _, mw := range []int{0, 1, 5} {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true, ServerHeader: "-"})
|
||||
for i := 0; i < mw; i++ {
|
||||
app.Use(func(c *zip.Ctx) error { return c.Continue() })
|
||||
app.Use(zip.H(func(c *zip.Ctx) error { return c.Continue() }))
|
||||
}
|
||||
app.Get("/v1/health", func(c *zip.Ctx) error { return c.NoContent(204) })
|
||||
h := app.Fiber().Handler()
|
||||
|
||||
Reference in New Issue
Block a user