Files

374 lines
15 KiB
Go

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.
// MethodAll is the method [App.Raw] and [Group.Raw] take to answer a path
// whatever the verb — the rare case a proxy, a catch-all or a legacy mount
// needs. Every other method is its own name, as net/http spells it.
const MethodAll = "ALL"
// methodAll is the internal spelling, kept so the walk and the router read one
// constant.
const methodAll = MethodAll
// plan is the walk result the live generation was built from. Each generation
// RETAINS it, so the reducers serving that generation — the OpenAPI endpoint,
// the MCP list, llms.txt — reuse it rather than rewalking per request.
func (a *App) plan() []occurrence { return a.mustBuild().occ }
// hostSet is every definition this composition reaches, including this one.
func (a *App) hostSet() []*App { return a.mustBuild().hosts }
// computeHosts reduces the walk result to that set.
func computeHosts(root *App, occ []occurrence) []*App {
out := []*App{root}
seen := map[*App]bool{root: true}
for _, o := range occ {
if def, ok := o.app(); ok && !seen[def] {
seen[def] = true
out = append(out, def)
}
}
return out
}
// mustBuild is liveOrBuild for the accessors that CANNOT report an error.
//
// Registry and Declaration return values, not (value, error), so a program that
// does not compose has no honest answer for them — and the dishonest one is what
// this replaced: an invalid build rendered as an EMPTY build, 0 ops and 0 routes,
// which every downstream projection faithfully reproduced.
//
// So it panics, carrying the joined error. That is the same category as mutating
// a frozen App, and for the same reason: you cannot obtain a projection of a
// program that does not compose. A sentinel would lose here precisely because a
// sentinel is something a caller can ignore, and a caller ignoring it is the
// failure this exists to stop.
//
// The error comes back from liveOrBuild rather than being read off the App: the
// draft is written under buildMu, so a read outside it races a concurrent build.
func (a *App) mustBuild() *generation {
g, err := a.liveOrBuild()
if err != nil {
panic("zip: this program does not compose, so it has no projection:\n\t" + err.Error())
}
return g
}
// 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 composition 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.
//
// Lock-free once a generation is live — it is read from serving goroutines by
// the OpenAPI endpoint and the MCP tool listing, on every request.
//
// 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 g := a.live.Load(); g != nil {
return g.ops // the live generation's, computed once, read without a lock
}
// MUTABLE PHASE. No host exists, so the program can still change and there is
// no generation to be consistent with: recompute per call, and do not
// memoise. Caching here is what produced a stale answer once already — a
// draft keyed on a counter that one mutation forgot to bump — and the phase
// where this matters is codegen and tests, never the served path.
//
// Deliberately NOT goroutine-safe: a program under construction has one
// writer by definition, and a lock here would buy nothing while implying a
// guarantee the phase cannot make.
occ, err := walk(a)
if err == nil {
if verr := structural(occ); verr != nil {
err = verr
} else {
err = derived(occ)
}
}
if err != nil {
panic("zip: this program does not compose, so it has no projection:\n\t" + err.Error())
}
return composeOps(occ)
}
// router returns the live generation's router, building a draft if nothing is
// live yet. It never installs — see [App.liveOrBuild].
func (a *App) router() *fiber.App { return a.mustBuild().router }
// composeOps reduces a walk to the op registry.
func composeOps(occ []occurrence) []*registeredOp {
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, 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.
// [scope.origin] is what makes that last clause true: the enclosing
// definition is a GROUP for every real app, and a group has no name.
c.Origin = o.ctx.origin
}
if len(c.Tags) == 0 && c.Origin != "" {
c.Tags = []string{c.Origin}
}
out = append(out, &c)
}
return out
}
// materialise replays an occurrence slice onto a fresh router, in order, and
// RETURNS it. It is pure with respect to the App — the caller decides whether
// the result becomes a generation — which is what lets a failed build be
// discarded without the live system ever having seen it.
//
// 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 the old delegating verb 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(occ []occurrence, ctl []route) *fiber.App {
f := fiber.New(a.fiberConfig(composeOAuth(occ)))
// The report goes on FIRST, so it is outermost: it observes every request
// this router answers, including the ones that match no route and the ones a
// later middleware refuses. An app gets it by being built, which is the whole
// of what "no per-app code" means — there is no call to forget and no order
// to get wrong. See [App.report].
if h := a.telemetryHandler(); h != nil {
f.Use(h)
}
// Beside the report and for the same reason: an app says where its own
// description lives by being BUILT, so there is no call to forget. It runs
// after the report so the report still observes every request, and before
// every route so it covers the answers no route matched. See [App.linkHandler].
f.Use(a.linkHandler())
for _, o := range occ {
switch o.kind() {
case kindMiddleware:
if o.ctx.depth == 0 {
h, _ := o.middleware()
f.Use(toFiberHandler(a, h))
}
case kindRoute:
r, _ := o.route()
a.installRoute(f, o.ctx.prefix, o.ctx.mw.included(), r)
}
}
// zip's OWN projection routes go on last, and are not entries at all: a
// control route SERVES what the program computes, so putting it in the
// program would make rendering a projection a mutation, and would make it
// inheritable by any host that included this definition.
for _, c := range ctl {
a.installRoute(f, "", nil, c)
}
return f
}
// installRoute 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) installRoute(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 — and so the definition's error
// vocabulary reaches the entry once instead of at each of those.
func (a *App) addRoute(site callsite, r route) {
r.oauth = a.oauth
r.undeclared = a.undeclared
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 {
// A reducer, like every other. Filtering the walk's result by the definition
// that WROTE each entry recovers that definition's own entries in order,
// which is all this needs — and it keeps the tree from being reopened here.
type seen struct {
included []occurrence
}
per := map[*App]*seen{}
var order []*App
var out []string
for _, o := range a.plan() {
def := o.ctx.in
st, ok := per[def]
if !ok {
st = &seen{}
per[def] = st
order = append(order, def)
}
switch o.kind() {
case kindApp:
st.included = append(st.included, o)
case kindMiddleware:
for _, inc := range st.included {
child, _ := inc.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(), o.site, child.who(), inc.site))
}
}
}
_ = order
sort.Strings(out)
return out
}
// fiberConfig is the App's Config as fiber wants it. One place, so New and
// every rematerialisation agree. oauth is the address set the error handler
// answers RFC 6749 at, which is a property of the routes this generation holds
// and so arrives with them.
func (a *App) fiberConfig(oauth map[string]bool) 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,
// Every string and body fiber hands out is a copy. Without this each is
// a view of the connection's RequestCtx, which the next request on the
// connection parses into in place, so a value kept past the handler (a
// map key, a cache entry, a goroutine, a usage record) silently becomes
// the next caller's. Not optional: zip has no knob for it, because a
// handler cannot know which of its values someone downstream will keep.
Immutable: true,
}
if a.cfg.ServerHeader != "-" {
fcfg.ServerHeader = a.cfg.ServerHeader
}
// The proxy-trust knobs reach the router or they mean nothing: c.IP() is
// where fiber applies the allowlist, and it reads them off this config.
fcfg.TrustProxy = a.cfg.TrustProxy
fcfg.ProxyHeader = a.cfg.ProxyHeader
if len(a.cfg.TrustedProxies) > 0 {
fcfg.TrustProxyConfig = fiber.TrustProxyConfig{Proxies: a.cfg.TrustedProxies}
}
if a.cfg.ErrorHandler != nil {
fcfg.ErrorHandler = a.cfg.ErrorHandler
} else {
fcfg.ErrorHandler = refusing(oauth)
}
return fcfg
}
// hosts walks the composition and returns every App carrying plugin state,
// including this one.
//
// A plugin used to be registered on whatever host happened to load it, so
// Plugins/Reload/Unload could read one map. A plugin is now a DEFINITION
// ([Load] returns one), so its state lives with it, and the host finds it the
// way it finds everything else about its composition: by walking. That is the
// same move the op registry made — one walk, and the projections stop keeping
// private copies of what the program already says.
func (a *App) hosts() []*App { return a.hostSet() }
// pluginNamed finds the definition holding the named plugin, and the plugin.
func (a *App) pluginNamed(name string) (*App, *plugin) {
for _, h := range a.hosts() {
h.plugMu.Lock()
p := h.plugins[name]
h.plugMu.Unlock()
if p != nil {
return h, p
}
}
return nil, nil
}