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]>
160 lines
5.5 KiB
Go
160 lines
5.5 KiB
Go
package zip
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// remoteService starts a real zip app on a unix socket and returns its address
|
|
// and its own declaration — the two things a host needs, and the second of
|
|
// which it must be HANDED rather than go and ask for.
|
|
func remoteService(t *testing.T) (string, Declaration) {
|
|
t.Helper()
|
|
svc := quiet("ledger")
|
|
svc.Get("/v1/ledger/entries/:id", func(_ context.Context, in *invoiceIn) (*invoiceOut, error) {
|
|
return &invoiceOut{ID: in.ID, Total: 7}, nil
|
|
}, WithOperationID("getEntry"))
|
|
svc.Raw("GET", "/v1/ledger/raw", func(c *Ctx) error { return c.String(200, "raw ledger") })
|
|
|
|
sock := filepath.Join(sockDir(t), "ledger.sock")
|
|
go func() { _ = svc.Listen(sock) }()
|
|
t.Cleanup(func() { _ = svc.Shutdown() })
|
|
|
|
// Wait for the SOCKET, not for App.listening. Listen increments that counter
|
|
// as soon as it has constructed the servers and before the goroutines it
|
|
// spawns have bound anything, so a reader that trusts it races the bind and
|
|
// gets ECONNREFUSED from a mount that is perfectly correct. Dialling is the
|
|
// only question whose answer means the service is reachable.
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
c, err := net.Dial("unix", sock)
|
|
if err == nil {
|
|
_ = c.Close()
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("service never bound %s: %v", sock, err)
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
return sock, svc.Declaration()
|
|
}
|
|
|
|
// TestProxy_DeclarationIsAnInputNotAFetch is the rule the whole design hangs
|
|
// on: building a program does no I/O. The address here goes nowhere, and the
|
|
// registry, the document and the tool list are still complete — because the
|
|
// declaration was handed in.
|
|
//
|
|
// If the walk dialled instead, this test could not exist: Registry() would be
|
|
// fallible and slow, and an OpenAPI document would depend on some other process
|
|
// being up at boot.
|
|
func TestProxy_DeclarationIsAnInputNotAFetch(t *testing.T) {
|
|
host := quiet("cloud")
|
|
dead := "/nonexistent/nothing-listens-here.sock"
|
|
host.Use(mustApp(Proxy("/v1/ledger", dead, Declaration{
|
|
Name: "ledger",
|
|
Routes: []Route{
|
|
{Method: "GET", Pattern: "/v1/ledger/entries/:id", Op: "getEntry"},
|
|
{Method: "GET", Pattern: "/v1/ledger/raw"},
|
|
},
|
|
})))
|
|
if err := build(host); err != nil {
|
|
t.Fatalf("build: %v", err)
|
|
}
|
|
|
|
reg := host.Registry()
|
|
if len(reg) != 1 {
|
|
t.Fatalf("registry has %d ops, want 1 (the remote's declared op)", len(reg))
|
|
}
|
|
if reg[0].OperationID != "getEntry" || reg[0].Path != "/v1/ledger/entries/:id" {
|
|
t.Errorf("remote op = %s %s, want getEntry /v1/ledger/entries/:id", reg[0].OperationID, reg[0].Path)
|
|
}
|
|
if reg[0].Origin != "ledger" {
|
|
t.Errorf("remote op origin = %q, want ledger", reg[0].Origin)
|
|
}
|
|
// The document names it too — a mounted service is in the projections
|
|
// because it is in the registry, not because the document learned to mount.
|
|
spec := host.OpenAPISpec()
|
|
if _, ok := spec["paths"].(map[string]map[string]any)["/v1/ledger/entries/{id}"]; !ok {
|
|
t.Errorf("the document does not carry the mounted op")
|
|
}
|
|
// And nothing was dialled: an unreachable address built a complete program.
|
|
}
|
|
|
|
// TestProxy_ServesAndCallsTheRemote: the leaf's routes proxy and its ops
|
|
// forward, over the same transport, to a service that really is somewhere else.
|
|
func TestProxy_ServesAndCallsTheRemote(t *testing.T) {
|
|
sock, decl := remoteService(t)
|
|
if len(decl.Routes) != 2 {
|
|
t.Fatalf("the service declared %d routes, want 2: %+v", len(decl.Routes), decl.Routes)
|
|
}
|
|
var typedOp string
|
|
for _, r := range decl.Routes {
|
|
if r.Pattern == "/v1/ledger/entries/:id" {
|
|
typedOp = r.Op
|
|
}
|
|
}
|
|
if typedOp != "getEntry" {
|
|
t.Fatalf("the declaration does not name the op for its typed route: %+v", decl.Routes)
|
|
}
|
|
|
|
host := quiet("cloud")
|
|
host.Use(mustApp(Proxy("/v1/ledger", sock, decl)))
|
|
if err := host.Build(); err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
|
|
// 1. The proxied route reaches the remote's own handler.
|
|
if code, body := wireGET(t, host, "/v1/ledger/raw"); code != 200 || body != "raw ledger" {
|
|
t.Errorf("proxied route: %d %q", code, body)
|
|
}
|
|
if code, body := wireGET(t, host, "/v1/ledger/entries/e-9"); code != 200 || !strings.Contains(body, "e-9") {
|
|
t.Errorf("proxied typed route: %d %q", code, body)
|
|
}
|
|
// 2. An address the remote did NOT declare falls through to the host.
|
|
if code, _ := wireGET(t, host, "/v1/ledger/undeclared"); code != 404 {
|
|
t.Errorf("undeclared remote path answered %d, want 404 — the mount is swallowing", code)
|
|
}
|
|
// 3. The op forwards BY NAME, so the tool an agent sees runs the remote's
|
|
// handler rather than finding a hole at the process boundary.
|
|
out := rpcOn(t, host,
|
|
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"getEntry","arguments":{"id":"e-3"}}}`)
|
|
if !strings.Contains(out, "e-3") {
|
|
t.Errorf("tools/call on a mounted op did not reach the remote: %s", out)
|
|
}
|
|
}
|
|
|
|
func rpcOn(t *testing.T, a *App, body string) string {
|
|
t.Helper()
|
|
req := postReq("/mcp", "application/json", []byte(body))
|
|
resp, err := a.Fiber().Test(req)
|
|
if err != nil {
|
|
t.Fatalf("POST /mcp: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
return readAll(t, resp.Body)
|
|
}
|
|
|
|
func postReq(path, ctype string, body []byte) *http.Request {
|
|
req := httptest.NewRequest("POST", path, bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", ctype)
|
|
return req
|
|
}
|
|
|
|
func readAll(t *testing.T, r io.Reader) string {
|
|
t.Helper()
|
|
b, err := io.ReadAll(r)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
return string(b)
|
|
}
|