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.
391 lines
12 KiB
Go
391 lines
12 KiB
Go
package zip
|
|
|
|
import (
|
|
"context"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The four method shapes a ZAP interface can declare, over two structs that are
|
|
// distinguishable only by name — which is the case a run-time type system gets
|
|
// wrong, because reflect interns two identical shapes into one type.
|
|
const echoZAP = `package echo
|
|
|
|
struct Ping {
|
|
Seq u64 @0
|
|
}
|
|
|
|
struct Pong {
|
|
Seq u64 @0
|
|
}
|
|
|
|
interface Echo {
|
|
ping(req: Ping) returns (resp: Pong)
|
|
notify(req: Ping)
|
|
health() returns (resp: Pong)
|
|
shutdown()
|
|
}
|
|
`
|
|
|
|
func readOne(t *testing.T, name, src string) *App {
|
|
t.Helper()
|
|
apps, err := ReadZAP(name, []byte(src))
|
|
if err != nil {
|
|
t.Fatalf("ReadZAP: %v", err)
|
|
}
|
|
if len(apps) != 1 {
|
|
t.Fatalf("want 1 app, got %d", len(apps))
|
|
}
|
|
return apps[0]
|
|
}
|
|
|
|
// A method becomes an op addressed at its own name, and the op's id is the
|
|
// method's — not one derived from the address, which is what lets a schema read
|
|
// here and projected back name the same methods.
|
|
func TestReadZAP_MethodsBecomeOps(t *testing.T) {
|
|
app := readOne(t, "echo.zap", echoZAP)
|
|
if app.Name() != "Echo" {
|
|
t.Errorf("app name = %q, want Echo (the interface's)", app.Name())
|
|
}
|
|
|
|
got := map[string]*registeredOp{}
|
|
for _, op := range app.Registry() {
|
|
got[opName(op)] = op
|
|
}
|
|
for _, want := range []string{"ping", "notify", "health", "shutdown"} {
|
|
op, ok := got[want]
|
|
if !ok {
|
|
t.Fatalf("no op %q; got %v", want, keysOf(got))
|
|
}
|
|
if op.Method != "POST" || op.Path != "/"+want {
|
|
t.Errorf("%s is %s %s, want POST /%s", want, op.Method, op.Path, want)
|
|
}
|
|
}
|
|
if len(got) != 4 {
|
|
t.Errorf("got %d ops, want 4: %v", len(got), keysOf(got))
|
|
}
|
|
|
|
// The four shapes: a direction that carries nothing is a struct with no
|
|
// fields, which every projection already reads as an absent payload.
|
|
for _, c := range []struct{ op, in, out string }{
|
|
{"ping", "Ping", "Pong"},
|
|
{"notify", "Ping", ""},
|
|
{"health", "", "Pong"},
|
|
{"shutdown", "", ""},
|
|
} {
|
|
if n := typeName(got[c.op].InType); n != c.in {
|
|
t.Errorf("%s takes %q, want %q", c.op, n, c.in)
|
|
}
|
|
if n := typeName(got[c.op].OutType); n != c.out {
|
|
t.Errorf("%s answers %q, want %q", c.op, n, c.out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Two declarations of the same shape are two types. reflect interns structurally
|
|
// identical types, so Ping and Pong would be one — and a method declared to
|
|
// answer Pong would answer Ping in every SDK generated from this schema.
|
|
func TestReadZAP_SameShapeDifferentNamesStayApart(t *testing.T) {
|
|
app := readOne(t, "echo.zap", echoZAP)
|
|
var ping, pong reflect.Type
|
|
for _, op := range app.Registry() {
|
|
if opName(op) == "ping" {
|
|
ping, pong = op.InType, op.OutType
|
|
}
|
|
}
|
|
if ping == pong {
|
|
t.Fatalf("Ping and Pong collapsed into one type: %s", ping)
|
|
}
|
|
if typeName(ping) != "Ping" || typeName(pong) != "Pong" {
|
|
t.Fatalf("names lost: %q and %q", typeName(ping), typeName(pong))
|
|
}
|
|
}
|
|
|
|
// The JSON edge keeps the schema's own spelling, so a field the schema wrote in
|
|
// lower case crosses under the name it was written with even though the Go field
|
|
// has to be exported to be reachable by reflection at all.
|
|
func TestReadZAP_FieldKeepsItsDeclaredName(t *testing.T) {
|
|
app := readOne(t, "s.zap", `package s
|
|
struct In {
|
|
seq u64 @0
|
|
}
|
|
interface S {
|
|
go_(req: In)
|
|
}
|
|
`)
|
|
in := app.Registry()[0].InType
|
|
f := in.Field(0)
|
|
if f.Name != "Seq" {
|
|
t.Errorf("Go field = %q, want Seq (exported so reflection can read it)", f.Name)
|
|
}
|
|
if got := f.Tag.Get("json"); got != "seq" {
|
|
t.Errorf("json name = %q, want seq (the schema's own spelling)", got)
|
|
}
|
|
}
|
|
|
|
// Every schema type has a Go type, and the layout the schema states is the one
|
|
// this process derives.
|
|
func TestReadZAP_TypesAndOffsets(t *testing.T) {
|
|
app := readOne(t, "wide.zap", `package wide
|
|
type id32 = bytes_fixed[32]
|
|
|
|
struct Wide {
|
|
Flag bool @0
|
|
Small u8 @1
|
|
Mid i32 @4
|
|
Big u64 @8
|
|
Name text @16
|
|
Blob bytes @24
|
|
Ids list<u64> @32
|
|
Id id32 @40
|
|
Ratio f64 @72
|
|
}
|
|
|
|
interface W {
|
|
take(req: Wide)
|
|
}
|
|
`)
|
|
in := app.Registry()[0].InType
|
|
want := []struct {
|
|
name string
|
|
kind string
|
|
}{
|
|
{"Flag", "bool"}, {"Small", "uint8"}, {"Mid", "int32"}, {"Big", "uint64"},
|
|
{"Name", "string"}, {"Blob", "[]uint8"}, {"Ids", "[]uint64"},
|
|
{"Id", "[32]uint8"}, {"Ratio", "float64"},
|
|
}
|
|
if in.NumField() != len(want) {
|
|
t.Fatalf("got %d fields, want %d", in.NumField(), len(want))
|
|
}
|
|
for i, w := range want {
|
|
f := in.Field(i)
|
|
if f.Name != w.name || f.Type.String() != w.kind {
|
|
t.Errorf("field %d is %s %s, want %s %s", i, f.Name, f.Type, w.name, w.kind)
|
|
}
|
|
}
|
|
// The offsets the schema stated are the offsets LayoutOf derives — checked
|
|
// by ReadZAP itself, and restated here so a change to either is visible.
|
|
shape, err := LayoutOf(in)
|
|
if err != nil {
|
|
t.Fatalf("LayoutOf: %v", err)
|
|
}
|
|
for i, off := range []int{0, 1, 4, 8, 16, 24, 32, 40, 72} {
|
|
if shape.Slots[i].Offset != off {
|
|
t.Errorf("%s lays out at @%d, schema says @%d", shape.Slots[i].Name, shape.Slots[i].Offset, off)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A .zap file states an offset and LayoutOf derives one. Where they differ the
|
|
// schema describes a wire this process does not speak, so it is refused rather
|
|
// than silently re-laid-out — and the refusal carries both numbers, because the
|
|
// author needs to know which one moved.
|
|
//
|
|
// The case is not contrived: the IDL's own offset assignment PACKS, and this
|
|
// layout ALIGNS each slot to its own width, so any i32 followed by a wider field
|
|
// diverges.
|
|
func TestReadZAP_RefusesAnOffsetItCannotKeep(t *testing.T) {
|
|
_, err := ReadZAP("packed.zap", []byte(`package p
|
|
struct Packed {
|
|
User text @0
|
|
Count i32 @8
|
|
Rest u64 @12
|
|
}
|
|
interface P {
|
|
take(req: Packed)
|
|
}
|
|
`))
|
|
if err == nil {
|
|
t.Fatal("a packed offset was accepted; an SDK from this schema would encode at the wrong bytes")
|
|
}
|
|
for _, want := range []string{"Packed.Rest", "@12", "@16"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("refusal does not mention %s: %v", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Every field that disagrees is named. A work list that stopped at the first
|
|
// understates the change by however many the schema has.
|
|
func TestReadZAP_NamesEveryOffsetThatDisagrees(t *testing.T) {
|
|
_, err := ReadZAP("packed.zap", []byte(`package p
|
|
struct A {
|
|
N i32 @0
|
|
X u64 @4
|
|
}
|
|
struct B {
|
|
N i32 @0
|
|
Y text @4
|
|
}
|
|
interface P {
|
|
a(req: A)
|
|
b(req: B)
|
|
}
|
|
`))
|
|
if err == nil {
|
|
t.Fatal("accepted two packed structs")
|
|
}
|
|
if !strings.Contains(err.Error(), "A.X") || !strings.Contains(err.Error(), "B.Y") {
|
|
t.Errorf("refusal names only some of the disagreements: %v", err)
|
|
}
|
|
}
|
|
|
|
// A schema that declares data and no service is a good schema with nothing to
|
|
// project. Answering with an empty registry instead is the failure this whole
|
|
// path exists to end.
|
|
func TestReadZAP_RefusesASchemaWithNoInterface(t *testing.T) {
|
|
_, err := ReadZAP("data.zap", []byte("package d\nstruct D {\n X u64 @0\n}\n"))
|
|
if err == nil || !strings.Contains(err.Error(), "no interface") {
|
|
t.Fatalf("want a refusal naming the absent interface, got %v", err)
|
|
}
|
|
}
|
|
|
|
// One app per interface, which is exactly what ZAPSchema takes on the way out.
|
|
func TestReadZAP_OneAppPerInterface(t *testing.T) {
|
|
apps, err := ReadZAP("two.zap", []byte(`package t
|
|
struct X { N u64 @0 }
|
|
interface A { one(req: X) }
|
|
interface B { two(req: X) }
|
|
`))
|
|
if err != nil {
|
|
t.Fatalf("ReadZAP: %v", err)
|
|
}
|
|
if len(apps) != 2 || apps[0].Name() != "A" || apps[1].Name() != "B" {
|
|
t.Fatalf("want apps A and B, got %d", len(apps))
|
|
}
|
|
if n := len(apps[0].Registry()); n != 1 {
|
|
t.Errorf("A has %d ops, want 1", n)
|
|
}
|
|
}
|
|
|
|
func TestReadZAP_Refusals(t *testing.T) {
|
|
cases := map[string]struct{ src, says string }{
|
|
"a struct that is not declared": {
|
|
src: "package p\ninterface P {\n f(req: Nope)\n}\n",
|
|
says: "not declared",
|
|
},
|
|
"a struct that reaches itself": {
|
|
src: "package p\nstruct S {\n Me S @0\n}\ninterface P {\n f(req: S)\n}\n",
|
|
says: "contains itself",
|
|
},
|
|
"two methods of one name": {
|
|
src: "package p\nstruct X { N u64 @0 }\ninterface P {\n f(req: X)\n f(req: X)\n}\n",
|
|
says: "declared twice",
|
|
},
|
|
"two fields that are one Go name": {
|
|
src: "package p\nstruct X {\n my_id u64 @0\n myId u64 @8\n}\ninterface P {\n f(req: X)\n}\n",
|
|
says: "MyId",
|
|
},
|
|
"a list of lists": {
|
|
src: "package p\nstruct X {\n N list<list<u64>> @0\n}\ninterface P {\n f(req: X)\n}\n",
|
|
says: "slice of slices",
|
|
},
|
|
}
|
|
for name, c := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := ReadZAP("t.zap", []byte(c.src)); err == nil {
|
|
t.Fatal("accepted")
|
|
} else if !strings.Contains(err.Error(), c.says) {
|
|
t.Errorf("refusal does not say %q: %v", c.says, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A schema states what an op takes and answers, never what it does. Every way
|
|
// into a declared op refuses the same way, so nothing can reach a handler that
|
|
// was never written.
|
|
func TestReadZAP_ADeclaredOpDoesNotRun(t *testing.T) {
|
|
app := readOne(t, "echo.zap", echoZAP)
|
|
var op *registeredOp
|
|
for _, o := range app.Registry() {
|
|
if opName(o) == "ping" {
|
|
op = o
|
|
}
|
|
}
|
|
if _, err := op.direct(context.Background(), nil); err == nil {
|
|
t.Error("a declared op ran")
|
|
} else if !strings.Contains(err.Error(), "no implementation") {
|
|
t.Errorf("refusal does not say why: %v", err)
|
|
}
|
|
if _, err := op.invoke(context.Background(), input{}); err == nil {
|
|
t.Error("a declared op ran over a transport")
|
|
}
|
|
}
|
|
|
|
// ---- the loop --------------------------------------------------------------
|
|
|
|
type rtHeight struct {
|
|
Height uint64
|
|
Name string
|
|
}
|
|
|
|
type rtRange struct {
|
|
From uint64
|
|
To uint64
|
|
Blocks []uint64
|
|
Memo []byte
|
|
}
|
|
|
|
// The inverse is exact. An app projected to a schema, read back and projected
|
|
// again is the same text: same structs, same offsets, same interface, same
|
|
// method names. Anything this file decided that the forward projection did not
|
|
// would show up here as a difference.
|
|
func TestReadZAP_ProjectsBackToTheSameSchema(t *testing.T) {
|
|
a := New(Config{AppName: "probe"})
|
|
a.Get("/v1/height", nop[rtHeight, rtHeight])
|
|
a.Post("/v1/range", nop[rtRange, rtRange])
|
|
a.Delete("/v1/range", nop[rtRange, rtRange])
|
|
|
|
first := ZAPSchema("probe", a).String()
|
|
|
|
apps, err := ReadZAP("probe.zap", []byte(first))
|
|
if err != nil {
|
|
t.Fatalf("reading back what this package just wrote: %v", err)
|
|
}
|
|
if len(apps) != 1 {
|
|
t.Fatalf("want 1 app, got %d", len(apps))
|
|
}
|
|
second := ZAPSchema("probe", apps[0]).String()
|
|
|
|
if first != second {
|
|
t.Errorf("the loop does not close:\n--- written ---\n%s\n--- read and written again ---\n%s", first, second)
|
|
}
|
|
// And the structs really are named — a loop that closed on two anonymous
|
|
// spellings would prove nothing.
|
|
if !strings.Contains(second, "struct rtHeight {") || !strings.Contains(second, "struct rtRange {") {
|
|
t.Errorf("the round trip lost the struct names:\n%s", second)
|
|
}
|
|
}
|
|
|
|
// The SDK is the point: a schema in, a client with one method per declared
|
|
// method and one struct per declared struct out.
|
|
func TestReadZAP_ProjectsAnSDK(t *testing.T) {
|
|
app := readOne(t, "echo.zap", echoZAP)
|
|
res, err := app.RustSDK("demo")
|
|
if err != nil {
|
|
t.Fatalf("RustSDK: %v", err)
|
|
}
|
|
src := string(res.Source)
|
|
for _, want := range []string{
|
|
"pub struct Ping {", "pub struct Pong {",
|
|
"pub async fn ping(&self, input: &Ping, ) -> Result<Pong, Error>",
|
|
"pub async fn notify(&self, input: &Ping) -> Result<(), Error>",
|
|
"pub async fn health(&self, ) -> Result<Pong, Error>",
|
|
"pub async fn shutdown(&self, ) -> Result<(), Error>",
|
|
} {
|
|
if !strings.Contains(src, want) {
|
|
t.Errorf("the SDK is missing %q:\n%s", want, src)
|
|
}
|
|
}
|
|
}
|
|
|
|
func keysOf[V any](m map[string]V) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|