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.
89 lines
2.7 KiB
Go
89 lines
2.7 KiB
Go
// SPDX-License-Identifier: BSD-3-Clause-Eco
|
|
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package zip_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// A streamed answer is published as the stream it is. Without the manifest's
|
|
// stream field an op with no Out projected as a 204, which is a document
|
|
// promising an empty answer from an op that answers for minutes.
|
|
|
|
func streamed() zip.Manifest {
|
|
return zip.Manifest{
|
|
Manifest: zip.ManifestVersion,
|
|
App: "chat",
|
|
Ops: []zip.ManifestOp{
|
|
{Method: "POST", Path: "/v1/chat", In: "Prompt", Stream: "sse"},
|
|
{Method: "POST", Path: "/v1/relay", Raw: true, Stream: "bytes"},
|
|
},
|
|
Types: []zip.TypeDesc{{
|
|
ID: "Prompt", Name: "Prompt", Kind: "struct",
|
|
Fields: []zip.FieldDesc{{Name: "text", JSON: "text", Type: zip.TypeRef{Prim: "string"}}},
|
|
}},
|
|
}
|
|
}
|
|
|
|
func TestProjectOpenAPIStreams(t *testing.T) {
|
|
m := streamed()
|
|
if err := m.Check(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.Settle()
|
|
b, err := json.Marshal(zip.ProjectOpenAPI(m))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var doc struct {
|
|
Paths map[string]map[string]struct {
|
|
RequestBody struct {
|
|
Content map[string]json.RawMessage `json:"content"`
|
|
} `json:"requestBody"`
|
|
Responses map[string]struct {
|
|
Content map[string]json.RawMessage `json:"content"`
|
|
} `json:"responses"`
|
|
} `json:"paths"`
|
|
}
|
|
if err := json.Unmarshal(b, &doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chat := doc.Paths["/v1/chat"]["post"]
|
|
if _, ok := chat.Responses["200"].Content["text/event-stream"]; !ok {
|
|
t.Errorf("chat answers %s, want a text/event-stream 200", b)
|
|
}
|
|
if _, ok := chat.Responses["204"]; ok {
|
|
t.Errorf("chat is published as answering nothing")
|
|
}
|
|
if _, ok := chat.RequestBody.Content["application/json"]; !ok {
|
|
t.Errorf("chat's prompt is not a JSON body")
|
|
}
|
|
relay := doc.Paths["/v1/relay"]["post"]
|
|
if got := string(relay.Responses["200"].Content["application/octet-stream"]); !strings.Contains(got, `"binary"`) {
|
|
t.Errorf("relay answers %q, want binary", got)
|
|
}
|
|
if got := string(relay.RequestBody.Content["application/octet-stream"]); !strings.Contains(got, `"binary"`) {
|
|
t.Errorf("relay reads %q, want binary", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckRefusesACrookedStream(t *testing.T) {
|
|
for name, op := range map[string]zip.ManifestOp{
|
|
"unknown stream": {Method: "GET", Path: "/a", Stream: "ws"},
|
|
"socket and out": {Method: "GET", Path: "/a", Stream: "socket", Out: "Prompt"},
|
|
"raw and in": {Method: "POST", Path: "/a", Raw: true, In: "Prompt"},
|
|
} {
|
|
m := streamed()
|
|
m.Ops = []zip.ManifestOp{op}
|
|
if err := m.Check(); err == nil {
|
|
t.Errorf("%s: checked clean", name)
|
|
}
|
|
}
|
|
}
|