Files
zeekay 999e2153b3 Type every wire: bytes, forms, streams, sockets, redirects, unions
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.
2026-10-09 09:42:00 -07:00

288 lines
11 KiB
Go

package zip
import (
"cmp"
"context"
"encoding/json"
"fmt"
"strings"
"github.com/valyala/fasthttp"
)
// A remote service is a LEAF, not a verb.
//
// [Proxy] 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 proxy 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.
// Proxy returns another service, running elsewhere, as a DEFINITION the host
// composes with Use like any other:
//
// ledger, err := zip.Proxy("/v1/ledger", "ledger.hanzo.svc:9653", decl)
// if err != nil { return err }
// app.Use(ledger)
//
// It is no longer a VERB on App. Delegating to another process is not a second
// kind of composition — it is a definition whose handlers happen to be a
// network away — so it arrives through the one verb and appears in every
// projection because it is in the registry, not because five projections each
// learned what mounting meant.
//
// # Why the name changed
//
// This was Mount, and Mount already meant something else to everyone reading it:
// 118 of the fleet's ~140 subsystems export
// `func Mount(app cloud.Router, deps cloud.Deps) error`, the function by which an
// app registers ITSELF. One name, two unrelated shapes — a caller who knows one
// learns nothing about the other, and a reader seeing `Mount` in a diff cannot
// tell which is meant without checking the arity.
//
// The verb was also wrong on its own terms, which is what makes the rename a
// correction and not a coat of paint. The doc above already says composition
// went to Use; a function that returns a value is a NOUN.
//
// Proxy is what it builds. Every route it registers runs the handler this file
// calls `proxy`, and that handler's whole body is `forward(...)` to the address
// given. The term is the term of art for a local stand-in whose handlers hand
// the request to someone else, so it needs no gloss.
//
// Not Remote, which was the first choice and is wrong: [Remote] already exists
// here, and correctly — it is the CALLER's side, a client that executes commands
// against a service running elsewhere. This is the SERVER's side: a definition
// that stands in for that service inside this program's own routing table. Two
// sides of one wire are two things, and the compiler said so.
//
// The address scheme selects the transport exactly as [App.Listen]'s does, so a
// bare address is ZAP and one registry serves both directions.
//
// decl is optional and is a BUILD INPUT, never a fetch. Without it the remote
// contributes its two proxy addresses and no ops, which honestly says nothing
// is known about the shape behind it. With it, the remote's ops join the
// document, the tool list, the commands and the call plane.
func Proxy(prefix, addr string, decl ...Declaration) (*App, error) {
var d Declaration
if len(decl) > 0 {
d = decl[0]
}
return remoteApp(newApp(Config{DisableStartupMessage: true}), prefix, addr, d)
}
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.Context(), c.fc.Request(), c.fc.Response(), client, hostport, "", "mount "+prefix, hold{})
}
if len(d.Routes) == 0 {
// Nothing declared: the prefix and everything under it, which is what a
// bare Proxy has always registered.
p := trimPrefix(prefix)
r.raw(here(1), methodAll, p, []Handler{proxy})
r.raw(here(1), methodAll, p+"/*", []Handler{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 {
// A Declaration is a BUILD INPUT — JSON from another team — and fiber
// refuses a method it does not know by PANICKING inside register(). A
// panic is not a refusal: it unwinds past the transaction and leaves the
// program poisoned. Validate at the boundary the input crosses, so a bad
// declaration is an error like every other bad declaration.
if !knownMethod(rt.Method) {
return nil, Errorf(400, "zip: Proxy(%s): %q is not an HTTP method zip can route (route %q)",
prefix, rt.Method, rt.Pattern)
}
if rt.Op == "" {
r.raw(here(1), 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: &registeredOp{
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, input) (any, error) {
return func(ctx context.Context, in input) (any, error) {
args := map[string]any{}
if len(in.body) > 0 {
if err := in.dec(in.body, &args); err != nil {
return nil, ErrBadRequest("invalid body: " + err.Error())
}
}
for k, v := range in.query {
args[k] = v
}
for k, v := range in.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.
// Substituted by SEGMENT rather than by name: a wildcard's segment is "*",
// which is not ":name" and so cannot be replaced by spelling one. The value
// may arrive under either name the segment goes by.
segs := strings.Split(pattern, "/")
for _, p := range pathParams(pattern) {
v, ok := args[p.Name]
if !ok {
v, ok = args[p.Key]
}
if !ok {
return nil, ErrBadRequest("missing path parameter: " + p.Name)
}
segs[p.At] = urlEscape(toStr(v))
delete(args, p.Name)
delete(args, p.Key)
}
url := strings.Join(segs, "/")
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(mimeJSON)
}
forwardIdentity(ctx, req)
if err := do(ctx, client, req, resp); err != nil {
if ended(err) {
return nil, fmt.Errorf("zip: call %s %s at %s: %w", method, url, host, err)
}
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)
}
}
// knownMethod reports whether zip can register this method. The list is fiber's
// own; anything else is a typo or a protocol zip does not speak, and either way
// it must not reach the router.
func knownMethod(m string) bool {
switch strings.ToUpper(m) {
case "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "CONNECT", "OPTIONS", "TRACE", methodAll:
return true
}
return false
}
// 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), "/")
}