mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
App, Group, Operation. A group is a prefix and what holds under it, an operation
is one typed route declared there, and the verb methods are generic methods —
which Go 1.27 allows on a concrete type and still refuses on an interface. So In
and Out come from the handler and the caller writes no type arguments:
users := app.Group("/v1/users")
users.Post("/", api.Create)
mint := users.Group("/mint").With(middleware.Mint(gate))
mint.Post("/deposit", api.Deposit)
A Group is a CLOSED HANDLE: two unexported fields, and no method that hands back
the app, the parent or the fiber underneath. A subsystem given the group at its
own prefix cannot reach above it, so confinement is a property of the type
instead of a rule something has to police — which is what a host needs in order
to hand out a bounded surface and know what comes back.
That is bought by giving up the router interface, deliberately. Router and
OpTarget are gone, and with them the ability to implement a route table outside
this package: a decorator is no longer a router, a gate is middleware on a group,
and a bound is a path. The five package-level verb functions go the same way —
a registration is a method on the thing it registers into.
Raw carries what is not a typed operation: a stream, an upgrade, a probe, a
foreign subtree. It names its method, and it is the longer spelling because
opting out of the typed model is the exception and should read like one.
Co-authored-by: Hanzo Dev <[email protected]>
325 lines
12 KiB
Go
325 lines
12 KiB
Go
package zip
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
|
|
fiber "github.com/zap-proto/fiber/v3"
|
|
)
|
|
|
|
// A plugin DECLARES the paths it answers; a host DISCOVERS them.
|
|
//
|
|
// A host that hand-maintains a routing row for a repository it does not build
|
|
// will get that row wrong, and a wrong row is an outage: cloud's `analytics`
|
|
// row listed the read endpoints and omitted the four ingestion doors the app
|
|
// actually served, so every product beacon in the fleet answered 405 eighteen
|
|
// seconds after the row became load-bearing.
|
|
//
|
|
// The declaration is projected from the LIVE ROUTER. Not from the AST — that
|
|
// cannot cross a repository boundary. Not from the OpenAPI document — a route
|
|
// with no typed op is in no document and still needs routing, which is exactly
|
|
// the ingestion case. Not from a committed golden — a golden is what gets
|
|
// consulted instead of the router.
|
|
|
|
// PluginPath is where a running plugin serves its own declaration, so a host
|
|
// that already started a child can compare what it MOUNTED against what the
|
|
// child SERVES. It is one of zip's control-plane routes, and a Declaration
|
|
// never includes one (see [App.control]).
|
|
const PluginPath = "/.well-known/zip/plugin.json"
|
|
|
|
// control registers one of zip's OWN routes — a projection of the app rather
|
|
// than a door the app's owner wrote — and records it so [App.Declaration]
|
|
// excludes it.
|
|
//
|
|
// Recording is not a filter on a path prefix, because zip's control plane is
|
|
// not all under one: /.well-known/openapi.json and /.well-known/zip/op/ are,
|
|
// but /docs and the MCP path (default /mcp, configurable) are not. A prefix
|
|
// filter therefore let a child DECLARE /docs and /mcp — paths that are not
|
|
// under /v1/ at all, that every other plugin also serves, and whose first
|
|
// claimant would take the HOST's own document and agent door for the whole
|
|
// composition. Registering through here makes leaking one impossible rather
|
|
// than unlikely.
|
|
func (a *App) control(method, path string, h fiber.Handler) {
|
|
if a.controls == nil {
|
|
a.controls = map[string]bool{}
|
|
}
|
|
a.controls[method+" "+path] = true
|
|
a.buildMu.Lock()
|
|
a.ctl = append(a.ctl, route{method: method, path: path, serve: h})
|
|
// The build moved, so any draft taken before this line is stale. Control
|
|
// routes are not entries, so nothing else bumps the counter for them.
|
|
version.Add(1)
|
|
live := a.live.Load() != nil
|
|
a.buildMu.Unlock()
|
|
if live {
|
|
// A projection route added after something is serving still goes through
|
|
// a generation: there is no other way for the live router to change.
|
|
_ = a.transact(here(1), func() {})
|
|
}
|
|
}
|
|
|
|
// Declaration is what a plugin tells a host: who it is, whether it must be
|
|
// running before the first request arrives, every route pattern its router
|
|
// holds, and every op name it answers on the call plane.
|
|
//
|
|
// It is the whole of what a host needs in order to route to a repository it
|
|
// does not build. There is no Prefixes field and no Remainder flag: a plugin
|
|
// that owns a version remainder declares the catch-all route it actually
|
|
// registered ("/v1/*"), so the fact lives in one place — the router.
|
|
type Declaration struct {
|
|
Name string `json:"name"`
|
|
Eager bool `json:"eager,omitempty"`
|
|
Routes []Route `json:"routes"`
|
|
Ops []string `json:"ops,omitempty"`
|
|
}
|
|
|
|
// Route is one pattern in the ROUTER's own spelling — ":id", not "{id}". The
|
|
// host mounts what the router matches, so the two must be the same string.
|
|
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 [Proxy] 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
|
|
// answer, sorted by (pattern, method) and deduplicated, plus every registered
|
|
// op name. It is complete by construction — a route the plugin serves and does
|
|
// not publish is still declared.
|
|
//
|
|
// zip's own control plane is excluded — see [App.control]: those are
|
|
// per-process routes a host serves for itself, and a child claiming them would
|
|
// take the host's own document, agent door and op plane.
|
|
func (a *App) Declaration() Declaration {
|
|
a.prepare()
|
|
d := Declaration{Name: a.cfg.AppName, Eager: a.cfg.Eager, Routes: []Route{}}
|
|
|
|
seen := map[string]bool{}
|
|
for _, o := range a.plan() {
|
|
r, ok := o.route()
|
|
if !ok || r.method == "" {
|
|
continue
|
|
}
|
|
if r.undeclared {
|
|
continue
|
|
}
|
|
for _, m := range declaredMethods(r.method) {
|
|
k := m + " " + o.abs(r.path)
|
|
if seen[k] {
|
|
continue
|
|
}
|
|
seen[k] = true
|
|
d.Routes = append(d.Routes, Route{Method: m, Pattern: o.abs(r.path)})
|
|
}
|
|
}
|
|
sort.Slice(d.Routes, func(i, j int) bool {
|
|
if d.Routes[i].Pattern != d.Routes[j].Pattern {
|
|
return d.Routes[i].Pattern < d.Routes[j].Pattern
|
|
}
|
|
return d.Routes[i].Method < d.Routes[j].Method
|
|
})
|
|
|
|
ops := map[string]bool{}
|
|
// 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
|
|
}
|
|
|
|
// declaredMethods expands one registered method into the methods a host must
|
|
// route, and is where the SHADOW DOCTRINE lives.
|
|
//
|
|
// A declaration is projected from the PROGRAM, not from the materialised router,
|
|
// and that is what settles the shadow question rather than a filter settling it.
|
|
// fiber registers a HEAD alongside every GET and an OPTIONS shadow for CORS.
|
|
// Those are not entries — nothing wrote them — so they never reach a declaration
|
|
// in the first place, and they do not need to: they are regenerated wherever the
|
|
// GET is registered, including on the host that routes to this service. A shadow
|
|
// is a property of registering a GET, not a door anybody declared.
|
|
//
|
|
// The filter this replaced dropped ALL HEAD and OPTIONS, which silently deleted
|
|
// an EXPLICITLY declared HEAD — a real door, and one a host would then answer
|
|
// 405 on. That is the analytics-outage class this type exists to prevent, so the
|
|
// distinction has to be structural: an entry is a door, a shadow is not an entry.
|
|
//
|
|
// App.All is the one entry that is genuinely many doors, so it expands here.
|
|
// HEAD and OPTIONS are left out of that expansion for the same reason as before:
|
|
// under All they are indistinguishable from the shadows, and a host that routes
|
|
// the GET gets them regenerated.
|
|
func declaredMethods(m string) []string {
|
|
if m != methodAll {
|
|
return []string{m}
|
|
}
|
|
return []string{"GET", "POST", "PUT", "PATCH", "DELETE", "CONNECT", "TRACE"}
|
|
}
|
|
|
|
// installPluginRoute serves this app's own declaration, so a host can read the
|
|
// SERVED routes off a started child and compare them to the ones it mounted.
|
|
// Called from prepare() alongside installOpenAPIRoutes and installMCP.
|
|
func (a *App) installPluginRoute() {
|
|
a.control(fiber.MethodGet, PluginPath, func(fc fiber.Ctx) error {
|
|
fc.Set(fiber.HeaderContentType, mimeJSON)
|
|
return fc.JSON(a.Declaration())
|
|
})
|
|
}
|
|
|
|
// Projection names one document an app can emit from its own op registry. The
|
|
// verb on the command line IS the name here, and both write a FILE.
|
|
type Projection string
|
|
|
|
const (
|
|
// OpenAPI is the app's own OpenAPI subset, composed upward into the fleet
|
|
// document. Never carved out of the fleet document by prefix: that makes
|
|
// the fleet the source and the plugin a derivative, which is how a
|
|
// catch-all silently swallows a neighbour's routes.
|
|
OpenAPI Projection = "openapi"
|
|
|
|
// Declare is the routing declaration a host discovers (see [Declaration]).
|
|
Declare Projection = "declare"
|
|
)
|
|
|
|
// Described writes the projection this process was asked for on the command
|
|
// line and reports whether it did:
|
|
//
|
|
// <binary> openapi <file> the app's OpenAPI subset
|
|
// <binary> declare <file> the app's routing declaration
|
|
//
|
|
// A main calls it once, after registering every op and BEFORE opening a store
|
|
// or dialing a peer — a projection is a function of the code, so a describe run
|
|
// must not need a database:
|
|
//
|
|
// if done, err := app.Described(); done {
|
|
// return err
|
|
// }
|
|
// return app.Listen(zip.Addr(":9653"))
|
|
//
|
|
// It writes a FILE and never stdout. A plugin's own dependencies write to
|
|
// stdout at construction — zip.New logs a line, GORM logs queries, sqlite-vec
|
|
// prints a warning — and `> file` splices those into the front of the
|
|
// document.
|
|
//
|
|
// One call rather than a Described()/Describe() pair, because argv is read in
|
|
// exactly one place and a main cannot forget to forward it.
|
|
func (a *App) Described() (bool, error) {
|
|
args := os.Args[1:]
|
|
if len(args) == 0 {
|
|
return false, nil
|
|
}
|
|
var mode Projection
|
|
switch Projection(args[0]) {
|
|
case OpenAPI:
|
|
mode = OpenAPI
|
|
case Declare:
|
|
mode = Declare
|
|
default:
|
|
return false, nil
|
|
}
|
|
if len(args) < 2 || args[1] == "" {
|
|
return true, fmt.Errorf("zip: %s needs a destination file", mode)
|
|
}
|
|
return true, a.project(mode, args[1])
|
|
}
|
|
|
|
// project renders one projection to dest, atomically: a reader that finds the
|
|
// file finds a whole document, and a failed render leaves the previous one.
|
|
func (a *App) project(mode Projection, dest string) error {
|
|
// Ask whether the program is a program BEFORE writing a file that claims to
|
|
// describe it. An invalid build renders as an EMPTY one — 0 ops, 0 routes —
|
|
// so without this, `app declare` writes {"routes":[]} and exits 0, and a
|
|
// release pipeline ships an empty API document for a broken service.
|
|
if err := a.Build(); err != nil {
|
|
return fmt.Errorf("zip: %s: refusing to write a document for a program that does not build: %w", mode, err)
|
|
}
|
|
var v any
|
|
switch mode {
|
|
case OpenAPI:
|
|
v = a.OpenAPISpec()
|
|
case Declare:
|
|
v = a.Declaration()
|
|
default:
|
|
return fmt.Errorf("zip: unknown projection %q", mode)
|
|
}
|
|
body, err := json.MarshalIndent(v, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("zip: %s: %w", mode, err)
|
|
}
|
|
body = append(body, '\n')
|
|
|
|
if dir := filepath.Dir(dest); dir != "" && dir != "." {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("zip: %s: %w", mode, err)
|
|
}
|
|
}
|
|
tmp := dest + ".tmp"
|
|
if err := os.WriteFile(tmp, body, 0o644); err != nil {
|
|
return fmt.Errorf("zip: %s: %w", mode, err)
|
|
}
|
|
if err := os.Rename(tmp, dest); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("zip: %s: %w", mode, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Undeclared returns a group whose routes SERVE but do not appear in
|
|
// [App.Declaration] — and so in none of the projections built from it: the
|
|
// OpenAPI document, the MCP tool list, the CLI commands, the by-name call
|
|
// plane.
|
|
//
|
|
// It is for an address that answers without being part of the contract. A
|
|
// retirement is the case it was built for: an address that is GONE answers 410
|
|
// and names its successor, for every method, because 410 is a statement about
|
|
// the target resource and a caller who sent the wrong verb still needs the
|
|
// successor. Published, that is one operation per method per address, most of
|
|
// them calls that never existed — a contract listing dead endpoints, which is
|
|
// a contract nobody can read.
|
|
//
|
|
// It is deliberately narrow. An address that DOES something and hides is a
|
|
// door nobody can find and nobody reviews, which is why [Group.Undeclared]
|
|
// returns a group rather than taking a path: the routes that use it are
|
|
// grouped, visible in one place, and read together.
|
|
//
|
|
// The fact rides the route ENTRY (see [App.addRoute]), so it survives
|
|
// composition — a service composed under a host stays undeclared at its new
|
|
// path.
|
|
|
|
// Declares reports whether the app publishes method+path — whether it appears
|
|
// in [App.Declaration], and so in the projections built from it.
|
|
//
|
|
// A host builds its document from the ROUTER, which is a strict superset: it
|
|
// carries zip's own control routes and anything registered through
|
|
// [Undeclared]. This is how it tells those apart from the app's contract
|
|
// without keeping a second list of what to skip.
|
|
func (a *App) Declares(method, path string) bool {
|
|
for _, r := range a.Declaration().Routes {
|
|
if r.Method == method && r.Pattern == path {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|