mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
v1.24.2 shipped two fixes. The operation-id one stays and is untouched here: a declared WithOperationID survives composition verbatim (measured on hanzoai/o11y — 353 ops, 217 declare an id, all 217 were being renamed by a host prefix and now are not). The address one, App.Shadow(), is removed. Shadow existed to make an op declarable in one place and answerable in another: a service installing its published table of ops onto the router its implementation already serves on, a test registering a second handler at an address deliberately. Both are the same shape — a declaration and the handler that satisfies it, written apart, with a framework verb holding the two ends together. That is a braid, and a framework that grows a verb per braid grows one verb per caller. The fix belongs in the caller: a typed registration is ALREADY both halves, the handler the router installs and the op the document, MCP tool list, CLI and SDK all read, so the declaration and the answer can simply be the same line. Use composes definitions, Group scopes them, and between them every shape Shadow was written for is expressible with one claim per address. So the address check goes back to what it was, exactly: one registration at an address, both parties named when there are two, no scope and no option that can talk it out of an accident. addrClaim, addressErrors and addressRules are gone with it — 190 lines of walk.go that existed only to reason about the SHAPE of multiple claims at one address, a question the language no longer asks. Removed: App.Shadow, OpScope.Shadow, route.shadow, App.shadow, wrapRouter.shadow and yields, App.method's shadow parameter. wrapChain and OpScope lose their nil-wrap branches, which existed only for a Shadow-only scope with no middleware to compose. No caller outside this repo referenced any of it at a released tag. Tests: shadow_test.go deleted (250 lines, ten cases, all about the removed verb). oneclaim_test.go replaces it with three that pin the rule from both sides — the braid is refused with both parties named, one registration declares AND answers, and Use+Group place a definition's ops wherever a host wants them with one claim per address. Everything else is unchanged, including all of declaredid_test.go and the unnamedApp/billingApp split in compose_test.go, which are the id fix and not this one. 319 root-package tests pass on a clean testcache and under -race. Co-authored-by: Hanzo Dev <[email protected]>
264 lines
9.9 KiB
Go
264 lines
9.9 KiB
Go
package zip
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/zap-proto/zip/internal/jsonenc"
|
|
)
|
|
|
|
// Module is one loaded extension — the zip-side projection of the HIP-0105
|
|
// Module contract. [App.Module] mounts one of these at one route.
|
|
type Module interface {
|
|
Name() string
|
|
Runtime() string
|
|
Exports() []string
|
|
Invoke(ctx context.Context, fn string, payload []byte) ([]byte, error)
|
|
Close() error
|
|
}
|
|
|
|
// Loader loads extensions for [App.Module]. It is the zip-side projection of
|
|
// the HIP-0105 extension runtime contract and it lives HERE, at the root,
|
|
// because the root is what consumes it: [Config.Loader] holds one and
|
|
// [App.Module] calls it.
|
|
//
|
|
// The interface is duck-typed and zip does NOT import an implementation —
|
|
// so a consumer that never evaluates JavaScript never compiles one. Build your
|
|
// loader and pass it in:
|
|
//
|
|
// app := zip.New(zip.Config{Loader: myLoader})
|
|
//
|
|
// Duck-typed here means structural, not loose: LoadDir must return
|
|
// map[string][Module] EXACTLY. A backend that declares its own Module type
|
|
// (hanzoai/base/plugins/extruntime does) therefore satisfies this through a
|
|
// thin adapter that returns [Module], not by direct assignment — Go requires
|
|
// identical result types, and an equivalent method set is not enough.
|
|
//
|
|
// The in-tree JavaScript implementation is package
|
|
// [github.com/zap-proto/zip/js] — goja to evaluate and esbuild to
|
|
// bundle. It is a SEPARATE import on purpose: that cost belongs to the
|
|
// binaries that ask for JS, not to every consumer of zip.
|
|
type Loader interface {
|
|
// LoadDir scans a directory for extension manifests and returns
|
|
// loaded modules keyed by manifest name.
|
|
LoadDir(ctx context.Context, dir string) (map[string]Module, error)
|
|
|
|
// LoadOne loads a single extension by directory. zip uses this for
|
|
// [App.Module], which mounts ONE extension at one route. Implementers
|
|
// may implement it by calling LoadDir on the parent and selecting
|
|
// the result.
|
|
LoadOne(ctx context.Context, dir string) (Module, error)
|
|
|
|
// Runtimes returns the registered runtime names ("goja", "wazero",
|
|
// "v8go", "pyvm", "starlark", "native").
|
|
Runtimes() []string
|
|
}
|
|
|
|
// moduleEnvelope is the JSON shape every extension runtime receives.
|
|
// Same shape across wasm / goja / pyvm / starlark — the host serializes
|
|
// once and the guest sees the same bytes regardless of which engine ran
|
|
// it. RawMessage is from encoding/json (v1) because the type lives in
|
|
// v1 only; both v1 and v2 (encoding/json/v2) honor its MarshalJSON /
|
|
// UnmarshalJSON so the envelope serializes identically under either
|
|
// build of jsonenc.
|
|
type moduleEnvelope struct {
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
Params map[string]string `json:"params"`
|
|
Query map[string]string `json:"query"`
|
|
Headers map[string]string `json:"headers"`
|
|
Body json.RawMessage `json:"body,omitempty"`
|
|
|
|
// Hanzo identity — populated from gateway X-* headers per HIP-0026.
|
|
Org string `json:"org,omitempty"`
|
|
User string `json:"user,omitempty"`
|
|
UserEmail string `json:"userEmail,omitempty"`
|
|
}
|
|
|
|
// moduleResponse is the JSON shape every extension runtime returns.
|
|
type moduleResponse struct {
|
|
Status int `json:"status,omitempty"`
|
|
Headers map[string]string `json:"headers,omitempty"`
|
|
Body json.RawMessage `json:"body,omitempty"`
|
|
}
|
|
|
|
// Module mounts a single HIP-0105 extension at the given method+path — the
|
|
// one way to put an extension on the app. The `methodPath` form is
|
|
// "METHOD /path" (e.g. "POST /v1/validate"), matching the
|
|
// idiom. `runtime` selects the backing engine ("wasm" | "goja" | "pyvm" |
|
|
// "starlark" | "v8go" | "native"); `modulePath` is the directory containing
|
|
// the extension.json manifest.
|
|
//
|
|
// app.Module("POST /v1/policy/eval", "wasm", "./extensions/policy")
|
|
// app.Module("POST /v1/transform", "pyvm", "./extensions/transform")
|
|
// app.Module("POST /v1/webhook", "goja", "./extensions/webhook")
|
|
//
|
|
// The extension's exported function name is inferred from the path's last
|
|
// non-{param} segment, lowercased (e.g. "POST /v1/policy/eval" → "eval").
|
|
//
|
|
// # A module route registers a ROUTE and no op, and cannot register one
|
|
//
|
|
// So it is invisible to every projection: it is in no OpenAPI document, is no
|
|
// MCP tool, and no service can reach it with zip.Call. That is a real hole —
|
|
// the envelope in and out of a module IS a JSON contract — and it is stated
|
|
// here rather than left to be discovered, because the two reasons it cannot
|
|
// close today are both structural:
|
|
//
|
|
// 1. THERE IS NO TYPE TO DERIVE A SCHEMA FROM. A typed op's schema comes from
|
|
// its In/Out Go types. A module is LOADED at run time out of a directory —
|
|
// that is the whole point of an extension — so at the moment this route is
|
|
// registered zip holds a [Module] (Name/Runtime/Exports/Invoke) and no
|
|
// declaration of what the module accepts or returns. Registering it as an
|
|
// open object would put the path in the document while saying nothing
|
|
// about the contract, which is not the same thing as documenting it.
|
|
// 2. A TYPED OP CANNOT EXPRESS THE MODULE'S RESPONSE. A module answers with a
|
|
// moduleResponse — status, headers, body — and modules use it (a redirect,
|
|
// a 404, a non-JSON body). A typed op returns Out and zip decides the
|
|
// status, so routing this through registerTyped would silently drop a
|
|
// capability the envelope exists to provide.
|
|
//
|
|
// What closes it is (1): an extension that DECLARES its contract, via a
|
|
// manifest schema surfaced on [Module]. The op would then carry the module's
|
|
// own schema and every projection follows, with the route keeping the envelope
|
|
// it has now. Until an extension declares one there is nothing to project, and
|
|
// inventing a shape for it here would be a second source of truth for a
|
|
// contract zip does not own.
|
|
//
|
|
// A route whose contract IS known belongs in a typed op — declare it with
|
|
// [Get]/[Post] and let the module be an implementation detail behind it.
|
|
func (a *App) Module(methodPath, runtimeName, modulePath string) error {
|
|
method, path, fn, err := parseModuleSpec(methodPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if a.loader == nil {
|
|
return fmt.Errorf("zip: app.Module requires Config.Loader to be set")
|
|
}
|
|
if a.cfg.AllowedRuntimes != nil {
|
|
ok := false
|
|
for _, r := range a.cfg.AllowedRuntimes {
|
|
if r == runtimeName {
|
|
ok = true
|
|
break
|
|
}
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("zip: runtime %q not in AllowedRuntimes", runtimeName)
|
|
}
|
|
}
|
|
|
|
mod, err := a.loader.LoadOne(nil, filepath.Clean(modulePath))
|
|
if err != nil {
|
|
return fmt.Errorf("zip: load module %s: %w", modulePath, err)
|
|
}
|
|
if got := mod.Runtime(); got != runtimeName {
|
|
// Loader picked a different runtime than caller asked — likely a
|
|
// manifest mismatch. Refuse to mount.
|
|
_ = mod.Close()
|
|
return fmt.Errorf("zip: module %s declared runtime=%q, caller passed %q", modulePath, got, runtimeName)
|
|
}
|
|
|
|
a.logger.Info("zip mounting module",
|
|
"method", method, "path", path, "fn", fn,
|
|
"runtime", runtimeName, "modulePath", modulePath)
|
|
|
|
handler := func(c *Ctx) error {
|
|
env := buildEnvelope(c)
|
|
payload, err := jsonenc.Marshal(env)
|
|
if err != nil {
|
|
return ErrInternal("marshal envelope: " + err.Error())
|
|
}
|
|
out, err := mod.Invoke(c.Context(), fn, payload)
|
|
if err != nil {
|
|
return ErrInternal("module invoke: " + err.Error())
|
|
}
|
|
var resp moduleResponse
|
|
if len(out) > 0 {
|
|
if err := jsonenc.Unmarshal(out, &resp); err != nil {
|
|
// Treat bare JSON output as the body with status 200.
|
|
return c.Bytes(200, out)
|
|
}
|
|
}
|
|
if resp.Status == 0 {
|
|
resp.Status = 200
|
|
}
|
|
for k, v := range resp.Headers {
|
|
c.SetHeader(k, v)
|
|
}
|
|
if len(resp.Body) == 0 {
|
|
return c.NoContent(resp.Status)
|
|
}
|
|
// Default to application/json for module responses when no
|
|
// Content-Type was set by the module. Modules can override via
|
|
// the response envelope's headers map.
|
|
if resp.Headers["Content-Type"] == "" && resp.Headers["content-type"] == "" {
|
|
c.SetHeader("Content-Type", "application/json; charset=utf-8")
|
|
}
|
|
return c.Bytes(resp.Status, resp.Body)
|
|
}
|
|
a.method(method, path, []Handler{handler})
|
|
|
|
// Release the module's resources on shutdown. Runs as a teardown hook,
|
|
// i.e. AFTER in-flight requests drain, so a module stays live for any
|
|
// request still executing it.
|
|
a.OnShutdown(func(context.Context) error { return mod.Close() })
|
|
return nil
|
|
}
|
|
|
|
// parseModuleSpec parses "METHOD /path" into (method, path, fnName).
|
|
// fnName defaults to the last non-{param} path segment, lowercased.
|
|
//
|
|
// "POST /v1/validate" -> POST /v1/validate validate
|
|
// "GET /v1/users/{id}" -> GET /v1/users/{id} users
|
|
// "POST /v1/foo/bar/baz" -> POST /v1/foo/bar/baz baz
|
|
func parseModuleSpec(spec string) (method, path, fn string, err error) {
|
|
parts := strings.Fields(spec)
|
|
if len(parts) != 2 {
|
|
return "", "", "", fmt.Errorf("zip: module spec %q: want \"METHOD /path\"", spec)
|
|
}
|
|
method = strings.ToUpper(parts[0])
|
|
path = parts[1]
|
|
segs := strings.Split(strings.Trim(path, "/"), "/")
|
|
for i := len(segs) - 1; i >= 0; i-- {
|
|
s := segs[i]
|
|
if s != "" && !strings.HasPrefix(s, "{") && !strings.HasPrefix(s, ":") {
|
|
fn = strings.ToLower(s)
|
|
break
|
|
}
|
|
}
|
|
if fn == "" {
|
|
fn = "handler"
|
|
}
|
|
return method, path, fn, nil
|
|
}
|
|
|
|
func buildEnvelope(c *Ctx) moduleEnvelope {
|
|
headers := map[string]string{}
|
|
c.fc.Request().Header.VisitAll(func(k, v []byte) {
|
|
headers[string(k)] = string(v)
|
|
})
|
|
|
|
queries := map[string]string{}
|
|
c.fc.Request().URI().QueryArgs().VisitAll(func(k, v []byte) {
|
|
queries[string(k)] = string(v)
|
|
})
|
|
|
|
// Fiber v3 doesn't expose AllParams directly — common pattern is to
|
|
// pull known params. For the envelope shape we carry the body and let
|
|
// the guest re-parse path params from the URL if it needs them.
|
|
return moduleEnvelope{
|
|
Method: c.Method(),
|
|
Path: c.Path(),
|
|
Params: map[string]string{},
|
|
Query: queries,
|
|
Headers: headers,
|
|
Body: c.Body(),
|
|
Org: c.Org(),
|
|
User: c.User(),
|
|
UserEmail: c.UserEmail(),
|
|
}
|
|
}
|