mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
A typed op could only take one JSON value and answer one, so uploads, signed webhooks, form posts, YAML and merge-patch bodies, PDFs, SSE, WebSockets, OAuth redirects and upstream relays stayed untyped routes with no schema, tool, command or SDK method. Each is now a typed op and its REST wire is unchanged. Requests: a zip.Body field takes the body and Content-Type exactly as sent; an In implementing Parser decodes itself; form: fields and *zip.File parts bind url-encoded and multipart forms; cookie: binds a request cookie as an in: cookie parameter. Consumes names the media. Answers: zip.Body (bytes, filename, its own status and headers, a Reader streams chunked), Verbatim[T] (a relay documented as T), Sse[T] (events with keep-alive), Socket[M] (an upgrade after the contract runs; 426 without one, 501 over ZAP), Redirect, CookieCoder, Produces, and unions: a type whose OneOf method's results are the alternatives, with Or[A,B] as the stock one filing each under the status it states. Projections follow from the manifest: the document publishes binary media, form schemas, x-events, x-socket with 101, 3xx with Location and oneOf; MCP takes bytes as base64 and returns bytes, text or collected events as tool content, listing a socket op in _meta as no tool; the CLI reads --body @file|-, --field name=@file and --out, prints events as they arrive and bridges a socket to stdin and stdout. tools/list is ProjectMCP over the manifest, the projection MCPTools already was. Prose reaches promoted fields (filed under the declaring struct), a type defined over another struct, and the type arguments of zip's kinds and OneOf results; an instantiated generic is published under its name followed by its argument names instead of an illegal component key. The unused reflect-only document and tool builders go.
607 lines
26 KiB
Go
607 lines
26 KiB
Go
// Package zip is Hanzo's canonical Go web framework. Built on
|
|
// Fiber v3 / fasthttp. ZAP-typed handlers.
|
|
// Multi-language extension support via HIP-0105.
|
|
//
|
|
// ONE framework, ZERO escape hatches. zip IS fast.
|
|
//
|
|
// app := zip.New(zip.Config{Logger: luxlog.NewLogger("svc")})
|
|
// app.Use(middleware.Recover(), middleware.RequestID())
|
|
// app.Get("/health", func(c *zip.Ctx) error {
|
|
// return c.JSON(200, fiber.Map{"ok": true})
|
|
// })
|
|
// app.Listen(":9653", "http://:8080") // ZAP primary + HTTP extra, one verb
|
|
//
|
|
// Public surface — types/functions exposed at the package root:
|
|
//
|
|
// type App, Config, Ctx, Handler
|
|
// func New(Config) *App
|
|
// func Get[I, O](app *App, path string, fn func(ctx, *I) (*O, error))
|
|
// func Post[I, O](app *App, path string, fn func(ctx, *I) (*O, error))
|
|
// func Dial(addr string) (*Conn, error) / DialApp(name string) (*Conn, error)
|
|
// func Call[I, O](ctx, *Conn, op string, in *I) (*O, error)
|
|
// ...
|
|
//
|
|
// All other behavior lives in subpackages: `middleware`, `js`.
|
|
package zip
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
luxlog "github.com/luxfi/log"
|
|
"github.com/zap-proto/fiber/v3"
|
|
zapmcp "github.com/zap-proto/mcp"
|
|
|
|
"github.com/zap-proto/zip/internal/jsonenc"
|
|
)
|
|
|
|
// JSONVariant names the JSON implementation on the edge and the semantics it
|
|
// writes, which zip.New logs once at startup. Per HIP-0106 the wire stack is
|
|
// "JSON only at edge, ZAP between services", so this is the one place the
|
|
// edge's encoder is stated. What it means is in [jsonenc.Variant].
|
|
const JSONVariant = jsonenc.Variant
|
|
|
|
// Handler is zip's request handler signature. Returning an error causes
|
|
// Fiber's error chain to write a JSON response.
|
|
type Handler func(c *Ctx) error
|
|
|
|
// Config configures the zip App. Most fields pass through to Fiber's own
|
|
// Config; a few zip-specific knobs control runtime loading.
|
|
type Config struct {
|
|
// Logger is the luxfi/log Logger zip uses internally. Required.
|
|
// If nil, a default one is created via luxlog.NewLogger("zip").
|
|
Logger luxlog.Logger
|
|
|
|
// Loader is the HIP-0105 extension runtime loader. nil disables
|
|
// app.Module() — only native handlers will work. zip does NOT take a hard
|
|
// dep on hanzoai/base: the consumer builds the loader and passes it in.
|
|
// Note that [Loader] is satisfied structurally but NOT loosely — LoadDir
|
|
// must return map[string][Module] exactly, so a backend with its own
|
|
// Module type (e.g. hanzoai/base/plugins/extruntime) needs a thin adapter,
|
|
// not a direct assignment. For JavaScript, import
|
|
// [github.com/zap-proto/zip/js] — a separate import so its goja +
|
|
// esbuild cost lands only on binaries that evaluate JS.
|
|
Loader Loader
|
|
|
|
// AllowedRuntimes restricts which extension runtimes app.Module()
|
|
// will accept (e.g. ["goja","wazero"] for hard multi-tenant safety).
|
|
// nil = allow whatever the Loader has registered.
|
|
AllowedRuntimes []string
|
|
|
|
// ServerHeader is sent as the Server: response header (default "zip").
|
|
// Set to "-" to suppress.
|
|
ServerHeader string
|
|
|
|
// BodyLimit is the maximum request body size (default 4 MiB).
|
|
BodyLimit int
|
|
|
|
// AppName forwards to fiber.Config.AppName. It is also the plugin's ONE
|
|
// name: the binary's name, its socket's stem ([SocketPath]), its
|
|
// [Declaration].Name and its <org>-<app> IAM segment. No mapping table.
|
|
AppName string
|
|
|
|
// Eager says this app's work is NOT request-driven: it owns a listener, a
|
|
// consumer or a background loop, so a host MUST start it rather than defer
|
|
// it to the first request that reaches one of its routes.
|
|
//
|
|
// It is the ONE fact about an app that its router cannot show, which is why
|
|
// it is stated here and travels in the [Declaration]. Everything else a host
|
|
// needs to route is projected from the router itself.
|
|
//
|
|
// A host reads it as the ceiling on its own deferral choice
|
|
// ([Plugin].Lazy): deferring an eager app is the mismatch §3.4 rejects.
|
|
Eager bool
|
|
|
|
// DisableStartupMessage suppresses Fiber's startup banner.
|
|
DisableStartupMessage bool
|
|
|
|
// Warm is how many plugin processes this app may hold at once. Zero means
|
|
// unbounded, the historical behaviour. It is enforced where a plugin STARTS,
|
|
// so it is a ceiling rather than something a sweep restores a minute later,
|
|
// and again by [App.Evict] alongside each plugin's IdleAfter.
|
|
//
|
|
// A process serving a request is never the one stopped for room. When every
|
|
// candidate is busy, a starter waits for one to finish, for as long as its
|
|
// own [Plugin.Start] allows, and then answers 503 rather than kill a request.
|
|
//
|
|
// The host states it because the number is a fact about the host's memory: the
|
|
// processes are its own children in its own cgroup, and a library cannot know
|
|
// how much of it they may have.
|
|
Warm int
|
|
|
|
// ErrorHandler replaces the handler that writes every refusal. Unset, a
|
|
// refusal is an RFC 9457 problem document (application/problem+json), or
|
|
// RFC 6749's {error, error_description} at an address declared with [OAuth].
|
|
// See problem.go.
|
|
ErrorHandler fiber.ErrorHandler
|
|
|
|
// Concurrency caps the maximum number of concurrent connections the
|
|
// server will accept. Default 0 means fasthttp's own default
|
|
// (256*1024). Ops should cap this at the per-replica budget — see
|
|
// `~/work/hanzo/hips/docs/SCALE_STANDARD.md`. With Hanzo's verified
|
|
// 8 KiB/conn budget, 100_000 sits at ~800 MiB inside a 1 GiB pod.
|
|
Concurrency int
|
|
|
|
// SocketMode is the file mode a unix socket is bound with. Zero is 0600:
|
|
// only the user running the app may connect, and the socket is the
|
|
// boundary. A server that instead attests every peer from what the
|
|
// kernel says about it — [PeerOf], [Peer.PodUID] — may open the mode,
|
|
// because the attestation is then the boundary and the mode is only the
|
|
// door.
|
|
SocketMode os.FileMode
|
|
|
|
// ReadBufferSize is fasthttp's per-conn request-read buffer (default
|
|
// 4 KiB). Raise only for header-heavy upstreams; raising it inflates
|
|
// the per-conn memory budget and breaks the conn-memory regression
|
|
// gate (see SCALE_STANDARD.md §8).
|
|
ReadBufferSize int
|
|
|
|
// WriteBufferSize is fasthttp's per-conn response-write buffer
|
|
// (default 4 KiB). Raise only for streaming-heavy responses; same
|
|
// budget caveat as ReadBufferSize.
|
|
WriteBufferSize int
|
|
|
|
// OpsAddr is where this app's ops listener binds — /healthz, /readyz and
|
|
// /metrics on a socket of their own, so a liveness probe never queues behind
|
|
// public traffic and a metrics endpoint is never on a public port. Empty
|
|
// means this process owns no ops listener, which is what a child composed
|
|
// into a host wants: the ops port belongs to the deployment, and a child's
|
|
// private socket is not one of the deployment's listeners (HIP-0106 §1.3(f)).
|
|
//
|
|
// It is an address like every other address zip takes, and the address names
|
|
// its own transport — so an ops listener a kubelet probes and Prometheus
|
|
// scrapes is [DefaultOpsAddr], an HTTP one. A bare ":9090" would be ZAP
|
|
// (DefaultScheme), which those two clients cannot speak.
|
|
OpsAddr string
|
|
|
|
// TrustProxy says this app sits behind a proxy whose forwarded headers may
|
|
// be believed — and it is OFF by default, deliberately.
|
|
//
|
|
// [Caller.IP] is the socket peer unless this is set, because X-Forwarded-For
|
|
// is attacker-controlled input: anyone can send one. An IP that silently
|
|
// reports the load balancer is worse than no IP at all, since someone will
|
|
// rate-limit or audit on it.
|
|
//
|
|
// The opt-in is allowlist-gated rather than a boolean promise: with
|
|
// TrustedProxies naming the proxies (IPs or CIDRs), the value in ProxyHeader
|
|
// is honoured ONLY when the peer is one of them, and falls back to the peer
|
|
// anywhere else. Setting TrustProxy with no TrustedProxies trusts nothing —
|
|
// fiber skips every spoofable header — which is the safe reading of an
|
|
// incomplete configuration.
|
|
TrustProxy bool
|
|
|
|
// TrustedProxies is the allowlist TrustProxy consults: proxy IPs or CIDR
|
|
// ranges whose forwarded headers this app will believe.
|
|
TrustedProxies []string
|
|
|
|
// ProxyHeader names the header a trusted proxy states the caller's address
|
|
// in — "X-Forwarded-For" for most ingress, "X-Real-IP" for some. Read only
|
|
// when TrustProxy is set and the peer is in TrustedProxies.
|
|
ProxyHeader string
|
|
|
|
// OpenAPI configures the auto-generated /.well-known/openapi.json
|
|
// served when typed handlers are registered.
|
|
OpenAPI OpenAPIConfig
|
|
|
|
// MCP configures the Model Context Protocol tool surface auto-derived from
|
|
// typed handlers (Get/Post[In,Out]). Enabled by default — it's free (the
|
|
// same op registry that feeds OpenAPI), served over every transport. Set
|
|
// MCP.Disabled to suppress.
|
|
MCP MCPConfig
|
|
|
|
// Telemetry says where this app's report goes and whether it makes one. The
|
|
// report itself is not configured: every zip app logs every request,
|
|
// measures it, and carries trace context, because it is serving. See
|
|
// [Telemetry].
|
|
Telemetry Telemetry
|
|
}
|
|
|
|
// App is the zip application. It wraps *fiber.App and exposes the zip
|
|
// handler signature alongside generic typed handlers.
|
|
type App struct {
|
|
cfg Config
|
|
logger luxlog.Logger
|
|
loader Loader
|
|
|
|
// 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 GENERATIONS. live is the sealed, immutable program currently serving,
|
|
// swapped whole and read without a lock; draft is the throwaway build that
|
|
// answers inspection before anything is live. buildMu serialises building —
|
|
// never reading. See generation.go.
|
|
live atomic.Pointer[generation]
|
|
draft *generation
|
|
draftAt uint64
|
|
draftErr error
|
|
buildMu sync.Mutex
|
|
// control is zip's own projection routes. They belong to the BUILD, not to
|
|
// the program — see [App.control].
|
|
ctl []route
|
|
|
|
// frozen: this definition has appeared in a built generation, so editing it
|
|
// in place is refused. Monotonic, and propagated across the whole reachable
|
|
// graph at install.
|
|
frozen atomic.Bool
|
|
freezeSite callsite
|
|
|
|
// 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
|
|
|
|
// oauth: every route registered on this definition answers RFC 6749 §5.2.
|
|
// Set by [OAuth] on the group it hands back, so the vocabulary is a property
|
|
// of where a route was declared and not of what its handler returns.
|
|
oauth bool
|
|
|
|
// undeclared: every route registered on this definition serves but is left
|
|
// out of [App.Declaration]. See [App.Undeclared].
|
|
undeclared bool
|
|
|
|
servers []Server // the running transport listeners, set by Listen
|
|
|
|
// unbind withdraws this app from the by-address table [Serving] reads, one
|
|
// entry per bound listener. Held beside servers because they have the same
|
|
// lifetime: an app answers in-process for exactly as long as it answers on
|
|
// the wire.
|
|
unbind []func()
|
|
|
|
// 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
|
|
// handler will act on. nil defers to [App.over], and nil there too leaves
|
|
// every decoded request unauthorized. See [App.rule].
|
|
authorizer Authorizer
|
|
|
|
// onResult, when set via OnResult, is told the outcome of every typed op this
|
|
// app serves, however it was reached. nil defers to [App.over]. See
|
|
// [App.result].
|
|
onResult func(context.Context, Op, error)
|
|
|
|
// over is the App whose rule this one answers to: the nearest definition
|
|
// ABOVE it in the composition that declared an Authorizer. Settled at build
|
|
// by [adopt], which is the only place that knows — a definition is written
|
|
// without knowing what will include it, and what includes it is what decides
|
|
// whose rule its ops serve under.
|
|
//
|
|
// Atomic because a build runs while the previous generation serves, the same
|
|
// reason a plugin learns its owner atomically.
|
|
over atomic.Pointer[App]
|
|
|
|
srvMu sync.Mutex
|
|
|
|
// Teardown lifecycle. hooks are drained LIFO by Shutdown after
|
|
// in-flight requests finish; shuttingDown guards against re-running
|
|
// them and against post-shutdown registration. hookMu guards both.
|
|
hooks []func(context.Context) error
|
|
shuttingDown bool
|
|
hookMu sync.Mutex
|
|
|
|
// Running plugins, by name, so Reload can find one after Load composed it.
|
|
// Guarded by plugMu.
|
|
plugins map[string]*plugin
|
|
|
|
// The composed MCP surface of the plugins this app Load'ed: every tool
|
|
// descriptor a plugin's build-time catalogue declared, and which plugin owns
|
|
// each name. Written by load (under plugMu), read by installMCP and by a
|
|
// tools/call. It is what lets a host expose 112 lazy children's tools without
|
|
// running any of them — see [Plugin.Tools].
|
|
pluginTools []mcpTool
|
|
toolOwners map[string]*plugin
|
|
// open is the one plugin whose catalogue is INCOMPLETE by construction — the
|
|
// tenant half of the door, asked per caller instead of embedded. See
|
|
// [Plugin.Open]. At most one, so a name no catalogue claims has one owner.
|
|
open *plugin
|
|
plugMu sync.Mutex
|
|
|
|
// warm is Config.Warm: how many plugin processes may run at once. It is state
|
|
// rather than an argument to Evict because the ceiling must be known where a
|
|
// process is STARTED — a bound only a periodic sweep consults is a bound a
|
|
// burst outruns. See makeRoom.
|
|
warm int
|
|
|
|
// roomMu makes "is there room" and "take it" one step, and starting counts
|
|
// the processes that have taken room but are not running yet. Without both,
|
|
// two starters each read 34 of 35 and each start, and the ceiling is a race.
|
|
roomMu sync.Mutex
|
|
starting int
|
|
|
|
// mcpList is the whole tools/list array, rendered by installMCP and re-rendered
|
|
// by any later load: own ops ++ every plugin catalogue, sorted by name. Serving
|
|
// bytes is what makes the most-called MCP method free. Atomic because a Load
|
|
// after Listen re-renders it while requests are reading it — nil until the door
|
|
// is installed, which is also how installTools knows whether to re-render.
|
|
mcpList atomic.Pointer[json.RawMessage]
|
|
|
|
// mcpNames is the set of names inside mcpList, stored with it so the
|
|
// per-caller half can tell whether a tenant tool would shadow a projected one
|
|
// without re-parsing the array on every request.
|
|
mcpNames atomic.Pointer[map[string]bool]
|
|
|
|
// mcpRefused names the ops that are not tools, and why — rendered with
|
|
// mcpList and answered in tools/list's _meta, so a client reading the list
|
|
// sees every op accounted for.
|
|
mcpRefused atomic.Pointer[map[string]string]
|
|
|
|
// The ZAP-native MCP listener, when Config.MCP.Addr named one. It is a
|
|
// zapmcp.Server over App.MCP — the door served on the wire it was designed
|
|
// for, with no HTTP anywhere in the path. Guarded by mcpMu so a second Listen
|
|
// does not bind a second one.
|
|
mcpSrv *zapmcp.Server
|
|
mcpMu sync.Mutex
|
|
// mcpOnce renders the tool list for whichever door asks first, so an app
|
|
// served only over ZAP is not left with the empty projection the HTTP
|
|
// installer used to be the sole producer of.
|
|
mcpOnce sync.Once
|
|
|
|
// The ops sibling (/healthz, /readyz, /metrics) and the listener count its
|
|
// readiness reports. born is the process's own clock for zip_uptime_seconds.
|
|
ops *App // the /healthz + /readyz + /metrics sibling; see App.Ops
|
|
opsOnce sync.Once
|
|
peer *App // the peer-op sibling served on the canonical socket; see App.Peer
|
|
peerOnce sync.Once
|
|
// sibling marks an App that IS one of the two above, so it does not grow
|
|
// siblings of its own and does not try to bind the process's ops port.
|
|
sibling bool
|
|
listening atomic.Int32
|
|
born time.Time
|
|
|
|
// This app's instruments. Built once with the app and never rebuilt: a
|
|
// generation swap re-installs the request boundary, and counters that reset
|
|
// on every composition change would read as a restart to anything watching
|
|
// the rate.
|
|
telemetry *telemetry
|
|
|
|
// controls is zip's OWN routes, keyed "METHOD path" — the openapi document,
|
|
// the docs page, the MCP door, the op plane, this app's declaration. Written
|
|
// only by App.control, read only by Declaration, so a control route cannot
|
|
// be added without being excluded from every plugin's declaration.
|
|
controls map[string]bool
|
|
|
|
prepareOnce sync.Once // installs deferred routes (OpenAPI, MCP) exactly once
|
|
// prepared reports whether that has happened, which sync.Once cannot be
|
|
// asked. Composition needs the answer: the document, the tool list and the
|
|
// call plane are rendered ONCE from the registry, so an op that arrives
|
|
// afterwards would serve and never describe.
|
|
prepared atomic.Bool
|
|
}
|
|
|
|
// Name is the app's [Config.AppName] — the one name it answers to.
|
|
//
|
|
// Exported because a caller holding several apps has to tell them apart, and
|
|
// the name is the only thing that does. [ReadZAP] answers with one app per
|
|
// interface a schema declares, and picking one of them out is exactly that
|
|
// question.
|
|
func (a *App) Name() string { return a.cfg.AppName }
|
|
|
|
// 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")
|
|
}
|
|
if cfg.BodyLimit == 0 {
|
|
cfg.BodyLimit = 4 << 20
|
|
}
|
|
if cfg.ServerHeader == "" {
|
|
cfg.ServerHeader = "zip"
|
|
}
|
|
return &App{
|
|
cfg: cfg,
|
|
logger: cfg.Logger,
|
|
loader: cfg.Loader,
|
|
born: time.Now(),
|
|
warm: cfg.Warm,
|
|
telemetry: newTelemetry(cfg),
|
|
}
|
|
}
|
|
|
|
// 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. 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, 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 }
|
|
|
|
// Shutdown gracefully stops every transport, then runs teardown hooks.
|
|
// The process is ending, so hooks receive context.Background() — no
|
|
// cancellation or deadline. Use ShutdownWithContext to bound teardown.
|
|
// Idempotent: a second call is a no-op and hooks run at most once.
|
|
func (a *App) Shutdown() error {
|
|
return a.shutdown(context.Background())
|
|
}
|
|
|
|
// ShutdownWithContext is Shutdown bounded by ctx: ctx bounds the in-flight
|
|
// drain and is passed to every teardown hook (values and deadline).
|
|
// Shares Shutdown's once-guard, so mixing the two still runs hooks once.
|
|
func (a *App) ShutdownWithContext(ctx context.Context) error {
|
|
return a.shutdown(ctx)
|
|
}
|
|
|
|
// With returns a group whose subsequent leaf registrations
|
|
// have mw wrapped around the handler at registration time — pure
|
|
// composition (RateLimit(CSRF(handler))). It does NOT touch the global Use
|
|
// stack and does NOT route through c.Next(); it is the per-route counterpart to
|
|
// Use. Routes registered on the returned group still obey specificity
|
|
// precedence exactly like any other route.
|
|
//
|
|
// app.With(RateLimit, CSRF).Post("/v1/keys", mintKey)
|
|
func (a *App) With(mw ...Middleware) *Group {
|
|
return (&Group{on: a.group(here(1), "")}).chain(mw)
|
|
}
|
|
|
|
// Raw registers a route that is NOT a typed operation — see [Group.Raw] for
|
|
// what that means and why it is the longer spelling. Chains are in wrapping
|
|
// order: middleware first, the final handler last.
|
|
//
|
|
// The method is a string because the verb NAMES belong to the typed operations:
|
|
//
|
|
// app.Raw("GET", "/healthz", ok)
|
|
//
|
|
// [MethodAll] is the one that answers a path whatever the verb.
|
|
func (a *App) Raw(method, path string, handlers ...Handler) *App {
|
|
a.raw(here(1), method, path, handlers)
|
|
return a
|
|
}
|
|
|
|
// Get declares a GET operation at the root.
|
|
func (a *App) Get[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
|
|
return &Operation[In, Out]{op: registerTyped(0, a, "GET", path, fn, opts...)}
|
|
}
|
|
|
|
// Post declares a POST operation at the root.
|
|
func (a *App) Post[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
|
|
return &Operation[In, Out]{op: registerTyped(0, a, "POST", path, fn, opts...)}
|
|
}
|
|
|
|
// Put declares a PUT operation at the root.
|
|
func (a *App) Put[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
|
|
return &Operation[In, Out]{op: registerTyped(0, a, "PUT", path, fn, opts...)}
|
|
}
|
|
|
|
// Patch declares a PATCH operation at the root.
|
|
func (a *App) Patch[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
|
|
return &Operation[In, Out]{op: registerTyped(0, a, "PATCH", path, fn, opts...)}
|
|
}
|
|
|
|
// Delete declares a DELETE operation at the root.
|
|
func (a *App) Delete[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
|
|
return &Operation[In, Out]{op: registerTyped(0, a, "DELETE", path, fn, opts...)}
|
|
}
|
|
|
|
// OAuth is [Group.OAuth] at the root.
|
|
func (a *App) OAuth() *Group { return a.Group("").OAuth() }
|
|
|
|
// Undeclared is [Group.Undeclared] at the root.
|
|
func (a *App) Undeclared() *Group { return a.Group("").Undeclared() }
|
|
|
|
func (a *App) raw(site callsite, method, path string, handlers []Handler) {
|
|
// 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")
|
|
}
|
|
for i, h := range handlers {
|
|
// toFiberHandler wraps a nil Handler into a NON-nil closure, so fiber's
|
|
// own nil check passes and the nil surfaces as a segfault on a serve
|
|
// goroutine no recover middleware can reach. Boot, not request time.
|
|
if h == nil {
|
|
panic(fmt.Sprintf("zip: %s %s: handler %d is nil", method, normPath(path), i))
|
|
}
|
|
}
|
|
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(site, route{method: method, path: normPath(path), chain: handlers})
|
|
}
|
|
|
|
// opScope makes the App itself a place a typed op lands. 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) {
|
|
var he *HTTPError
|
|
if errors.As(err, &he) {
|
|
return he, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// Routes is every address this program answers, as [Declaration] projects them:
|
|
// the program's own entries, absolute, without fiber's HEAD and OPTIONS shadows
|
|
// and without zip's control plane.
|
|
//
|
|
// It exists so that enumerating routes does not require reaching through
|
|
// [App.Fiber]. That reach is now refused when it mutates (see checkForeignRoutes)
|
|
// and was always wrong when it merely read, because fiber's own GetRoutes cannot
|
|
// apply a doctrine zip owns — it reports shadows nobody declared and cannot tell
|
|
// an explicit HEAD from a generated one.
|
|
func (a *App) Routes() []Route { return a.Declaration().Routes }
|
|
|
|
// TestConfig bounds a [App.Test] request. It is zip's own type on purpose: Test
|
|
// is what callers are told to use INSTEAD of reaching through [App.Fiber], and a
|
|
// signature naming fiber sent them straight back to the escape hatch it replaces
|
|
// — so every test that wanted a timeout imported the router this package exists
|
|
// to keep out of its callers.
|
|
//
|
|
// Two fields because two are what the estate uses. A knob nobody turns is a
|
|
// promise about the router underneath.
|
|
type TestConfig struct {
|
|
// Timeout bounds the request. Zero means the underlying default (1s).
|
|
Timeout time.Duration
|
|
|
|
// FailOnTimeout returns an error when Timeout is exceeded, rather than a
|
|
// 408 response. Default true, matching the router.
|
|
FailOnTimeout bool
|
|
}
|
|
|
|
// Test drives one request against the program without binding a socket, which is
|
|
// the other thing callers reached through [App.Fiber] for.
|
|
//
|
|
// It builds the program if nothing is live, exactly as serving would, so a test
|
|
// exercises the same generation a request would — including every validation.
|
|
//
|
|
// "Exactly as serving would" includes the DEFERRED PROJECTIONS. Serving calls
|
|
// prepare, which installs /mcp, the OpenAPI document, the op-call plane and the
|
|
// plugin route; building alone does not. Test used to skip it, so those four
|
|
// addresses answered 404 under test and 200 in production — a test could not see
|
|
// the surface it was written to check, and the closer the test got to the real
|
|
// program the more confidently it reported the wrong thing. Callers papered over
|
|
// it with an exported Prepare, which is why unexporting that broke them.
|
|
func (a *App) Test(req *http.Request, cfg ...TestConfig) (*http.Response, error) {
|
|
a.prepare()
|
|
if len(cfg) == 0 {
|
|
return a.router().Test(req)
|
|
}
|
|
c := cfg[0]
|
|
return a.router().Test(req, fiber.TestConfig{
|
|
Timeout: c.Timeout,
|
|
FailOnTimeout: c.FailOnTimeout,
|
|
})
|
|
}
|