Files
zip/parser_test.go
zeekay 999e2153b3 Type every wire: bytes, forms, streams, sockets, redirects, unions
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.
2026-10-09 09:42:00 -07:00

133 lines
4.2 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 (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"sort"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// An input that decodes its own body — YAML or JSON, an RFC 7386 merge patch, a
// strict decode that refuses an unknown field, a cap on its own size — is a
// zip.Parser, and stays a typed op: the document publishes its reflected schema
// under the media it consumes.
type patchIn struct {
ID string `json:"id" url:"id"`
Name *string `json:"name"`
Tags []string `json:"tags"`
// sent names the members the patch carried, null ones included: RFC 7386
// tells "clear it" from "leave it" by presence.
sent []string
}
const patchCap = 64
func (p *patchIn) Parse(media string, body []byte) error {
if len(body) > patchCap {
return zip.Errorf(413, "a patch is at most %d bytes", patchCap)
}
if strings.HasPrefix(media, "application/yaml") {
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok || strings.TrimSpace(k) != "name" {
return fmt.Errorf("line %q is not name: <value>", line)
}
name := strings.TrimSpace(v)
p.Name, p.sent = &name, append(p.sent, "name")
}
return nil
}
var members map[string]json.RawMessage
if err := json.Unmarshal(body, &members); err != nil {
return err
}
for k := range members {
p.sent = append(p.sent, k)
}
sort.Strings(p.sent)
d := json.NewDecoder(bytes.NewReader(body))
d.DisallowUnknownFields()
return d.Decode(p)
}
type patchOut struct {
ID string `json:"id"`
Name *string `json:"name"`
Sent []string `json:"sent"`
}
func patchApp() *zip.App {
app := zip.New(zip.Config{AppName: "rec", DisableStartupMessage: true})
app.Patch("/v1/records/:id", func(_ context.Context, in *patchIn) (*patchOut, error) {
return &patchOut{ID: in.ID, Name: in.Name, Sent: in.sent}, nil
}, zip.Consumes("application/merge-patch+json", "application/yaml"))
return app
}
func sendPatch(t *testing.T, app *zip.App, media, body string) (int, string) {
t.Helper()
req := httptest.NewRequest("PATCH", "/v1/records/r1", strings.NewReader(body))
req.Header.Set("Content-Type", media)
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
var b bytes.Buffer
_, _ = b.ReadFrom(resp.Body)
return resp.StatusCode, b.String()
}
func TestParser_DecodesItsOwnBody(t *testing.T) {
app := patchApp()
for _, tc := range []struct {
media, body string
code int
has string
}{
{"application/merge-patch+json", `{"name":null}`, 200, `"id":"r1","name":null,"sent":["name"]`},
{"application/merge-patch+json", `{"name":"b","tags":["x"]}`, 200, `"name":"b","sent":["name","tags"]`},
{"application/yaml", "name: c\n", 200, `"name":"c","sent":["name"]`},
{"application/merge-patch+json", `{"nmae":"typo"}`, 400, `unknown field`},
{"application/merge-patch+json", `{"name":"` + strings.Repeat("x", 80) + `"}`, 413, `at most 64 bytes`},
} {
code, body := sendPatch(t, app, tc.media, tc.body)
if code != tc.code || !strings.Contains(body, tc.has) {
t.Errorf("%s %s: %d %s, want %d with %s", tc.media, tc.body, code, body, tc.code, tc.has)
}
}
}
// The document publishes the input's own schema under each media it consumes.
func TestParser_PublishesItsSchema(t *testing.T) {
op := opIn(t, patchApp(), "/v1/records/{id}", "patch")
content := op["requestBody"].(map[string]any)["content"].(map[string]any)
for _, media := range []string{"application/merge-patch+json", "application/yaml"} {
ref, _ := content[media].(map[string]any)["schema"].(map[string]any)["$ref"].(string)
if !strings.HasSuffix(ref, "/patchIn") {
t.Errorf("%s = %v, want the input's schema", media, content[media])
}
}
}
// Over MCP the argument object is handed to Parse as application/json.
func TestParser_TakesTheArgumentObject(t *testing.T) {
app := patchApp()
built(t, app)
env := mcpCall(t, app, zip.ID("PATCH", "/v1/records/:id"), map[string]any{"id": "r2", "name": "d"})
got := mcpText(t, env["result"].(map[string]any))
if !strings.Contains(got, `"name":"d"`) {
t.Errorf("over MCP: %s", got)
}
}