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.
136 lines
4.3 KiB
Go
136 lines
4.3 KiB
Go
package zip
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// A DECLARED operation id survives composition, verbatim.
|
|
//
|
|
// This is the o11y shape, minimised: a service declares its ops with the names
|
|
// it publishes, and a host includes it under a prefix. Before this rule the
|
|
// prefix was folded into every id — `CreateRole` became `v1.o11y.CreateRole` —
|
|
// so composing the service renamed all 353 of its published operations, and
|
|
// with them every MCP tool an agent had cached, every generated SDK method,
|
|
// every CLI command and every operationId in the document. A wiring line is not
|
|
// allowed to do that.
|
|
//
|
|
// The assertion is deliberately on ALL FIVE projections that read the id, not
|
|
// just the document, because the defect was in the one place they all read.
|
|
|
|
type roleIn struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type roleOut struct {
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
// service is the o11y shape: a definition that declares its own published ids.
|
|
func service() *App {
|
|
s := quiet("o11y")
|
|
s.Post("/roles", func(_ context.Context, in *roleIn) (*roleOut, error) {
|
|
return &roleOut{ID: in.Name}, nil
|
|
}, WithOperationID("CreateRole"))
|
|
return s
|
|
}
|
|
|
|
func TestDeclaredOperationIDSurvivesComposition(t *testing.T) {
|
|
host := quiet("host")
|
|
host.Group("/v1/o11y").Use(service())
|
|
if err := host.Build(); err != nil {
|
|
t.Fatalf("build: %v", err)
|
|
}
|
|
|
|
reg := host.Registry()
|
|
if len(reg) != 1 {
|
|
t.Fatalf("registry has %d ops, want 1", len(reg))
|
|
}
|
|
if got := reg[0].OperationID; got != "CreateRole" {
|
|
t.Errorf("registry: operation id = %q, want %q — an explicitly declared id must "+
|
|
"survive composition verbatim, not be qualified by the host's prefix", got, "CreateRole")
|
|
}
|
|
// The path IS composed — only the NAME is the author's.
|
|
if got := reg[0].Path; got != "/v1/o11y/roles" {
|
|
t.Errorf("path = %q, want /v1/o11y/roles — composition still owns the address", got)
|
|
}
|
|
|
|
// The document.
|
|
doc := host.OpenAPISpec()
|
|
paths, _ := doc["paths"].(map[string]map[string]any)
|
|
post, _ := paths["/v1/o11y/roles"]["post"].(map[string]any)
|
|
if post == nil {
|
|
t.Fatalf("no post operation at /v1/o11y/roles: %v", paths)
|
|
}
|
|
if got := post["operationId"]; got != "CreateRole" {
|
|
t.Errorf("document: operationId = %v, want CreateRole", got)
|
|
}
|
|
|
|
// The MCP tool name.
|
|
_, tools, _ := host.composeTools()
|
|
if !tools["CreateRole"] {
|
|
var got []string
|
|
for n := range tools {
|
|
got = append(got, n)
|
|
}
|
|
t.Errorf("MCP tool names = %v, want CreateRole", got)
|
|
}
|
|
|
|
// The by-name call plane.
|
|
if op := host.opByName("CreateRole"); op == nil {
|
|
t.Errorf("CreateRole is not resolvable by name")
|
|
}
|
|
}
|
|
|
|
// The other half of the rule, and the reason the derivation exists at all: an
|
|
// op that declares NO id takes the one its ABSOLUTE path spells, so one
|
|
// definition included twice yields two distinguishable ids and neither is a
|
|
// function of mount order.
|
|
func TestUndeclaredOperationIDComesFromTheAbsolutePath(t *testing.T) {
|
|
anon := quiet("anon")
|
|
anon.Get("/invoices/:id", func(_ context.Context, in *invoiceIn) (*invoiceOut, error) {
|
|
return &invoiceOut{ID: in.ID}, nil
|
|
})
|
|
|
|
host := quiet("host")
|
|
host.Group("/v1").Use(anon)
|
|
host.Group("/admin").Use(anon)
|
|
if err := host.Build(); err != nil {
|
|
t.Fatalf("build: %v", err)
|
|
}
|
|
|
|
got := map[string]bool{}
|
|
for _, op := range host.Registry() {
|
|
got[op.OperationID] = true
|
|
}
|
|
for _, want := range []string{"get_invoices_by_id", "get_admin_invoices_by_id"} {
|
|
if !got[want] {
|
|
t.Errorf("id %q missing from %v — an UNDECLARED id must be derived from the "+
|
|
"absolute path its occurrence answers at", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Where the two rules meet: a DECLARED id and two occurrences is a refused
|
|
// program, not a silent rename. The refusal is what makes the survival rule
|
|
// safe — the ambiguity is reported to the author rather than resolved behind
|
|
// their back by editing their published name.
|
|
func TestDeclaredOperationIDIncludedTwiceIsRefused(t *testing.T) {
|
|
svc := service() // ONE definition, two inclusion sites
|
|
host := quiet("host")
|
|
host.Group("/v1").Use(svc)
|
|
host.Group("/admin").Use(svc)
|
|
|
|
err := host.Build()
|
|
if err == nil {
|
|
t.Fatalf("two occurrences of a declared id must be refused")
|
|
}
|
|
msg := err.Error()
|
|
for _, want := range []string{"CreateRole", "declared once and occurs twice", "WithOperationID"} {
|
|
if !strings.Contains(msg, want) {
|
|
t.Errorf("refusal does not mention %q: %s", want, msg)
|
|
}
|
|
}
|
|
}
|