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.
288 lines
11 KiB
Go
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: ®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, 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), "/")
|
|
}
|