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.
This commit is contained in:
2026-10-09 09:42:00 -07:00
parent e2f4d75160
commit 999e2153b3
43 changed files with 5097 additions and 780 deletions
+3 -1
View File
@@ -31,6 +31,7 @@ Patch tags, one above the highest `v*` on GitHub. Push the commit to main, read
| Files | Hold |
|---|---|
| `typed.go` | `Get`, `Post`, `Put`, `Patch`, `Delete`, the `With*` options, `registeredOp`. `invoke` decodes the body, binds declared headers, the query and the path, then `run` validates, authorizes and calls the handler. |
| `body.go`, `answer.go`, `oneof.go` | the request kinds (`Body` field, `Parser`, `form:`/`cookie:` fields, `File`, `Consumes`) and the answer kinds (`Body`, `Verbatim`, `Sse`, `Socket`, `Redirect`, `CookieCoder`, `Produces`, `Or` and the `OneOf()` union). `intakeOf` and `answerOf` read them off In and Out once; `writeAnswer` is the one place they reach the REST wire. |
| `openapi.go` | the document (`buildOpenAPI`), `schemaOf`, `ID`, `SpecPath`, `DocsPath`, the Swagger UI page, the `default` refusal response |
| `mcp.go` | `App.MCP` (the door as a `zapmcp.Handler`), the `POST /mcp` adapter, `negotiate`, `MCPConfig` |
| `cli.go`, `clispec.go` | `Commands`, `CLI`, `LocalInvoke`, `Remote`, `CommandsFromSpec` |
@@ -71,7 +72,8 @@ Patch tags, one above the highest `v*` on GitHub. Push the commit to main, read
- `app.Test` runs `prepare`, so `/mcp`, the OpenAPI document and the call plane answer under test as they do when serving.
- The `/docs` page loads Swagger UI from cdn.jsdelivr.net. Embedding swagger-ui-dist 5 would add about 1.74 MB (bundle 1,552,209 bytes, CSS 185,784 bytes, Apache-2.0) to every binary that links zip.
- A manifest op may say `"stream": "sse"` or `"bytes"` (the answer is written as produced, and `ProjectOpenAPI` publishes `text/event-stream` or `application/octet-stream` for it) and `"raw": true` (the request body is bytes, bound to no type). `Check` refuses a stream beside an `out` and a raw body beside an `in`. Go's own typed ops set neither; the Rust front end sets both.
- A manifest op may say `"stream": "sse"`, `"bytes"` or `"socket"` (the answer is written as produced, or the connection upgrades; `ProjectOpenAPI` publishes `text/event-stream` with `x-events`, binary media, or `101` with `x-socket`) and `"raw": true` (the request body is bytes, bound to no type; the Rust front end's). A stream beside an `out` means either. `Check` refuses a socket beside an `out` and a raw body beside an `in`.
- Every wire is a typed op. A request body that is not JSON is a `zip.Body` field (bytes and Content-Type as sent), a `Parser` In (decodes itself; the document keeps In's schema), or `form:` fields and `*zip.File` parts; `cookie:` is an `in: cookie` parameter. An answer that is not one JSON value is a `Body` (bytes, filename, its own status and headers; a `Reader` streams chunked), `Verbatim[T]` (a relay's bytes documented as T; the upstream's 4xx/5xx pass undeclared), `Sse[T]`, `Socket[M]` (GET only; 426 without an upgrade, 501 over ZAP), `Redirect` or a `CookieCoder`. A union is a type whose `OneOf()` result types are its alternatives; `Or[A,B]` is the stock one and files an alternative under its value-receiver `StatusCode()`. Over MCP bytes are base64 (in) and tool content (out), a stream is collected as JSON lines up to `toolBound`, and a socket op is no tool (`_meta.refused`). The call plane writes an `Sse` as the REST door does and refuses a `Socket`.
## Rust
+728
View File
@@ -0,0 +1,728 @@
// 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
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strconv"
"strings"
"time"
"github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip/internal/jsonenc"
"github.com/zap-proto/zip/internal/ws"
)
// The answers a typed op may give besides one JSON value. Each is the Out of an
// ordinary zip.Get/Post/…, so the op keeps its schema, its prose, its MCP tool,
// its CLI command and its place in every SDK, and the document says what the
// wire carries: bytes under their media type, an event stream and the type of
// one event, an upgrade and the type of one message, a redirect and its
// Location.
// Verbatim is a [Body] documented as T: a relay's answer, written exactly as
// the upstream sent it, while the document, the SDKs and the tool describe the
// upstream's 2xx shape T.
//
// Its status is the upstream's. A 2xx or 3xx must be one the op declared, as
// for any answer; a 4xx or 5xx passes through undeclared, because it is the
// upstream's refusal in the upstream's own words, and the document says so
// beside the op's own refusal. Verbatim[Sse[E]] relays an event stream and is
// documented as one whose events are E.
type Verbatim[T any] struct{ Body }
// documents is the type a Verbatim is documented as.
func (Verbatim[T]) documents() reflect.Type { return reflect.TypeOf((*T)(nil)).Elem() }
// Sse is an answer written as server-sent events (text/event-stream), one
// [Event] per frame, each written and flushed as Send produces it.
//
// return &zip.Sse[Chunk]{Keep: 15 * time.Second, Send: func(emit func(zip.Event[Chunk]) error) error {
// for chunk := range chunks {
// if err := emit(zip.Event[Chunk]{Data: chunk}); err != nil {
// return err // the client is gone
// }
// }
// return emit(zip.Event[Chunk]{Text: "[DONE]"})
// }}, nil
//
// Send runs after the handler returns, once the status and headers are out. An
// error from emit means the client has gone, and Send should return it. An
// error Send returns ends the stream where it stands: the status already went
// out, so it cannot be a refusal.
//
// The document publishes text/event-stream with the event's type as x-events.
// Over MCP and the CLI the events are the result, one JSON line each.
type Sse[T any] struct {
// Send produces the events, calling emit once per event in order.
Send func(emit func(Event[T]) error) error
// Keep writes an empty comment line whenever this long passes with no
// event, so a proxy keeps a waiting stream open and a client that left is
// found by the write. Zero writes none.
Keep time.Duration
// Header are the answer's headers beside Content-Type, each one the op
// declared ([WithResponseHeader]).
Header map[string]string
}
// ResponseHeaders are the headers the stream states.
func (s Sse[T]) ResponseHeaders() map[string]string { return s.Header }
// frame is the type one event carries.
func (Sse[T]) frame() reflect.Type { return reflect.TypeOf((*T)(nil)).Elem() }
// Event is one server-sent event. Data is written as one line of JSON; Text,
// when it is not empty, is written in its place as it is (a sentinel such as
// [DONE] is text, not a value). A line break in Text, Event or ID cannot start
// a field of its own: Text becomes several data lines, Event and ID lose theirs.
type Event[T any] struct {
// Event is the event's name, the field a browser dispatches on.
Event string
// ID is the id a reconnecting client sends back as Last-Event-ID.
ID string
// Retry is how long, in milliseconds, a client waits before reconnecting.
Retry int
// Data is the event's value.
Data T
// Text is the event's data as written, in place of Data.
Text string
}
// payload is the event's data as the stream carries it.
func (e Event[T]) payload() ([]byte, error) {
if e.Text != "" {
return []byte(e.Text), nil
}
return jsonenc.Marshal(e.Data)
}
// encode is the event as the bytes text/event-stream carries.
func (e Event[T]) encode() ([]byte, error) {
data, err := e.payload()
if err != nil {
return nil, err
}
var b strings.Builder
if e.Event != "" {
b.WriteString("event: " + oneLine(e.Event) + "\n")
}
if e.ID != "" {
b.WriteString("id: " + oneLine(e.ID) + "\n")
}
if e.Retry > 0 {
b.WriteString("retry: " + strconv.Itoa(e.Retry) + "\n")
}
for _, line := range sseLines(string(data)) {
b.WriteString("data: " + line + "\n")
}
b.WriteByte('\n')
return []byte(b.String()), nil
}
// keepLine is what a quiet interval writes: an empty comment, as zip in Rust
// writes it.
var keepLine = []byte(":\n\n")
// sseLines splits on every line break the event-stream format knows.
func sseLines(s string) []string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
return strings.Split(s, "\n")
}
func oneLine(s string) string { return strings.NewReplacer("\r", "", "\n", "").Replace(s) }
// events runs Send, handing each event to frame as the bytes the stream carries
// (wire) or as its data alone.
func (s *Sse[T]) events(wire bool, frame func([]byte) error) (err error) {
if s.Send == nil {
return nil
}
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("zip: an event stream panicked: %v", r)
}
}()
return s.Send(func(e Event[T]) error {
var b []byte
var err error
if wire {
b, err = e.encode()
} else {
b, err = e.payload()
}
if err != nil {
return err
}
return frame(b)
})
}
// stream is how the REST door writes any event stream.
type stream interface {
events(wire bool, frame func([]byte) error) error
keepEvery() time.Duration
}
func (s *Sse[T]) keepEvery() time.Duration { return s.Keep }
// errGone is what emit answers once the client has stopped reading.
var errGone = errors.New("zip: the client is gone")
// writeEvents writes a stream to w, flushing each event, with a keep-alive
// comment in every quiet interval when the stream asks for one.
func writeEvents(s stream, w *bufio.Writer) error {
every := s.keepEvery()
if every <= 0 {
return s.events(true, func(b []byte) error {
if _, err := w.Write(b); err != nil {
return errGone
}
if w.Flush() != nil {
return errGone
}
return nil
})
}
// With a keep-alive, the events are produced beside the writer, so a quiet
// interval can be filled without the producer's help.
frames := make(chan []byte)
done := make(chan struct{})
ended := make(chan error, 1)
go func() {
ended <- s.events(true, func(b []byte) error {
select {
case frames <- b:
return nil
case <-done:
return errGone
}
})
}()
defer close(done)
tick := time.NewTimer(every)
defer tick.Stop()
for {
select {
case b := <-frames:
if _, err := w.Write(b); err != nil {
return errGone
}
if w.Flush() != nil {
return errGone
}
tick.Reset(every)
case <-tick.C:
if _, err := w.Write(keepLine); err != nil {
return errGone
}
if w.Flush() != nil {
return errGone
}
tick.Reset(every)
case err := <-ended:
return err
}
}
}
// Socket is an answer that upgrades the connection to a WebSocket whose
// messages are M. The op validates, authorizes and binds its input like any
// other; then the connection is upgraded and Serve runs on it until it
// returns.
//
// return &zip.Socket[Frame]{Serve: func(conn *wsx.Conn) error { … }}, nil
//
// A request that does not ask to upgrade is answered 426 with Upgrade:
// websocket; over ZAP, which carries no upgrade, 501. The document publishes
// 101 with M as x-events and marks the op x-socket: websocket. A connection is
// not a call, so the op is no MCP tool and has no call-plane method; the CLI
// bridges it to stdin and stdout.
type Socket[M any] struct {
// Serve runs on the upgraded connection.
Serve func(conn *ws.Conn) error
// Config configures the upgrade (wsx.Config).
Config ws.Config
}
// frame is the type one message carries.
func (Socket[M]) frame() reflect.Type { return reflect.TypeOf((*M)(nil)).Elem() }
func (s *Socket[M]) serve(c fiber.Ctx) error {
if carriedByZAP(c.RequestCtx()) {
return Errorf(501, "%s %s upgrades to a WebSocket, which does not cross ZAP; connect over HTTP", c.Method(), c.Path())
}
if !ws.Is(c.RequestCtx()) {
c.Set("Upgrade", "websocket")
return Errorf(http.StatusUpgradeRequired, ws.Refusal)
}
serve := s.Serve
return ws.Upgrade(c.RequestCtx(), s.Config, func(conn *ws.Conn) {
if serve != nil {
_ = serve(conn)
}
})
}
// upgrader is a [Socket], whatever its message type.
type upgrader interface{ serve(c fiber.Ctx) error }
// NotACall is why an op that answers a [Socket] is no MCP tool and no call:
// the op's answer is a connection that stays open.
const NotACall = "a connection is not a call"
// Redirect is an answer that sends the client elsewhere: Status (a 3xx the op
// declared; zero is the first it declared, 302 when it declared none) with
// Location set to To and no body. The document publishes the status and its
// Location header; MCP and the CLI answer the location.
type Redirect struct {
// To is the location the client is sent to.
To string `json:"to"`
// Status is the redirect's status.
Status int `json:"status,omitempty"`
}
// StatusCode is the status the redirect states; zero is the op's own.
func (r Redirect) StatusCode() int { return r.Status }
var redirectType = reflect.TypeOf(Redirect{})
// CookieCoder is an answer that sets cookies: one Set-Cookie per cookie, which
// a header map cannot say. An op whose answer is one declares Set-Cookie by
// being one.
type CookieCoder interface{ Cookies() []*http.Cookie }
var cookieCoder = reflect.TypeOf((*CookieCoder)(nil)).Elem()
// Produces names the media types an op answers with when they are not
// application/json: application/scim+json, application/jwk-set+json, or the
// types of the bytes a [Body] answers. The first is what a JSON answer is sent
// as.
func Produces(media ...string) OpOption {
if len(media) == 0 {
panic("zip: Produces needs at least one media type")
}
return func(op *registeredOp) { op.Produces = append([]string(nil), media...) }
}
// Answer kinds a manifest names an op's stream by.
const (
streamSSE = "sse"
streamBytes = "bytes"
streamSocket = "socket"
)
// answer is what an op's Out may put on the wire, read once from its type. A
// union ([Or], or a type with a OneOf method) is flattened into the kinds its
// alternatives are.
type answer struct {
// json are the JSON values the op may answer: one, or several when it is a
// union. union is the union's own type when it is one that writes itself
// (a OneOf method); an [Or]'s alternatives are flattened into json.
json []reflect.Type
union reflect.Type
// stream is "sse", "bytes" or "socket" when the op may answer one, and
// frame the type of one event or message.
stream string
frame reflect.Type
// verbatim says the answer is bytes relayed as sent, documented as json.
verbatim bool
// redirect says the op may answer a [Redirect].
redirect bool
// cookies says the answer may set cookies ([CookieCoder]).
cookies bool
// statuses are the statuses the alternatives state for themselves, by type.
statuses map[reflect.Type]int
}
// answerOf reads an Out type's answer kinds.
func answerOf(out reflect.Type) answer {
a := answer{statuses: map[reflect.Type]int{}}
if out == nil {
return a
}
a.add(out, true)
return a
}
func (a *answer) add(t reflect.Type, top bool) {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Implements(cookieCoder) || reflect.PointerTo(t).Implements(cookieCoder) {
a.cookies = true
}
v := reflect.New(t).Interface()
switch x := v.(type) {
case interface{ documents() reflect.Type }:
a.verbatim = true
d := x.documents()
if f, ok := reflect.New(d).Interface().(interface{ frame() reflect.Type }); ok {
if _, sse := reflect.New(d).Interface().(stream); sse {
a.setStream(streamSSE, f.frame())
return
}
}
a.json = append(a.json, d)
return
case stream:
a.setStream(streamSSE, v.(interface{ frame() reflect.Type }).frame())
return
case upgrader:
a.setStream(streamSocket, v.(interface{ frame() reflect.Type }).frame())
return
}
switch {
case t == bodyType:
a.setStream(streamBytes, nil)
return
case t == redirectType:
a.redirect = true
return
}
if alts := alternatives(t); len(alts) > 0 {
if !isOr(t) {
// A union of JSON values decoded by its own type: each
// alternative is a schema, the value is written as it marshals.
a.union = t
for _, alt := range alts {
a.json = append(a.json, alt)
a.state(alt)
}
return
}
for _, alt := range alts {
a.add(alt, false)
}
return
}
a.json = append(a.json, t)
if !top {
a.state(t)
}
}
// state records the status an alternative states for itself: the value
// StatusCode of its zero value.
func (a *answer) state(t reflect.Type) {
if sc, ok := reflect.Zero(t).Interface().(StatusCoder); ok {
if code := sc.StatusCode(); code != 0 {
a.statuses[t] = code
}
}
}
func (a *answer) setStream(kind string, frame reflect.Type) {
if a.stream != "" && a.stream != kind {
panic(fmt.Sprintf("zip: an answer may be one kind of stream, not both %s and %s", a.stream, kind))
}
a.stream, a.frame = kind, frame
}
// settle completes an op's declaration from its answer: the statuses its
// alternatives and its redirects state, and the headers a redirect and a cookie
// set, so the document and the seam agree on them without the author repeating
// what the type already says.
func (a answer) settle(op *registeredOp) {
if a.stream == streamSocket {
if op.Method != http.MethodGet {
panic(fmt.Sprintf("zip: %s %s answers a WebSocket, which a client opens with GET", op.Method, op.Path))
}
if len(a.json) > 0 || a.redirect {
panic(fmt.Sprintf("zip: %s %s answers a WebSocket and something else; an upgrade is the whole answer", op.Method, op.Path))
}
}
declared := len(op.Statuses) > 0
primary := 200
if declared {
primary = op.Statuses[0]
}
want := []int{}
for _, code := range a.statuses {
want = append(want, code)
}
if a.redirect {
has := false
for _, code := range op.Statuses {
if code >= 300 && code < 400 {
has = true
}
}
if !has {
want = append(want, http.StatusFound)
}
op.ResponseHeaders = addHeader(op.ResponseHeaders, "Location")
}
if a.cookies {
op.ResponseHeaders = addHeader(op.ResponseHeaders, "Set-Cookie")
}
if a.stream == streamBytes || a.verbatim {
op.ResponseHeaders = addHeader(op.ResponseHeaders, "Content-Disposition")
}
for _, code := range want {
if containsInt(op.Statuses, code) {
continue
}
if declared && !(a.redirect && code == http.StatusFound) {
panic(fmt.Sprintf("zip: %s %s answers %d, which its WithStatus does not declare", op.Method, op.Path, code))
}
if len(op.Statuses) == 0 && (len(a.json) > 0 || a.stream != "") {
op.Statuses = append(op.Statuses, primary)
}
op.Statuses = append(op.Statuses, code)
}
}
func addHeader(list []string, name string) []string {
for _, h := range list {
if strings.EqualFold(h, name) {
return list
}
}
return append(list, name)
}
func containsInt(list []int, v int) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
// bodyOf is the [Body] an answer carries, when it carries one: a Body or a
// Verbatim.
func bodyOf(out any) (*Body, bool) {
switch b := out.(type) {
case *Body:
return b, true
case interface{ documents() reflect.Type }:
v := reflect.ValueOf(b)
if v.Kind() == reflect.Pointer && !v.IsNil() {
if f := v.Elem().FieldByName("Body"); f.IsValid() {
return f.Addr().Interface().(*Body), true
}
}
}
return nil, false
}
// unwrap is the alternative a union answer holds, or the answer itself.
func unwrap(out any) any {
for {
u, ok := out.(interface{ either() any })
if !ok {
return out
}
out = u.either()
}
}
// writeAnswer writes an op's answer over REST. It is the one place the kinds
// above reach the wire.
func writeAnswer(c fiber.Ctx, op *registeredOp, out any) error {
out = unwrap(out)
if out == nil {
code, err := statusOf(op, nil, false)
if err != nil {
return err
}
c.Status(nonZero(code, http.StatusNoContent))
return nil
}
if s, ok := out.(upgrader); ok {
return s.serve(c)
}
b, isBody := bodyOf(out)
hdrs, err := responseHeadersOf(op, out)
if err != nil {
return err
}
code, err := statusOf(op, out, op.ans.verbatim)
if err != nil {
return err
}
if cc, ok := out.(CookieCoder); ok {
for _, ck := range cc.Cookies() {
if ck == nil {
continue
}
if v := ck.String(); v != "" {
c.Response().Header.Add("Set-Cookie", v)
}
}
}
for name, v := range hdrs {
c.Set(name, v)
}
switch x := out.(type) {
case *Redirect:
c.Set("Location", x.To)
c.Status(nonZero(code, http.StatusFound))
return nil
case stream:
if code != 0 {
c.Status(code)
}
c.Set(fiber.HeaderContentType, "text/event-stream")
return c.SendStreamWriter(func(w *bufio.Writer) { _ = writeEvents(x, w) })
}
if code != 0 {
c.Status(code)
}
if isBody {
media := b.Type
if media == "" {
media = produces(op, mimeOctet)
}
c.Set(fiber.HeaderContentType, media)
if b.Name != "" {
c.Set(fiber.HeaderContentDisposition, `attachment; filename="`+strings.ReplaceAll(b.Name, `"`, `\"`)+`"`)
}
if b.Reader != nil {
r := b.Reader
return c.SendStreamWriter(func(w *bufio.Writer) { copyFlushing(w, r) })
}
return c.Send(b.Bytes)
}
return c.JSON(out, produces(op, mimeJSON))
}
// copyFlushing writes r to w a read at a time, flushing each, so bytes reach
// the client as the source yields them. It closes r when it can.
func copyFlushing(w *bufio.Writer, r io.Reader) {
if c, ok := r.(io.Closer); ok {
defer func() { _ = c.Close() }()
}
buf := make([]byte, 32<<10)
for {
n, err := r.Read(buf)
if n > 0 {
if _, werr := w.Write(buf[:n]); werr != nil {
return
}
if w.Flush() != nil {
return
}
}
if err != nil {
return
}
}
}
// produces is the media a kind of answer is sent as: the op's first Produces,
// else the kind's own.
func produces(op *registeredOp, dflt string) string {
if len(op.Produces) > 0 && (dflt == mimeJSON) == (op.ans.stream != streamBytes) {
return op.Produces[0]
}
return dflt
}
func nonZero(code, dflt int) int {
if code == 0 {
return dflt
}
return code
}
// toolBound is the most a tool result carries of an answer that is bytes or a
// stream. A tool result is read into a model's context in one piece, so an
// answer past it is cut at a frame or at the bound and says so.
const toolBound = 1 << 20
// errBound ends a collection at the tool bound.
var errBound = errors.New("zip: the tool result bound was reached")
// collect reads an answer that is not one JSON value into what a call returns:
// bytes with their media type, or a stream's events as JSON lines, bounded.
// truncated says the bound was reached first.
func collect(out any) (data []byte, media string, truncated bool, err error) {
if s, ok := out.(stream); ok {
var b []byte
err = s.events(false, func(frame []byte) error {
if len(b)+len(frame)+1 > toolBound {
return errBound
}
b = append(append(b, frame...), '\n')
return nil
})
if errors.Is(err, errBound) {
return b, "application/jsonl", true, nil
}
return b, "application/jsonl", false, err
}
b, _ := bodyOf(out)
if b.Reader == nil {
return b.Bytes, b.Type, false, nil
}
if c, ok := b.Reader.(io.Closer); ok {
defer func() { _ = c.Close() }()
}
data, err = io.ReadAll(io.LimitReader(b.Reader, toolBound+1))
if len(data) > toolBound {
return data[:toolBound], b.Type, true, err
}
return data, b.Type, false, err
}
// textual reports whether bytes of a media type read as text.
func textual(media string) bool {
m := strings.ToLower(strings.TrimSpace(strings.Split(media, ";")[0]))
switch {
case m == "", strings.HasPrefix(m, "text/"):
return true
case strings.HasSuffix(m, "+json"), strings.HasSuffix(m, "+xml"), strings.HasSuffix(m, "+yaml"):
return true
}
switch m {
case mimeJSON, "application/jsonl", "application/x-ndjson", "application/xml", "application/yaml",
"application/x-yaml", "application/javascript", "application/graphql", mimeForm:
return true
}
return false
}
// toolContent is an answer that is not one JSON value, as MCP tool content:
// text as text, an image as an image, any other bytes as an embedded resource
// whose blob is the base64 of them.
func toolContent(id string, out any) (map[string]any, error) {
data, media, truncated, err := collect(out)
if err != nil {
return nil, err
}
var content map[string]any
switch {
case textual(media):
content = map[string]any{"type": "text", "text": string(data)}
case strings.HasPrefix(media, "image/"):
content = map[string]any{"type": "image", "data": base64Text(data), "mimeType": media}
default:
content = map[string]any{"type": "resource", "resource": map[string]any{
"uri": "zip:" + id, "mimeType": media, "blob": base64Text(data),
}}
}
result := map[string]any{"content": []map[string]any{content}}
if truncated {
result["_meta"] = map[string]any{"truncated": true, "bound": toolBound}
}
return result, nil
}
// redirectJSON is the location a redirect answers with where there is no
// status line to carry it.
func redirectJSON(r *Redirect) []byte {
b, _ := json.Marshal(r)
return b
}
+179
View File
@@ -0,0 +1,179 @@
// 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 (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// The answers a typed op gives besides one JSON value: a relay's bytes, a
// redirect, cookies, and JSON under its own media type. Each keeps its wire and
// says what it is in the document.
// upstreamShape is what an upstream answers on success.
type upstreamShape struct {
Model string `json:"model"`
}
type passIn struct {
Fail bool `json:"fail" url:"fail"`
}
func pass(_ context.Context, in *passIn) (*zip.Verbatim[upstreamShape], error) {
if in.Fail {
return &zip.Verbatim[upstreamShape]{Body: zip.Body{
Type: "application/json", Bytes: []byte(`{"error":{"message":"overloaded"}}`), Status: 529,
Header: map[string]string{"Retry-After": "3"},
}}, nil
}
return &zip.Verbatim[upstreamShape]{Body: zip.Body{
Type: "application/json; charset=utf-8", Bytes: []byte(`{"model":"m1" }`),
}}, nil
}
// A relay writes the upstream's bytes, status and declared headers untouched,
// a refusal included, and the document describes the upstream's 2xx shape.
func TestVerbatim_RelaysAsSent(t *testing.T) {
app := zip.New(zip.Config{AppName: "rl", DisableStartupMessage: true})
app.Post("/v1/relay", pass, zip.WithResponseHeader("Retry-After"))
for _, tc := range []struct {
query, body, media, retry string
code int
}{
{"", `{"model":"m1" }`, "application/json; charset=utf-8", "", 200},
{"?fail=true", `{"error":{"message":"overloaded"}}`, "application/json", "3", 529},
} {
resp, err := app.Test(httptest.NewRequest("POST", "/v1/relay"+tc.query, nil))
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != tc.code || string(b) != tc.body || resp.Header.Get("Content-Type") != tc.media ||
resp.Header.Get("Retry-After") != tc.retry {
t.Errorf("%q: %d %q %q %q", tc.query, resp.StatusCode, b, resp.Header.Get("Content-Type"), resp.Header.Get("Retry-After"))
}
}
op := opIn(t, app, "/v1/relay", "post")
resp := op["responses"].(map[string]any)
ref, _ := resp["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)["$ref"].(string)
if !strings.HasSuffix(ref, "/upstreamShape") {
t.Errorf("200 = %v, want the upstream's shape", resp["200"])
}
refusal := resp["default"].(map[string]any)
if _, ok := refusal["content"].(map[string]any)["*/*"]; !ok {
t.Errorf("default = %v, want the upstream's refusal beside this service's", refusal)
}
}
// A redirect answers its 3xx with Location and no body; the document names the
// status and the header.
func TestRedirect_SendsTheClientOn(t *testing.T) {
app := zip.New(zip.Config{AppName: "oauth", DisableStartupMessage: true})
app.Get("/v1/login", func(context.Context, *struct{}) (*zip.Redirect, error) {
return &zip.Redirect{To: "https://id.example/authorize?state=s1"}, nil
})
app.Get("/v1/moved", func(context.Context, *struct{}) (*zip.Redirect, error) {
return &zip.Redirect{To: "/v1/here"}, nil
}, zip.WithStatus(308))
for path, want := range map[string]struct {
code int
to string
}{"/v1/login": {302, "https://id.example/authorize?state=s1"}, "/v1/moved": {308, "/v1/here"}} {
resp, err := app.Test(httptest.NewRequest("GET", path, nil))
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != want.code || resp.Header.Get("Location") != want.to || len(b) != 0 {
t.Errorf("%s: %d %q %q", path, resp.StatusCode, resp.Header.Get("Location"), b)
}
}
resp := opIn(t, app, "/v1/login", "get")["responses"].(map[string]any)
found, ok := resp["302"].(map[string]any)
if !ok {
t.Fatalf("responses = %v, want a 302", resp)
}
if _, ok := found["headers"].(map[string]any)["Location"]; !ok || found["content"] != nil {
t.Errorf("302 = %v, want Location and no body", found)
}
}
// An answer that sets cookies sets one Set-Cookie per cookie, which a header
// map cannot say.
type session struct {
User string `json:"user"`
}
func (session) Cookies() []*http.Cookie {
return []*http.Cookie{
{Name: "sid", Value: "s1", Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode},
{Name: "csrf", Value: "c1", Path: "/"},
}
}
func TestCookies_OneHeaderEach(t *testing.T) {
app := zip.New(zip.Config{AppName: "s", DisableStartupMessage: true})
app.Post("/v1/session", func(context.Context, *struct{}) (*session, error) { return &session{User: "z"}, nil })
resp, err := app.Test(httptest.NewRequest("POST", "/v1/session", nil))
if err != nil {
t.Fatal(err)
}
got := resp.Header.Values("Set-Cookie")
if len(got) != 2 || got[0] != "sid=s1; Path=/; HttpOnly; Secure; SameSite=Strict" || got[1] != "csrf=c1; Path=/" {
t.Errorf("Set-Cookie = %q", got)
}
headers := opIn(t, app, "/v1/session", "post")["responses"].(map[string]any)["200"].(map[string]any)["headers"]
if _, ok := headers.(map[string]any)["Set-Cookie"]; !ok {
t.Errorf("headers = %v, want Set-Cookie", headers)
}
}
// Produces names the media a JSON answer goes out as.
func TestProduces_NamesTheMedia(t *testing.T) {
app := zip.New(zip.Config{AppName: "scim", DisableStartupMessage: true})
app.Get("/v1/scim/Users/:id", func(_ context.Context, in *reportIn) (*mkOut, error) {
return &mkOut{ID: in.ID}, nil
}, zip.Produces("application/scim+json"))
resp, err := app.Test(httptest.NewRequest("GET", "/v1/scim/Users/u1", nil))
if err != nil {
t.Fatal(err)
}
var got mkOut
_ = json.NewDecoder(resp.Body).Decode(&got)
if resp.Header.Get("Content-Type") != "application/scim+json" || got.ID != "u1" {
t.Errorf("%q %+v", resp.Header.Get("Content-Type"), got)
}
content := opIn(t, app, "/v1/scim/Users/{id}", "get")["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)
if _, ok := content["application/scim+json"]; !ok || len(content) != 1 {
t.Errorf("content = %v", content)
}
}
// A JSON answer without Produces is written exactly as before: the same bytes
// under the same Content-Type.
func TestAnswer_JSONWireIsUnchanged(t *testing.T) {
app := zip.New(zip.Config{AppName: "j", DisableStartupMessage: true})
app.Post("/v1/j", mk)
resp, err := app.Test(httptest.NewRequest("POST", "/v1/j", strings.NewReader(`{"name":"x"}`)))
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
if resp.Header.Get("Content-Type") != "application/json" || string(b) != `{"id":"x"}` {
t.Errorf("%q %s", resp.Header.Get("Content-Type"), b)
}
}
+41
View File
@@ -0,0 +1,41 @@
// 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
import (
"go/ast"
"go/parser"
"go/token"
"reflect"
"strings"
"testing"
"github.com/zap-proto/zip/internal/jsontag"
)
// The held answer's prose is the comments on Approval's fields, stated once
// more for the document because no zipdoc pass reads zip's own source. This
// holds the two to one text.
func TestApprovalProseIsItsComments(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "authorize.go", nil, parser.ParseComments)
if err != nil {
t.Fatal(err)
}
want := map[string]string{}
ast.Inspect(f, func(n ast.Node) bool {
ts, ok := n.(*ast.TypeSpec)
if !ok || ts.Name.Name != "Approval" {
return true
}
for _, field := range ts.Type.(*ast.StructType).Fields.List {
tag := reflect.StructTag(strings.Trim(field.Tag.Value, "`")).Get("json")
want["Approval."+jsontag.Name(field.Names[0].Name, tag)] = strings.TrimSpace(field.Doc.Text())
}
return false
})
if !reflect.DeepEqual(want, approvalProse) {
t.Errorf("approvalProse = %v\nthe comments say %v", approvalProse, want)
}
}
+13 -1
View File
@@ -75,11 +75,23 @@ const Held = "held"
// reads it off the error lane — so no projection can report a held op as a
// finished one.
type Approval struct {
Status string `json:"status"` // always Held
// Status is always held: the op stopped for sign-off and did not run.
Status string `json:"status"`
// Clause names the clause of the rule that asked for sign-off.
Clause string `json:"clause"`
// Reason is what a person reads before answering.
Reason string `json:"reason,omitempty"`
}
// approvalProse is Approval's field comments, as the document carries them.
// zip's own types are not lifted by zipdoc, so the sentences are stated here
// once and TestApprovalProseIsItsComments holds them to the comments above.
var approvalProse = map[string]string{
"Approval.status": "Status is always held: the op stopped for sign-off and did not run.",
"Approval.clause": "Clause names the clause of the rule that asked for sign-off.",
"Approval.reason": "Reason is what a person reads before answering.",
}
// Error lets an Approval travel the lane every Go caller already reads. A held
// op has no Out and never will have one — the handler did not run — so a
// projection that must answer (*Out, error) has exactly one honest shape for
+581
View File
@@ -0,0 +1,581 @@
// 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
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"reflect"
"strings"
"github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip/internal/jsonenc"
"github.com/zap-proto/zip/internal/jsontag"
)
// Body is bytes and the media type that names them.
//
// As a field of an op's In it is the request body exactly as it arrived. The
// REST door puts the request's bytes and its Content-Type in it and decodes
// nothing; the input's other fields still bind from the path, the query, the
// declared headers and the declared cookies. An upload, a pack stream, a
// statement file and a signed webhook are typed ops this way, and the wire
// they speak does not move:
//
// type ScanIn struct {
// Org string `json:"org" url:"org"`
// Body zip.Body `json:"body"` // the receipt, as sent
// }
// zip.Post(app, "/v1/books/:org/scan", scan, zip.Consumes("application/pdf", "image/png"))
//
// Over MCP, the CLI and the call plane the input arrives as one value, and the
// field is its bytes as a base64 string; its Type is then the first media type
// the op [Consumes].
//
// As an op's Out it is the answer: Bytes, or Reader read to its end and written
// chunk by chunk as it arrives, under Type, with Name as the filename a client
// saves it under (Content-Disposition: attachment). Status and Header are the
// status and headers it states; each must be one the op declared
// ([WithStatus], [WithResponseHeader]), as for any answer.
//
// The document publishes a request Body as the op's [Consumes] media with
// {type: string, format: binary}, and an answer as its [Produces] media the
// same way.
type Body struct {
// Type is the media type: the request's Content-Type as sent, or the
// answer's.
Type string
// Bytes are the bytes, exactly as received or as written.
Bytes []byte
// Name is the filename an answer is saved under. Empty sends no
// Content-Disposition.
Name string
// Status is the answer's status, zero for the op's own. A request ignores it.
Status int
// Header are the answer's headers beside Content-Type and
// Content-Disposition. A request ignores them.
Header map[string]string `zap:"-"`
// Reader, when set, is read to its end and written as it arrives in place
// of Bytes. It is closed afterwards when it is an io.Closer.
Reader io.Reader `zap:"-"`
}
// StatusCode is the status the answer states; zero is the op's own.
func (b Body) StatusCode() int { return b.Status }
// drain reads Reader into Bytes, for a transport whose answer is one message.
func (b *Body) drain() error {
r := b.Reader
b.Reader = nil
if c, ok := r.(io.Closer); ok {
defer func() { _ = c.Close() }()
}
got, err := io.ReadAll(r)
b.Bytes = got
return err
}
// ResponseHeaders are the headers the answer states.
func (b Body) ResponseHeaders() map[string]string { return b.Header }
// MarshalJSON writes the bytes as one base64 string, the form an argument
// object carries them in.
func (b Body) MarshalJSON() ([]byte, error) { return json.Marshal(b.Bytes) }
// UnmarshalJSON reads the bytes from one base64 string.
func (b *Body) UnmarshalJSON(data []byte) error {
var raw []byte
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("a body is one base64 string: %w", err)
}
b.Bytes = raw
return nil
}
// JSONSchema is the body as an argument object carries it.
func (Body) JSONSchema() map[string]any {
return map[string]any{"type": "string", "contentEncoding": "base64"}
}
// File is one part of a multipart form: the filename the client gave it, its
// media type and its bytes. A field of type File, *File, []File or []*File in
// an op's In binds the part (or parts) of its form name.
//
// An argument object carries it as {"name", "type", "bytes"}, the bytes in
// base64, so a filename the handler reads survives MCP and the CLI.
type File struct {
// Name is the filename the part was sent under.
Name string `json:"name,omitempty"`
// Type is the part's media type.
Type string `json:"type,omitempty"`
// Bytes are the part's bytes.
Bytes []byte `json:"bytes"`
}
// Parser is an input that decodes its own request body. The REST door hands it
// the request's Content-Type and bytes instead of decoding JSON, so an op can
// read YAML or JSON, apply an RFC 7386 merge patch, refuse an unknown field,
// cap its own size or decode tolerantly, and still be a typed op: the document
// publishes the input's reflected schema under the media the op [Consumes]
// (application/json when it names none).
//
// An error that is an [HTTPError] is answered as it is, so a cap answers its
// own 413; any other error is 400 "invalid body". An argument object (MCP, the
// CLI) is handed over as application/json; the call plane carries the input as
// a ZAP message and does not parse.
type Parser interface {
Parse(media string, body []byte) error
}
// Consumes names the media types an op's request body may arrive in. It is
// what the document publishes the request body under.
//
// Without it an op with a [Body] field consumes application/octet-stream, one
// with form fields application/x-www-form-urlencoded (multipart/form-data when
// it takes a [File]), and any other application/json.
func Consumes(media ...string) OpOption {
if len(media) == 0 {
panic("zip: Consumes needs at least one media type")
}
return func(op *registeredOp) { op.Consumes = append([]string(nil), media...) }
}
// The media types a request kind defaults to.
const (
mimeOctet = "application/octet-stream"
mimeForm = "application/x-www-form-urlencoded"
mimeMultipart = "multipart/form-data"
)
var (
bodyType = reflect.TypeOf(Body{})
fileType = reflect.TypeOf(File{})
parserType = reflect.TypeOf((*Parser)(nil)).Elem()
)
// intake is how an op's input arrives, decided once from In at registration.
type intake struct {
// raw is the index path of In's [Body] field, nil when it has none.
raw []int
// form says In carries form fields or file parts.
form bool
// parse says *In decodes its own body ([Parser]).
parse bool
// cookies says In declares a cookie: field.
cookies bool
}
// intakeOf reads the request kind off In, refusing a shape the wire cannot
// carry rather than registering an op that misreads it.
func intakeOf(method string, in reflect.Type) intake {
var r intake
t := in
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil || t.Kind() != reflect.Struct {
return r
}
r.parse = reflect.PointerTo(t).Implements(parserType)
for _, f := range wireFields(t) {
switch {
case f.Type == bodyType:
if r.raw != nil {
panic(fmt.Sprintf("zip: %s takes two zip.Body fields; a request has one body", t))
}
r.raw = f.Index
case isFile(f.Type):
r.form = true
case formFieldName(f) != "":
r.form = true
case f.Type.Kind() == reflect.Pointer && f.Type.Elem() == bodyType:
panic(fmt.Sprintf("zip: %s.%s is a *zip.Body; a request body is always there, so declare zip.Body", t, f.Name))
}
if cookieFieldName(f) != "" {
r.cookies = true
}
}
kinds := 0
for _, k := range []bool{r.raw != nil, r.form, r.parse} {
if k {
kinds++
}
}
if kinds > 1 {
panic(fmt.Sprintf("zip: %s takes its body more than one way (a zip.Body field, form fields, a Parse method); a request body is read once", t))
}
if kinds > 0 && !hasBody(method) {
panic(fmt.Sprintf("zip: %s carries no request body, so %s cannot take one", method, t))
}
return r
}
// consumes is the request media an op names, defaulted by its request kind.
// Nil is application/json.
func consumes(op *registeredOp) []string {
if len(op.Consumes) > 0 {
return op.Consumes
}
switch {
case op.req.raw != nil:
return []string{mimeOctet}
case op.req.form:
if takesFile(op.InType) {
return []string{mimeMultipart}
}
return []string{mimeForm}
}
return nil
}
// takesFile reports whether In has a file part.
func takesFile(in reflect.Type) bool {
for _, f := range wireFields(in) {
if isFile(f.Type) {
return true
}
}
return false
}
// isFile reports whether t binds a multipart part: File, *File, []File or []*File.
func isFile(t reflect.Type) bool {
if t.Kind() == reflect.Slice {
t = t.Elem()
}
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
return t == fileType
}
// formFieldName is the form key a field binds, or "" when it is not a form
// field. A file part with no form: tag binds under its JSON name.
func formFieldName(f reflect.StructField) string {
if tag, ok := f.Tag.Lookup("form"); ok {
if name := jsontag.Name(f.Name, tag); name != "-" {
return name
}
return ""
}
if isFile(f.Type) {
return jsonFieldName(f)
}
return ""
}
// cookieFieldName is the request cookie a field reads, or "".
func cookieFieldName(f reflect.StructField) string {
if tag, ok := f.Tag.Lookup("cookie"); ok {
if name := jsontag.Name(f.Name, tag); name != "-" {
return name
}
}
return ""
}
// input is one request as the invoke seam takes it.
//
// sent says body is a REST request body exactly as it arrived, under media,
// which a [Body] field takes whole and a form or a [Parser] reads by its media.
// Otherwise body is the whole input as one value — an argument object over
// MCP and the CLI, a ZAP message over the call plane — and dec decodes it.
type input struct {
dec decoder
body []byte
sent bool
media string
// form reads the request's form fields and parts. REST only.
form func() (formData, error)
query map[string]string
path map[string]string
header func(string) string
cookie func(string) string
}
// formData is a request's form: its fields and its file parts by name.
type formData struct {
values map[string][]string
files map[string][]File
}
// decode reads the body into v (a *In) the way the op's request kind says.
// consumes is what the op declared it takes.
func (r intake) decode(v any, in input, consumes []string) error {
if !in.sent {
return r.decodeArgs(v, in)
}
switch {
case r.raw != nil:
field := reflect.ValueOf(v).Elem().FieldByIndex(r.raw)
field.Set(reflect.ValueOf(Body{Type: in.media, Bytes: append([]byte(nil), in.body...)}))
return nil
case r.parse:
return parsed(v.(Parser).Parse(in.media, in.body))
case r.form && isFormMedia(in.media):
fd, err := in.form()
if err != nil {
return ErrBadRequest("invalid body: " + err.Error())
}
return bindForm(v, fd)
case r.form:
// A form op reads JSON only when it says it takes JSON; it then reads
// the same fields under the same names, so one object binds whichever
// way it was sent. A body in any other media binds nothing, as a form
// reader finds no fields in it.
if !isJSONMedia(in.media) || !takesJSON(consumes) {
return nil
}
return r.decodeArgs(v, input{dec: jsonenc.Unmarshal, body: in.body, media: mimeJSON})
}
if len(in.body) == 0 {
return nil
}
if err := in.dec(in.body, v); err != nil {
return ErrBadRequest("invalid body: " + err.Error())
}
return nil
}
// decodeArgs reads an input that arrived as one value.
func (r intake) decodeArgs(v any, in input) error {
if len(in.body) > 0 {
if r.parse && in.media == mimeJSON {
if err := parsed(v.(Parser).Parse(mimeJSON, in.body)); err != nil {
return err
}
} else if err := in.dec(in.body, v); err != nil {
return ErrBadRequest("invalid body: " + err.Error())
}
if r.form && in.media == mimeJSON {
if err := bindFormArgs(v, in.body); err != nil {
return ErrBadRequest("invalid body: " + err.Error())
}
}
}
return nil
}
// parsed is a Parser's error as the seam answers it.
func parsed(err error) error {
if err == nil {
return nil
}
var he *HTTPError
if errors.As(err, &he) {
return he
}
return ErrBadRequest("invalid body: " + err.Error())
}
// isFormMedia reports whether a Content-Type is a form encoding.
func isFormMedia(media string) bool {
m, _, _ := mime.ParseMediaType(media)
return m == mimeForm || m == mimeMultipart
}
// isJSONMedia reports whether a Content-Type is JSON: application/json or a
// +json suffix.
func isJSONMedia(media string) bool {
m, _, _ := mime.ParseMediaType(media)
return m == mimeJSON || strings.HasSuffix(m, "+json")
}
// takesJSON reports whether a declared media list names JSON.
func takesJSON(consumes []string) bool {
for _, m := range consumes {
if isJSONMedia(m) {
return true
}
}
return false
}
// bindForm writes a request's form onto v's form fields and file parts.
func bindForm(v any, fd formData) error {
rv := reflect.ValueOf(v).Elem()
for _, f := range wireFields(rv.Type()) {
name := formFieldName(f)
if name == "" {
continue
}
fv := rv.FieldByIndex(f.Index)
if isFile(f.Type) {
setFiles(fv, fd.files[name])
continue
}
setValues(fv, fd.values[name])
}
return nil
}
// setValues writes a form field's values: each repeated key one element of a
// list, else the first value.
func setValues(fv reflect.Value, values []string) {
if len(values) == 0 {
return
}
if fv.Kind() == reflect.Slice && fv.Type().Elem().Kind() != reflect.Uint8 && !readsText(fv.Type()) {
out := reflect.MakeSlice(fv.Type(), len(values), len(values))
for i, s := range values {
setScalar(out.Index(i), s)
}
fv.Set(out)
return
}
setScalar(fv, values[0])
}
// setFiles writes the parts of one name onto a File, *File, []File or []*File.
func setFiles(fv reflect.Value, parts []File) {
if len(parts) == 0 {
return
}
one := func(t reflect.Type, p File) reflect.Value {
if t.Kind() == reflect.Pointer {
c := p
return reflect.ValueOf(&c)
}
return reflect.ValueOf(p)
}
if fv.Kind() == reflect.Slice {
out := reflect.MakeSlice(fv.Type(), len(parts), len(parts))
for i, p := range parts {
out.Index(i).Set(one(fv.Type().Elem(), p))
}
fv.Set(out)
return
}
fv.Set(one(fv.Type(), parts[0]))
}
// bindFormArgs writes an argument object's form fields onto v, read under
// their form names: an argument object names a form field the way the form
// does, so one name reaches it over REST, MCP and the CLI.
func bindFormArgs(v any, body []byte) error {
var obj map[string]json.RawMessage
if err := json.Unmarshal(body, &obj); err != nil {
return err
}
rv := reflect.ValueOf(v).Elem()
for _, f := range wireFields(rv.Type()) {
name := formFieldName(f)
raw, ok := obj[name]
if name == "" || !ok || string(raw) == "null" {
continue
}
fv := rv.FieldByIndex(f.Index)
if isFile(f.Type) {
if err := json.Unmarshal(raw, fv.Addr().Interface()); err != nil {
return fmt.Errorf("%s: %w", name, err)
}
continue
}
values, err := argValues(raw)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
setValues(fv, values)
}
return nil
}
// argValues reads one argument as the strings a form would carry: a list
// element by element, a string unquoted, any other value as written.
func argValues(raw json.RawMessage) ([]string, error) {
raw = json.RawMessage(strings.TrimSpace(string(raw)))
if len(raw) > 0 && raw[0] == '[' {
var items []json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil {
return nil, err
}
out := make([]string, 0, len(items))
for _, it := range items {
v, err := argValues(it)
if err != nil {
return nil, err
}
out = append(out, v...)
}
return out, nil
}
var s string
if json.Unmarshal(raw, &s) == nil {
return []string{s}, nil
}
return []string{string(raw)}, nil
}
// bindCookies copies declared cookie values onto the input, as bindHeaders does
// for headers.
func bindCookies(in any, get func(string) string) {
if get == nil {
return
}
v := reflect.ValueOf(in).Elem()
for _, f := range wireFields(v.Type()) {
name := cookieFieldName(f)
if name == "" {
continue
}
if got := get(name); got != "" {
setScalar(v.FieldByIndex(f.Index), got)
}
}
}
// defaultBodyType fills a [Body] field's Type, when an argument object left it
// empty, with the first media the op consumes.
func (r intake) defaultBodyType(v any, media []string) {
if r.raw == nil || len(media) == 0 {
return
}
field := reflect.ValueOf(v).Elem().FieldByIndex(r.raw)
if b := field.Addr().Interface().(*Body); b.Type == "" {
b.Type = media[0]
}
}
// base64Text is how bytes travel inside JSON.
func base64Text(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
// readForm reads a REST request's form: its url-encoded fields, or a multipart
// form's fields and parts, each part read whole.
func readForm(c fiber.Ctx, media string) (formData, error) {
fd := formData{values: map[string][]string{}, files: map[string][]File{}}
if m, _, _ := mime.ParseMediaType(media); m != mimeMultipart {
for k, v := range c.Request().PostArgs().All() {
fd.values[string(k)] = append(fd.values[string(k)], string(v))
}
return fd, nil
}
form, err := c.RequestCtx().MultipartForm()
if err != nil {
return fd, err
}
for k, v := range form.Value {
fd.values[k] = v
}
for k, parts := range form.File {
for _, h := range parts {
f, err := h.Open()
if err != nil {
return fd, err
}
b, err := io.ReadAll(f)
_ = f.Close()
if err != nil {
return fd, err
}
fd.files[k] = append(fd.files[k], File{Name: h.Filename, Type: h.Header.Get("Content-Type"), Bytes: b})
}
}
return fd, nil
}
+316
View File
@@ -0,0 +1,316 @@
// 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/base64"
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// An op whose request body is bytes — an upload, a statement, a signed webhook
// — takes it as a zip.Body field and is a typed op like any other: the bytes
// and their media type arrive untouched, and the input's other fields still
// bind from the URL.
type scanIn struct {
Org string `json:"org" url:"org"`
Kind string `json:"kind"`
Body zip.Body `json:"body"`
}
type scanOut struct {
Org string `json:"org"`
Kind string `json:"kind"`
Type string `json:"type"`
Size int `json:"size"`
Sum string `json:"sum"`
}
func scan(_ context.Context, in *scanIn) (*scanOut, error) {
return &scanOut{Org: in.Org, Kind: in.Kind, Type: in.Body.Type, Size: len(in.Body.Bytes),
Sum: base64.StdEncoding.EncodeToString(in.Body.Bytes)}, nil
}
func scanApp() *zip.App {
app := zip.New(zip.Config{AppName: "books", DisableStartupMessage: true})
zip.Describe("POST /v1/books/:org/scan", zip.Doc{
Description: "Scan reads a receipt.",
Fields: map[string]string{
"scanIn.org": "Org is the org whose books take the receipt.",
"scanIn.kind": "Kind names what the receipt is.",
"scanIn.body": "Body is the receipt, as the camera wrote it.",
},
})
app.Post("/v1/books/:org/scan", scan, zip.Consumes("application/pdf", "image/png"))
return app
}
// Bytes that are not JSON — here, not even UTF-8 — reach the handler exactly
// as sent, under the Content-Type they were sent with.
func TestBody_TakesTheBytesAsSent(t *testing.T) {
app := scanApp()
raw := []byte{0x25, 0x50, 0x44, 0x46, 0x00, 0xff, '{', 0x80}
req := httptest.NewRequest("POST", "/v1/books/acme/scan?kind=fuel", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/pdf")
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
var got scanOut
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("status %d: %v", resp.StatusCode, err)
}
if resp.StatusCode != 200 || got.Org != "acme" || got.Kind != "fuel" || got.Type != "application/pdf" ||
got.Sum != base64.StdEncoding.EncodeToString(raw) {
t.Errorf("status %d, handler saw %+v", resp.StatusCode, got)
}
}
// The document says what the wire takes: the bytes under each media type the
// op consumes, the field's prose as the body's, and every other field as a
// parameter of the URL that carries it.
func TestBody_IsPublishedAsBinary(t *testing.T) {
op := opIn(t, scanApp(), "/v1/books/{org}/scan", "post")
body := op["requestBody"].(map[string]any)
content := body["content"].(map[string]any)
if len(content) != 2 {
t.Fatalf("content = %v, want the two media the op consumes", content)
}
for _, media := range []string{"application/pdf", "image/png"} {
schema := content[media].(map[string]any)["schema"].(map[string]any)
if schema["type"] != "string" || schema["format"] != "binary" {
t.Errorf("%s schema = %v, want binary", media, schema)
}
}
if body["description"] != "Body is the receipt, as the camera wrote it." {
t.Errorf("requestBody description = %v", body["description"])
}
params := map[string]map[string]any{}
for _, p := range op["parameters"].([]any) {
m := p.(map[string]any)
params[m["in"].(string)+":"+m["name"].(string)] = m
}
if params["path:org"]["description"] != "Org is the org whose books take the receipt." {
t.Errorf("path org = %v", params["path:org"])
}
if params["query:kind"]["description"] != "Kind names what the receipt is." {
t.Errorf("query kind = %v, want the field the URL carries beside the bytes", params["query:kind"])
}
if _, ok := params["query:body"]; ok {
t.Error("the body is offered as a query parameter")
}
}
// Without Consumes the bytes are application/octet-stream.
func TestBody_DefaultsToOctets(t *testing.T) {
app := zip.New(zip.Config{AppName: "up", DisableStartupMessage: true})
app.Post("/v1/up", scan)
content := opIn(t, app, "/v1/up", "post")["requestBody"].(map[string]any)["content"].(map[string]any)
if _, ok := content["application/octet-stream"]; !ok || len(content) != 1 {
t.Errorf("content = %v, want application/octet-stream alone", content)
}
}
// Over MCP the input is one argument object, and the bytes are its base64
// string; their type is the first the op consumes.
func TestBody_CrossesMCPAsBase64(t *testing.T) {
app := scanApp()
var schema map[string]any
for _, tool := range app.MCPTools() {
if tool["name"] == zip.ID("POST", "/v1/books/:org/scan") {
schema = tool["inputSchema"].(map[string]any)
}
}
prop, _ := schema["properties"].(map[string]any)["body"].(map[string]any)
if prop["type"] != "string" || prop["contentEncoding"] != "base64" {
t.Fatalf("tool body = %v, want a base64 string", prop)
}
raw := []byte{0, 1, 2, 0xff}
built(t, app)
env := mcpCall(t, app, zip.ID("POST", "/v1/books/:org/scan"), map[string]any{
"org": "acme", "kind": "fuel", "body": base64.StdEncoding.EncodeToString(raw),
})
var got scanOut
if err := json.Unmarshal([]byte(mcpText(t, env["result"].(map[string]any))), &got); err != nil {
t.Fatal(err)
}
if got.Type != "application/pdf" || got.Size != 4 || got.Org != "acme" {
t.Errorf("handler saw %+v", got)
}
}
// A shape the wire cannot carry is refused when it is registered.
func TestBody_RefusesWhatTheWireCannotCarry(t *testing.T) {
type two struct {
A zip.Body `json:"a"`
B zip.Body `json:"b"`
}
type ptr struct {
B *zip.Body `json:"b"`
}
for name, register := range map[string]func(*zip.App){
"two bodies": func(a *zip.App) {
a.Post("/x", func(context.Context, *two) (*struct{}, error) { return nil, nil })
},
"a body on GET": func(a *zip.App) {
a.Get("/x", func(context.Context, *scanIn) (*struct{}, error) { return nil, nil })
},
"a pointer body": func(a *zip.App) {
a.Post("/x", func(context.Context, *ptr) (*struct{}, error) { return nil, nil })
},
"consumes on GET": func(a *zip.App) {
a.Get("/x", func(context.Context, *mkIn) (*struct{}, error) { return nil, nil }, zip.Consumes("text/plain"))
},
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: registered", name)
}
}()
register(zip.New(zip.Config{DisableStartupMessage: true}))
}()
}
}
// A Body is also an answer: bytes under their media type, a filename to save
// them under, and a status of their own.
type reportIn struct {
ID string `json:"id" url:"id"`
}
func report(_ context.Context, in *reportIn) (*zip.Body, error) {
if in.ID == "late" {
return &zip.Body{Type: "application/pdf", Bytes: []byte("%PDF-later"), Status: 202}, nil
}
return &zip.Body{Type: "application/pdf", Bytes: []byte("%PDF-1.7 \x00\xff"), Name: `report "q3".pdf`}, nil
}
func TestBody_AnswersBytes(t *testing.T) {
app := zip.New(zip.Config{AppName: "rep", DisableStartupMessage: true})
app.Get("/v1/reports/:id", report, zip.Produces("application/pdf"), zip.WithStatus(200, 202))
resp, err := app.Test(httptest.NewRequest("GET", "/v1/reports/q3", nil))
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 || string(b) != "%PDF-1.7 \x00\xff" {
t.Errorf("answered %d %q", resp.StatusCode, b)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/pdf" {
t.Errorf("Content-Type = %q", ct)
}
if cd := resp.Header.Get("Content-Disposition"); cd != `attachment; filename="report \"q3\".pdf"` {
t.Errorf("Content-Disposition = %q", cd)
}
resp, err = app.Test(httptest.NewRequest("GET", "/v1/reports/late", nil))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 202 || resp.Header.Get("Content-Disposition") != "" {
t.Errorf("late answered %d with %q", resp.StatusCode, resp.Header.Get("Content-Disposition"))
}
op := opIn(t, app, "/v1/reports/{id}", "get")
for _, code := range []string{"200", "202"} {
entry := op["responses"].(map[string]any)[code].(map[string]any)
content, _ := entry["content"].(map[string]any)
media, ok := content["application/pdf"].(map[string]any)
if !ok || media["schema"].(map[string]any)["format"] != "binary" {
t.Errorf("%s = %v, want binary application/pdf", code, entry)
}
if _, ok := entry["headers"].(map[string]any)["Content-Disposition"]; !ok {
t.Errorf("%s does not publish Content-Disposition", code)
}
}
}
// A Body with a Reader is written as the reader yields it, chunk by chunk.
func TestBody_StreamsAReader(t *testing.T) {
app := zip.New(zip.Config{AppName: "csv", DisableStartupMessage: true})
app.Get("/v1/export", func(context.Context, *struct{}) (*zip.Body, error) {
return &zip.Body{Type: "text/csv", Reader: io.NopCloser(strings.NewReader("a,b\n1,2\n"))}, nil
}, zip.Produces("text/csv"))
resp, err := app.Test(httptest.NewRequest("GET", "/v1/export", nil))
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
if string(b) != "a,b\n1,2\n" || resp.Header.Get("Content-Type") != "text/csv" {
t.Errorf("answered %q as %q", b, resp.Header.Get("Content-Type"))
}
if te := resp.TransferEncoding; len(te) == 0 || te[0] != "chunked" {
t.Errorf("transfer encoding = %v, want chunked", te)
}
}
// A tools/call of an op that answers bytes reads them into tool content: text
// as text, an image as an image, anything else as an embedded resource blob.
func TestBody_AnswersMCPAsContent(t *testing.T) {
app := zip.New(zip.Config{AppName: "rep", DisableStartupMessage: true})
app.Get("/v1/reports/:id", report, zip.Produces("application/pdf"), zip.WithStatus(200, 202))
app.Get("/v1/logo", func(context.Context, *struct{}) (*zip.Body, error) {
return &zip.Body{Type: "image/png", Bytes: []byte{0x89, 'P', 'N', 'G'}}, nil
}, zip.Produces("image/png"))
app.Get("/v1/readme", func(context.Context, *struct{}) (*zip.Body, error) {
return &zip.Body{Type: "text/markdown", Bytes: []byte("# hi")}, nil
}, zip.Produces("text/markdown"))
built(t, app)
first := func(env map[string]any) map[string]any {
return env["result"].(map[string]any)["content"].([]any)[0].(map[string]any)
}
pdf := first(mcpCall(t, app, zip.ID("GET", "/v1/reports/:id"), map[string]any{"id": "q3"}))
res, _ := pdf["resource"].(map[string]any)
if pdf["type"] != "resource" || res["mimeType"] != "application/pdf" ||
res["blob"] != base64.StdEncoding.EncodeToString([]byte("%PDF-1.7 \x00\xff")) {
t.Errorf("pdf = %v", pdf)
}
if img := first(mcpCall(t, app, zip.ID("GET", "/v1/logo"), nil)); img["type"] != "image" || img["mimeType"] != "image/png" {
t.Errorf("png = %v", img)
}
if txt := first(mcpCall(t, app, zip.ID("GET", "/v1/readme"), nil)); txt["type"] != "text" || txt["text"] != "# hi" {
t.Errorf("markdown = %v", txt)
}
}
// built builds app, which installs /mcp and the call plane.
func built(t *testing.T, app *zip.App) {
t.Helper()
if err := app.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
}
// opIn is one operation of an app's document.
func opIn(t *testing.T, app *zip.App, path, method string) map[string]any {
t.Helper()
b, err := json.Marshal(app.OpenAPISpec())
if err != nil {
t.Fatal(err)
}
var doc struct {
Paths map[string]map[string]map[string]any `json:"paths"`
}
if err := json.Unmarshal(b, &doc); err != nil {
t.Fatal(err)
}
op, ok := doc.Paths[path][method]
if !ok {
t.Fatalf("no %s %s in %s", method, path, b)
}
return op
}
+16 -1
View File
@@ -156,7 +156,7 @@ func (a *App) installCallPlane() {
// through a text format on the way. JSON is the BOUNDARY encoding — it
// belongs on the REST routes a browser reaches and in the MCP envelope an
// agent reads, and has no place inside the binary protocol.
out, err := op.invoke(withOp(callerContext(fc), servedOp(op)), zapenc.Unmarshal, fc.Body(), nil, nil, func(k string) string { return fc.Get(k) })
out, err := op.invoke(withOp(callerContext(fc), servedOp(op)), input{dec: zapenc.Unmarshal, body: fc.Body(), media: CallContentType, header: func(k string) string { return fc.Get(k) }})
if err != nil {
return sendCallError(fc, err)
}
@@ -176,6 +176,21 @@ func (a *App) installCallPlane() {
fc.Status(fiber.StatusAccepted)
return fc.Send(body)
}
// An answer that is not one value is not a message. A connection is not
// a call at all; an event stream is written as the REST door writes it,
// which over ZAP is zap-proto/http's head, data and end frames. Bytes
// that stream are read whole, since a reply here is one message.
switch x := unwrap(out).(type) {
case upgrader:
return sendCallError(fc, Errorf(fiber.StatusNotImplemented, "%s: %s", opName(op), NotACall))
case stream:
return writeAnswer(fc, op, x)
}
if b, ok := bodyOf(unwrap(out)); ok && b.Reader != nil {
if err := b.drain(); err != nil {
return sendCallError(fc, ErrInternal("zip: read answer: "+err.Error()))
}
}
// The call plane owns this response — one request, one op — so a declared
// response header is written here exactly as it is over REST.
hdrs, herr := responseHeadersOf(op, out)
+548 -140
View File
@@ -1,17 +1,29 @@
package zip
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/zap-proto/zip/internal/jsonenc"
"io"
"mime"
"mime/multipart"
"net/http"
"net/textproto"
neturl "net/url"
"os"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
"github.com/fasthttp/websocket"
"github.com/valyala/fasthttp"
"github.com/zap-proto/zip/internal/jsonenc"
"github.com/zap-proto/zip/internal/ws"
)
// CLI — the FOURTH projection. The same typed-op registry (a.registry) that produces
@@ -86,6 +98,16 @@ type Command struct {
// parameters the document had to split it across.
Example json.RawMessage
// Consumes are the media the request body is sent in when it is not
// application/json, sorted: bytes as sent for --body, a form for form
// fields and --field parts.
Consumes []string `json:",omitempty"`
// Stream is "sse", "bytes" or "socket" when the answer is not one JSON
// value: the runner prints events as they arrive, writes the bytes, or
// bridges the connection to stdin and stdout.
Stream string `json:",omitempty"`
// op is set only by App.Commands: it is what LocalInvoke runs. A
// spec-derived command has none and must be executed remotely.
op *registeredOp
@@ -100,10 +122,15 @@ type Arg struct {
// Flag is one In field as a flag.
type Flag struct {
Name string // the flag, kebab-cased and without the dashes: "organization-id"
Field string // the JSON field it sets: "organizationId"
Type string // string | integer | number | boolean | json
Field string // the name it is sent under: the JSON field, the header, the cookie or the form key
Type string // string | integer | number | boolean | json | file
Help string
Required bool
// In is where the value rides: "" the JSON body or the query, "header" or
// "cookie" a request header or cookie, "form" a form field, "file" a
// multipart part (--field name=@path), "body" the request body itself
// (--body @path, or - for stdin).
In string `json:",omitempty"`
}
// Commands projects every registered typed op into a command. This is the whole
@@ -123,88 +150,6 @@ func (a *App) Commands() []Command {
return cmds
}
// newCommand fills in everything that comes from the op's identity and its doc,
// which is the half both derivations share.
func newCommand(method, path, id, summary string, doc Doc, has bool) Command {
svc, name := commandName(method, path, id)
c := Command{Service: svc, Name: name, OperationID: id, Summary: summary, Method: method, Path: path}
if has {
c.Description = doc.Description
c.Example = doc.Example
if c.Summary == "" {
c.Summary = firstSentence(doc.Description)
}
}
return c
}
// bindIn splits an In type into positional args and flags. A field whose name
// matches a path parameter is positional and is NOT also a flag: the URL
// addresses the resource, so offering a second way to set the same value would
// be two ways to say one thing (and bindPath would overrule one of them).
//
// body says the method carries one (hasBody). A bodyless op's input rides the
// URL, so its flags are exactly the URL-bindable fields — the SAME urlFields
// list the document reads. Offering the rest would offer flags the wire cannot
// carry: a `--tags '["a"]'` on a DELETE marshalled fine, went out as a query
// value, and was dropped by the binder, so the command silently did nothing.
func bindIn(in reflect.Type, params []pathParam, fieldDocs map[string]string, body bool) ([]Arg, []Flag) {
args := make([]Arg, 0, len(params))
for _, p := range params {
// The argument a person types is the DOCUMENT's name: a wildcard's router
// key is *1, which is not something to ask anyone to write.
args = append(args, Arg{Name: p.Name, Help: fieldDocs[typeName(in)+"."+p.Name]})
}
if in == nil {
return args, nil
}
t := in
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return args, nil
}
var flags []Flag
// url is the name the URL carries the field under, which is what decides
// whether a flag would be a SECOND way to say what the path already says. It
// is not always the field's JSON name: a body field tagged `url:"-"` shares
// its name with a path param on purpose (the worker's source vs its name), and
// offering no flag for it left the command unable to send the body at all.
add := func(name, url, kind string, required bool) {
if name == "-" || isParam(params, url) {
return
}
flags = append(flags, Flag{
Name: kebab(name),
Field: name,
Type: kind,
Help: fieldDocs[t.Name()+"."+name],
Required: required,
})
}
if body {
// wireFields, so an embedded body type's promoted fields get flags. Reading
// only the outer type left a command unable to send them at all.
for _, f := range wireFields(t) {
add(jsonFieldName(f), urlFieldName(f), flagType(f.Type), strings.Contains(f.Tag.Get("validate"), "required"))
}
} else {
for _, f := range urlFields(t) {
kind, _ := f.schema["type"].(string)
add(f.name, f.name, specType(kind), f.required)
}
}
// The args' help lives under the field's own json name, which is what the
// extraction keyed it by; look it up now that the type is in hand.
for i, a := range args {
if a.Help == "" {
args[i].Help = fieldDocs[t.Name()+"."+a.Name]
}
}
return args, flags
}
func isParam(params []pathParam, name string) bool {
for _, p := range params {
if strings.EqualFold(p.Name, name) || strings.EqualFold(p.Key, name) {
@@ -392,7 +337,10 @@ func kebab(s string) string {
case r >= 'A' && r <= 'Z':
prevLower := i > 0 && (rs[i-1] >= 'a' && rs[i-1] <= 'z' || rs[i-1] >= '0' && rs[i-1] <= '9')
nextLower := i+1 < len(rs) && rs[i+1] >= 'a' && rs[i+1] <= 'z'
if i > 0 && (prevLower || nextLower) {
// A word a separator already opened takes no second one: a header
// is X-Org-Id, and its flag is x-org-id.
opened := i > 0 && strings.ContainsRune("-_ .", rs[i-1])
if i > 0 && !opened && (prevLower || nextLower) {
b.WriteByte('-')
}
b.WriteRune(r + ('a' - 'A'))
@@ -425,16 +373,65 @@ type Invoker func(ctx context.Context, c Command, path map[string]string, body [
// LocalInvoke runs the handler in this process, through the op's own invoke
// seam. It is the whole reason a fused binary needs no client: the command IS
// the handler call.
//
// A command's arguments are named, so they bind as path values, and a CLI has
// no URL for the "?a=b" half: everything else arrives as one argument object,
// exactly as it does over MCP. The flags that ride as a header or a cookie are
// handed to the op as one, which is the request fact it declared.
func LocalInvoke(ctx context.Context, c Command, path map[string]string, body []byte) (any, error) {
if c.op == nil || c.op.invoke == nil {
return nil, fmt.Errorf("%s %s is not registered in this process — give the CLI a Remote invoker", c.Service, c.Name)
}
// No query map and no headers: a command's arguments are named, so they bind
// as path values, and a CLI has neither a URL for the "?a=b" half nor a
// request to read headers from. A declared header field is simply supplied
// as an argument here — which is why the honest answer on a transport with
// no request is "nothing", not a panic.
return c.op.invoke(withOp(ctx, servedOp(c.op)), jsonenc.Unmarshal, body, nil, path, nil)
rest, header, cookie, err := c.split(body)
if err != nil {
return nil, err
}
// The body flag is sent under "body", which the document can name; the
// op's own field may be named otherwise.
if raw := c.op.req.raw; raw != nil {
if v, ok := rest["body"]; ok {
delete(rest, "body")
rest[jsonFieldName(deref(c.op.InType).FieldByIndex(raw))] = v
}
}
wire := input{dec: jsonenc.Unmarshal, media: mimeJSON, path: path,
header: func(k string) string { return header[k] },
cookie: func(k string) string { return cookie[k] }}
if len(rest) > 0 {
if wire.body, err = json.Marshal(rest); err != nil {
return nil, err
}
}
return c.op.invoke(withOp(ctx, servedOp(c.op)), wire)
}
// split takes a command's argument object apart: the values that ride as
// headers and cookies, keyed by the header and the cookie, and the rest.
func (c Command) split(body []byte) (rest map[string]json.RawMessage, header, cookie map[string]string, err error) {
rest = map[string]json.RawMessage{}
header, cookie = map[string]string{}, map[string]string{}
if len(body) > 0 {
if err := json.Unmarshal(body, &rest); err != nil {
return nil, nil, nil, fmt.Errorf("%s %s: the arguments are not an object: %w", c.Service, c.Name, err)
}
}
for _, f := range c.Flags {
raw, ok := rest[f.Field]
if !ok || (f.In != "header" && f.In != "cookie") {
continue
}
v, err := queryValue(raw)
if err != nil {
return nil, nil, nil, err
}
if f.In == "header" {
header[f.Field] = v
} else {
cookie[f.Field] = v
}
delete(rest, f.Field)
}
return rest, header, cookie, nil
}
// Remote executes a command against a running zip service, and reads that
@@ -456,50 +453,279 @@ func (r Remote) Spec(ctx context.Context) ([]byte, error) {
// Invoke sends one command to the service. Its signature is Invoker's, so it
// drops into a CLI wherever LocalInvoke would.
//
// The request is built the way the op takes it: header and cookie flags as
// headers and cookies; a --body as the bytes themselves under the media the op
// consumes; form fields and --field parts as a form; the rest as the JSON body,
// or as the query when the method carries no body or the body is not JSON. An
// answer that is not one JSON value comes back as it arrives — a [Body] whose
// Reader the runner writes, events as the stream yields them, a [Redirect] for
// a 3xx, and for an op that upgrades, the open connection.
func (r Remote) Invoke(ctx context.Context, c Command, path map[string]string, body []byte) (any, error) {
url := c.Path
for name, val := range path {
url = strings.ReplaceAll(url, ":"+name, urlEscape(val))
}
// A bodyless method's non-path inputs belong in the query string, which is
// where the route's own decoder looks for them. hasBody is THE rule, read
// here as well as by the document and by the route itself.
if !hasBody(c.Method) && len(body) > 0 {
q, err := queryOf(body)
rest, header, cookie, err := c.split(body)
if err != nil {
return nil, err
}
var payload []byte
var media string
kind := ""
for _, f := range c.Flags {
switch f.In {
case "body", "form", "file":
kind = f.In
}
if kind == "body" {
break
}
}
switch {
case kind == "body":
if raw, ok := rest["body"]; ok {
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, fmt.Errorf("--body: %w", err)
}
delete(rest, "body")
}
media = mimeOctet
if len(c.Consumes) > 0 {
media = c.Consumes[0]
}
case kind == "form" || kind == "file":
payload, media, err = c.form(rest)
if err != nil {
return nil, err
}
case hasBody(c.Method) && c.Stream != streamSocket:
if len(rest) > 0 {
if payload, err = json.Marshal(rest); err != nil {
return nil, err
}
media = mimeJSON
if len(c.Consumes) > 0 && !containsString(c.Consumes, mimeJSON) {
media = c.Consumes[0]
}
}
rest = nil
}
if len(rest) > 0 {
b, err := json.Marshal(rest)
if err != nil {
return nil, err
}
q, err := queryOf(b)
if err != nil {
return nil, err
}
if q != "" {
url += "?" + q
}
body = nil
}
out, err := r.do(ctx, c.Method, url, body)
if c.Stream == streamSocket {
return r.dial(ctx, url, header, cookie)
}
return r.send(ctx, c, url, payload, media, header, cookie)
}
// form encodes a command's form fields and file parts, and takes them out of
// rest: url-encoded, or multipart when the op consumes it or a part is a file.
func (c Command) form(rest map[string]json.RawMessage) ([]byte, string, error) {
multi := containsString(c.Consumes, mimeMultipart)
for _, f := range c.Flags {
if f.In == "file" {
multi = true
}
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
values := neturl.Values{}
for _, f := range c.Flags {
raw, ok := rest[f.Field]
if !ok || (f.In != "form" && f.In != "file") {
continue
}
delete(rest, f.Field)
if f.In == "file" {
var parts []File
if raw[0] == '[' {
if err := json.Unmarshal(raw, &parts); err != nil {
return nil, "", fmt.Errorf("--field %s: %w", f.Field, err)
}
} else {
var one File
if err := json.Unmarshal(raw, &one); err != nil {
return nil, "", fmt.Errorf("--field %s: %w", f.Field, err)
}
parts = []File{one}
}
for _, part := range parts {
h := textproto.MIMEHeader{}
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, f.Field, part.Name))
h.Set("Content-Type", nonEmpty(part.Type, mimeOctet))
w, err := mw.CreatePart(h)
if err != nil {
return nil, "", err
}
if _, err := w.Write(part.Bytes); err != nil {
return nil, "", err
}
}
continue
}
vals, err := argValues(raw)
if err != nil {
return nil, "", fmt.Errorf("--%s: %w", f.Name, err)
}
for _, v := range vals {
values.Add(f.Field, v)
if multi {
if err := mw.WriteField(f.Field, v); err != nil {
return nil, "", err
}
}
}
}
if !multi {
return []byte(values.Encode()), mimeForm, nil
}
if err := mw.Close(); err != nil {
return nil, "", err
}
return buf.Bytes(), mw.FormDataContentType(), nil
}
// send performs one request and reads its answer the way the command says it
// comes back.
func (r Remote) send(ctx context.Context, c Command, path string, body []byte, media string, header, cookie map[string]string) (any, error) {
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
if len(body) > 0 {
req.SetBody(body)
req.Header.SetContentType(media)
}
for k, v := range header {
req.Header.Set(k, v)
}
for k, v := range cookie {
req.Header.SetCookie(k, v)
}
resp := fasthttp.AcquireResponse()
resp.StreamBody = c.Stream != ""
if err := r.exchange(ctx, c.Method, path, req, resp); err != nil {
fasthttp.ReleaseResponse(resp)
return nil, err
}
code := resp.StatusCode()
if code >= 300 && code < 400 {
to := string(resp.Header.Peek("Location"))
fasthttp.ReleaseResponse(resp)
return &Redirect{To: to, Status: code}, nil
}
ct := string(resp.Header.ContentType())
if c.Stream == "" || code >= 400 || code == fasthttp.StatusNoContent {
out := append([]byte(nil), resp.Body()...)
fasthttp.ReleaseResponse(resp)
if code >= 400 {
return nil, fmt.Errorf("%s %s: %d %s", c.Method, path, code, strings.TrimSpace(string(out)))
}
if len(out) == 0 {
return nil, nil
}
if c.Stream == "" && !textual(ct) || c.Stream == streamBytes {
return &Body{Type: ct, Bytes: out}, nil
}
return json.RawMessage(out), nil
}
if s := resp.BodyStream(); s != nil {
return &Body{Type: ct, Reader: &answerStream{Reader: s, resp: resp}}, nil
}
out := append([]byte(nil), resp.Body()...)
fasthttp.ReleaseResponse(resp)
return &Body{Type: ct, Bytes: out}, nil
}
// answerStream is a streamed answer's body, holding its response until it is
// closed.
type answerStream struct {
io.Reader
resp *fasthttp.Response
}
func (a *answerStream) Close() error {
err := a.resp.CloseBodyStream()
fasthttp.ReleaseResponse(a.resp)
return err
}
// dial opens the WebSocket an op upgrades to, over the address Base names.
func (r Remote) dial(ctx context.Context, path string, header, cookie map[string]string) (*ws.Conn, error) {
scheme, host, _, err := transportFor(r.Base)
if err != nil {
return nil, err
}
if len(out) == 0 {
return nil, nil
var url string
switch scheme {
case "http":
url = "ws://" + host + path
case "https":
url = "wss://" + host + path
default:
return nil, fmt.Errorf("zip: a WebSocket is reached over http or https, and %q is %s", r.Base, scheme)
}
return json.RawMessage(out), nil
h := http.Header{}
for k, v := range r.Header {
h.Set(k, v)
}
for k, v := range header {
h.Set(k, v)
}
for k, v := range cookie {
h.Add("Cookie", (&http.Cookie{Name: k, Value: v}).String())
}
conn, resp, err := websocket.DefaultDialer.DialContext(ctx, url, h)
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
if err != nil {
return nil, fmt.Errorf("GET %s: %w", path, err)
}
return conn, nil
}
// do performs one request over the transport Base names, bounded by ctx.
func (r Remote) do(ctx context.Context, method, path string, body []byte) ([]byte, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
scheme, host, t, err := transportFor(r.Base)
if err != nil {
return nil, err
}
if t.Dial == nil {
return nil, fmt.Errorf("zip: transport %q cannot dial (serve-only)", scheme)
}
req, resp := fasthttp.AcquireRequest(), fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
if len(body) > 0 {
req.SetBody(body)
req.Header.SetContentType("application/json")
}
if err := r.exchange(ctx, method, path, req, resp); err != nil {
return nil, err
}
out := append([]byte(nil), resp.Body()...)
if code := resp.StatusCode(); code >= 400 {
return nil, fmt.Errorf("%s %s: %d %s", method, path, code, strings.TrimSpace(string(out)))
}
return out, nil
}
// exchange sends req and fills resp over the transport Base names.
func (r Remote) exchange(ctx context.Context, method, path string, req *fasthttp.Request, resp *fasthttp.Response) error {
if err := ctx.Err(); err != nil {
return err
}
scheme, host, t, err := transportFor(r.Base)
if err != nil {
return err
}
if t.Dial == nil {
return fmt.Errorf("zip: transport %q cannot dial (serve-only)", scheme)
}
req.SetRequestURI(path)
req.SetHost(host)
req.Header.SetMethod(method)
@@ -526,21 +752,29 @@ func (r Remote) do(ctx context.Context, method, path string, body []byte) ([]byt
if scheme == "https" {
req.URI().SetScheme("https")
}
if len(body) > 0 {
req.SetBody(body)
req.Header.SetContentType("application/json")
}
for k, v := range r.Header {
req.Header.Set(k, v)
}
if err := do(ctx, t.Dial(host), req, resp); err != nil {
return nil, fmt.Errorf("%s %s: %w", method, path, err)
return fmt.Errorf("%s %s: %w", method, path, err)
}
out := append([]byte(nil), resp.Body()...)
if code := resp.StatusCode(); code >= 400 {
return nil, fmt.Errorf("%s %s: %d %s", method, path, code, strings.TrimSpace(string(out)))
return nil
}
func containsString(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return out, nil
return false
}
func nonEmpty(s, dflt string) string {
if s == "" {
return dflt
}
return s
}
// Query is the query string that carries in to a bodyless op.
@@ -665,6 +899,10 @@ type CLI struct {
Commands []Command
Invoke Invoker
Out io.Writer
// In is the standard input: what `--body -` reads, and what a command
// that opens a WebSocket sends, one text message per line. Nil reads
// nothing.
In io.Reader
}
// CLI returns the command line for everything registered on this app, executed
@@ -727,28 +965,54 @@ func (c *CLI) Run(ctx context.Context, args []string) error {
cmd.help(out, c.Name)
return nil
}
path, body, err := cmd.parse(rest)
path, body, to, media, err := cmd.parse(rest, c.In)
if err != nil {
cmd.help(out, c.Name)
return err
}
if containsString(cmd.Consumes, media) {
// The file's own type, when the op takes it, is what the body is sent
// as; otherwise the first the op names.
cmd.Consumes = append([]string{media}, cmd.Consumes...)
}
res, err := c.Invoke(ctx, cmd, path, body)
if err != nil {
return err
}
return writeResult(out, res)
if to != "" {
f, err := os.Create(to)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
out = f
}
return writeResult(ctx, out, c.In, res)
}
// parse turns the remaining argv into the two values an op's invoke seam takes:
// the path parameters and the JSON body. Only flags that were actually given
// appear in the body — an absent flag must not become a zero that overwrites a
// server-side default.
func (c Command) parse(args []string) (map[string]string, []byte, error) {
//
// Bytes are named, not typed: `--body @file` sends a file as the request body
// (`--body -` the standard input), and `--field name=@file` sends one as a
// multipart part, as curl spells both. Each crosses as its base64 string, the
// form an argument object carries bytes in. `--out file` writes an answer that
// is not one JSON value to that file instead of the output; it is the runner's
// own flag, read only where the op names none of that name.
func (c Command) parse(args []string, stdin io.Reader) (path map[string]string, body []byte, to, media string, err error) {
byName := make(map[string]Flag, len(c.Flags))
parts := map[string]Flag{}
for _, f := range c.Flags {
if f.In == "file" {
parts[f.Field] = f
continue
}
byName[f.Name] = f
}
fields := map[string]json.RawMessage{}
files := map[string][]File{}
var positional []string
for i := 0; i < len(args); i++ {
@@ -759,48 +1023,116 @@ func (c Command) parse(args []string) (map[string]string, []byte, error) {
}
name, val, hasVal := strings.Cut(strings.TrimPrefix(a, "--"), "=")
f, ok := byName[name]
if !ok {
return nil, nil, fmt.Errorf("unknown flag --%s for %s %s", name, c.Service, c.Name)
runner := !ok && (name == "out" || name == "field" && len(parts) > 0)
if !ok && !runner {
return nil, nil, "", "", fmt.Errorf("unknown flag --%s for %s %s", name, c.Service, c.Name)
}
if !hasVal {
// A boolean stands alone; anything else takes the next argument.
if f.Type == "boolean" {
if ok && f.Type == "boolean" {
val = "true"
} else {
if i+1 >= len(args) {
return nil, nil, fmt.Errorf("--%s needs a value", name)
return nil, nil, "", "", fmt.Errorf("--%s needs a value", name)
}
i++
val = args[i]
}
}
raw, err := encodeFlag(f, val)
if err != nil {
return nil, nil, err
switch {
case name == "out" && !ok:
to = val
continue
case name == "field" && !ok:
part, from, _ := strings.Cut(val, "=")
pf, known := parts[part]
if !known {
return nil, nil, "", "", fmt.Errorf("--field %s: %s %s takes no part of that name", part, c.Service, c.Name)
}
b, rerr := readNamed(pf.Name, from, stdin)
if rerr != nil {
return nil, nil, "", "", rerr
}
files[pf.Field] = append(files[pf.Field], File{Name: filepath.Base(strings.TrimPrefix(from, "@")), Type: mediaOf(from), Bytes: b})
continue
}
if f.In == "body" {
media = mediaOf(val)
b, rerr := readNamed(f.Name, val, stdin)
if rerr != nil {
return nil, nil, "", "", rerr
}
raw, merr := json.Marshal(b)
if merr != nil {
return nil, nil, "", "", merr
}
fields[f.Field] = raw
continue
}
raw, ferr := encodeFlag(f, val)
if ferr != nil {
return nil, nil, "", "", ferr
}
fields[f.Field] = raw
}
for name, got := range files {
var raw []byte
var merr error
if len(got) == 1 {
raw, merr = json.Marshal(got[0])
} else {
raw, merr = json.Marshal(got)
}
if merr != nil {
return nil, nil, "", "", merr
}
fields[name] = raw
}
if len(positional) != len(c.Args) {
return nil, nil, fmt.Errorf("%s %s takes %d argument(s): %s",
return nil, nil, "", "", fmt.Errorf("%s %s takes %d argument(s): %s",
c.Service, c.Name, len(c.Args), argNames(c.Args))
}
path := make(map[string]string, len(c.Args))
path = make(map[string]string, len(c.Args))
for i, a := range c.Args {
path[a.Name] = positional[i]
}
for _, f := range c.Flags {
if f.Required {
if _, ok := fields[f.Field]; !ok {
return nil, nil, fmt.Errorf("--%s is required", f.Name)
return nil, nil, "", "", fmt.Errorf("--%s is required", f.Name)
}
}
}
if len(fields) == 0 {
return path, nil, nil
return path, nil, to, media, nil
}
body, err := json.Marshal(fields)
return path, body, err
body, err = json.Marshal(fields)
return path, body, to, media, err
}
// readNamed reads the bytes a flag names: @path is the file at path, and - is
// the standard input.
func readNamed(flag, val string, stdin io.Reader) ([]byte, error) {
switch {
case val == "-":
if stdin == nil {
return nil, fmt.Errorf("--%s -: there is no standard input", flag)
}
return io.ReadAll(stdin)
case strings.HasPrefix(val, "@"):
return os.ReadFile(val[1:])
}
return nil, fmt.Errorf("--%s takes @file or -, not %q", flag, val)
}
// mediaOf is the media type a file's extension names, octet-stream when it
// names none.
func mediaOf(path string) string {
if m := mime.TypeByExtension(filepath.Ext(path)); m != "" {
return m
}
return mimeOctet
}
// encodeFlag turns one flag value into the JSON it stands for, refusing a value
@@ -835,10 +1167,42 @@ func encodeFlag(f Flag, val string) (json.RawMessage, error) {
}
}
func writeResult(w io.Writer, res any) error {
// writeResult prints an answer. One JSON value is printed indented. Bytes are
// written as they are, as they arrive when they stream. An event stream is
// printed one event per line as each arrives. A redirect prints where it sends
// the client. An open WebSocket is bridged: each line of in is sent as a text
// message and each message received is printed on a line, until either side
// ends.
func writeResult(ctx context.Context, w io.Writer, in io.Reader, res any) error {
res = unwrap(res)
if res == nil {
return nil
}
switch x := res.(type) {
case *Redirect:
_, err := fmt.Fprintln(w, x.To)
return err
case *ws.Conn:
return bridge(ctx, x, in, w)
case upgrader:
return fmt.Errorf("zip: %s; reach it over HTTP", NotACall)
case stream:
return x.events(false, func(frame []byte) error {
_, err := fmt.Fprintln(w, string(frame))
return err
})
}
if b, ok := bodyOf(res); ok {
if b.Reader == nil {
_, err := w.Write(b.Bytes)
return err
}
if c, ok := b.Reader.(io.Closer); ok {
defer func() { _ = c.Close() }()
}
_, err := io.Copy(w, b.Reader)
return err
}
b, err := json.MarshalIndent(res, "", " ")
if err != nil {
return err
@@ -847,6 +1211,36 @@ func writeResult(w io.Writer, res any) error {
return err
}
// bridge joins a WebSocket to a terminal: lines in, messages out.
func bridge(ctx context.Context, conn *ws.Conn, in io.Reader, w io.Writer) error {
defer func() { _ = conn.Close() }()
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
defer stop()
if in != nil {
go func() {
lines := bufio.NewScanner(in)
for lines.Scan() {
if conn.WriteMessage(websocket.TextMessage, lines.Bytes()) != nil {
return
}
}
_ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
}()
}
for {
_, msg, err := conn.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) || ctx.Err() != nil {
return nil
}
return err
}
if _, err := fmt.Fprintln(w, string(msg)); err != nil {
return err
}
}
}
func (c *CLI) find(service, name string) []Command {
var out []Command
for _, cmd := range c.Commands {
@@ -971,7 +1365,7 @@ func (c Command) help(w io.Writer, bin string) {
width = max(width, len(a.Name)+2)
}
for _, f := range c.Flags {
width = max(width, len(f.Name)+len(f.Type)+3)
width = max(width, len(f.usage()))
}
if len(c.Args) > 0 {
fmt.Fprintf(w, "Arguments:\n")
@@ -987,15 +1381,29 @@ func (c Command) help(w io.Writer, bin string) {
if f.Required {
help = strings.TrimSpace(help + " (required)")
}
fmt.Fprintf(w, " %-*s %s\n", width, "--"+f.Name+" "+f.Type, help)
fmt.Fprintf(w, " %-*s %s\n", width, f.usage(), help)
}
fmt.Fprintln(w)
}
if c.Stream != "" && c.Stream != streamSocket {
fmt.Fprintf(w, "The answer is written to the output as it arrives; --out <file> writes it to a file.\n\n")
}
if ex := c.exampleLine(bin); ex != "" {
fmt.Fprintf(w, "Example:\n %s\n", ex)
}
}
// usage is how a flag is written on a command line.
func (f Flag) usage() string {
switch f.In {
case "body":
return "--" + f.Name + " @file|-"
case "file":
return "--field " + f.Field + "=@file"
}
return "--" + f.Name + " " + f.Type
}
// exampleLine renders the doc comment's Example body as the command line that
// sends it. The example in the reference and the example on the terminal are
// then the same value, projected twice.
+204
View File
@@ -0,0 +1,204 @@
// 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"
"os"
"path/filepath"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// Every kind of request and answer is a command. Bytes are named with @file or
// -, a multipart part with --field name=@file, a header or cookie is a flag
// that rides as one, and an answer that is not one JSON value is written as it
// is: bytes to the output or --out, events as they arrive, a redirect's
// Location, a WebSocket bridged to the terminal.
func kindsApp() *zip.App {
app := zip.New(zip.Config{AppName: "kinds", DisableStartupMessage: true})
app.Post("/v1/books/:org/scan", scan, zip.Consumes("application/pdf", "image/png"))
app.Post("/v1/upload", upload)
app.Post("/v1/token", token)
app.Get("/v1/reports/:id", report, zip.Produces("application/pdf"), zip.WithStatus(200, 202))
app.Get("/v1/login", func(context.Context, *struct{}) (*zip.Redirect, error) {
return &zip.Redirect{To: "https://id.example/authorize"}, nil
})
app.Post("/v1/ask", either)
app.Get("/v1/rooms", room)
app.Post("/v1/hdr", func(_ context.Context, in *hdrIn) (*mkOut, error) { return &mkOut{ID: in.Org + "/" + in.Name}, nil })
app.Get("/v1/hdr", func(_ context.Context, in *hdrIn) (*mkOut, error) { return &mkOut{ID: in.Org + "/" + in.Name}, nil })
app.Get("/v1/me", func(_ context.Context, in *meIn) (*mkOut, error) { return &mkOut{ID: in.Session}, nil })
return app
}
// hdrIn reads a declared header beside its other fields.
type hdrIn struct {
Org string `json:"org" header:"X-Org-Id"`
Name string `json:"name"`
}
// meIn reads a session cookie.
type meIn struct {
Session string `json:"session" cookie:"session"`
}
// argv is the command line that reaches the op at method path.
func argv(t *testing.T, cmds []zip.Command, method, path string, rest ...string) []string {
t.Helper()
id := zip.ID(method, path)
for _, c := range cmds {
if c.OperationID == id {
return append([]string{c.Service, c.Name}, rest...)
}
}
t.Fatalf("no command for %s", id)
return nil
}
// The two derivations — off the registry and off the document — are the same
// commands for every kind.
func TestCLIKinds_SpecAndRegistryAgree(t *testing.T) {
app := kindsApp()
spec, err := json.Marshal(app.OpenAPISpec())
if err != nil {
t.Fatal(err)
}
fromSpec, err := zip.CommandsFromSpec(spec)
if err != nil {
t.Fatal(err)
}
fromRegistry := app.Commands()
if len(fromSpec) != len(fromRegistry) {
t.Fatalf("spec has %d commands, registry %d", len(fromSpec), len(fromRegistry))
}
for i, r := range fromRegistry {
s := fromSpec[i]
if fmt.Sprint(r.Flags) != fmt.Sprint(s.Flags) || fmt.Sprint(r.Consumes) != fmt.Sprint(s.Consumes) || r.Stream != s.Stream {
t.Errorf("%s:\n registry %v %v %q\n spec %v %v %q", r.OperationID, r.Flags, r.Consumes, r.Stream, s.Flags, s.Consumes, s.Stream)
}
}
}
func TestCLIKinds_Local(t *testing.T) {
app := kindsApp()
dir := t.TempDir()
pdf := filepath.Join(dir, "r.pdf")
_ = os.WriteFile(pdf, []byte("%PDF\x00\xff"), 0o600)
png := filepath.Join(dir, "me.png")
_ = os.WriteFile(png, []byte("PNG"), 0o600)
cmds := app.Commands()
run := func(stdin string, args ...string) string {
t.Helper()
var out bytes.Buffer
cli := app.CLI()
cli.Out = &out
cli.In = strings.NewReader(stdin)
if err := cli.Run(context.Background(), args); err != nil {
t.Fatalf("%v: %v", args, err)
}
return out.String()
}
var seen scanOut
_ = json.Unmarshal([]byte(run("", argv(t, cmds, "POST", "/v1/books/:org/scan", "acme", "--kind", "fuel", "--body", "@"+pdf)...)), &seen)
if seen.Org != "acme" || seen.Kind != "fuel" || seen.Size != 6 || seen.Type != "application/pdf" {
t.Errorf("scan saw %+v", seen)
}
_ = json.Unmarshal([]byte(run("\x01\x02", argv(t, cmds, "POST", "/v1/books/:org/scan", "acme", "--body", "-")...)), &seen)
if seen.Size != 2 {
t.Errorf("scan from stdin saw %+v", seen)
}
var up uploadOut
_ = json.Unmarshal([]byte(run("", argv(t, cmds, "POST", "/v1/upload", "--title", "Q3", "--field", "avatar=@"+png)...)), &up)
if up.Title != "Q3" || up.Avatar != "me.png|image/png|PNG" {
t.Errorf("upload saw %+v", up)
}
saved := filepath.Join(dir, "out.pdf")
if got := run("", argv(t, cmds, "GET", "/v1/reports/:id", "q3", "--out", saved)...); got != "" {
t.Errorf("--out still printed %q", got)
}
if b, _ := os.ReadFile(saved); string(b) != "%PDF-1.7 \x00\xff" {
t.Errorf("--out wrote %q", b)
}
if got := run("", argv(t, cmds, "GET", "/v1/login")...); got != "https://id.example/authorize\n" {
t.Errorf("redirect printed %q", got)
}
if got := run("", argv(t, cmds, "POST", "/v1/ask", "--stream")...); got != "{\"text\":\"a\"}\n{\"text\":\"b\"}\n[DONE]\n" {
t.Errorf("stream printed %q", got)
}
}
func TestCLIKinds_Remote(t *testing.T) {
addr := serveHTTP(t, kindsApp())
remote := zip.Remote{Base: "http://" + addr}
spec, err := remote.Spec(context.Background())
if err != nil {
t.Fatal(err)
}
cmds, err := zip.CommandsFromSpec(spec)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
png := filepath.Join(dir, "me.png")
_ = os.WriteFile(png, []byte("PNG"), 0o600)
run := func(stdin string, args ...string) string {
t.Helper()
var out bytes.Buffer
cli := &zip.CLI{Name: "kinds", Commands: cmds, Invoke: remote.Invoke, Out: &out, In: strings.NewReader(stdin)}
if err := cli.Run(context.Background(), args); err != nil {
t.Fatalf("%v: %v", args, err)
}
return out.String()
}
var seen scanOut
_ = json.Unmarshal([]byte(run("\x00\x01\x02", argv(t, cmds, "POST", "/v1/books/:org/scan", "acme", "--kind", "fuel", "--body", "-")...)), &seen)
if seen.Org != "acme" || seen.Kind != "fuel" || seen.Size != 3 || seen.Type != "application/pdf" {
t.Errorf("scan saw %+v", seen)
}
var up uploadOut
_ = json.Unmarshal([]byte(run("", argv(t, cmds, "POST", "/v1/upload", "--title", "Q3", "--field", "avatar=@"+png)...)), &up)
if up.Title != "Q3" || up.Avatar != "me.png|image/png|PNG" {
t.Errorf("upload saw %+v", up)
}
var tok tokenOut
_ = json.Unmarshal([]byte(run("", argv(t, cmds, "POST", "/v1/token", "--grant-type", "code", "--code", "c1",
"--scope", `["read","write"]`, "--device", "d9")...)), &tok)
if tok.Grant != "code" || tok.Code != "c1" || strings.Join(tok.Scope, ",") != "read,write" || tok.Device != "d9" {
t.Errorf("token saw %+v", tok)
}
if got := run("", argv(t, cmds, "GET", "/v1/reports/:id", "q3")...); got != "%PDF-1.7 \x00\xff" {
t.Errorf("report printed %q", got)
}
if got := run("", argv(t, cmds, "GET", "/v1/login")...); got != "https://id.example/authorize\n" {
t.Errorf("redirect printed %q", got)
}
if got := run("", argv(t, cmds, "POST", "/v1/ask", "--stream")...); got != "data: {\"text\":\"a\"}\n\ndata: {\"text\":\"b\"}\n\ndata: [DONE]\n\n" {
t.Errorf("stream printed %q", got)
}
if got := run("hello\n", argv(t, cmds, "GET", "/v1/rooms", "--room", "r1")...); got != "r1:HELLO\n" {
t.Errorf("socket printed %q", got)
}
var who mkOut
_ = json.Unmarshal([]byte(run("", argv(t, cmds, "GET", "/v1/hdr", "--x-org-id", "acme", "--name", "n")...)), &who)
if who.ID != "acme/n" {
t.Errorf("header op saw %+v", who)
}
_ = json.Unmarshal([]byte(run("", argv(t, cmds, "GET", "/v1/me", "--session", "s1")...)), &who)
if who.ID != "s1" {
t.Errorf("cookie op saw %+v", who)
}
}
+112 -9
View File
@@ -42,6 +42,8 @@ func CommandsFromSpec(spec []byte) ([]Command, error) {
}
c.Args, c.Flags = doc.bind(op, pathParams(route))
c.Example = op.example()
c.Consumes = op.consumes()
c.Stream = op.stream()
cmds = append(cmds, c)
}
}
@@ -63,22 +65,54 @@ func (d specDoc) bind(op specOp, params []pathParam) ([]Arg, []Flag) {
}
var flags []Flag
add := func(name, typ, description string, required bool) {
add := func(name, typ, description string, required bool, in string) {
if isParam(params, name) {
return
}
flags = append(flags, Flag{
Name: kebab(name), Field: name, Type: specType(typ),
Help: description, Required: required,
Help: description, Required: required, In: in,
})
}
// Query parameters are inputs too — an operation that takes its filters in
// the URL must offer them as flags, or the CLI silently loses half the API.
for _, p := range op.Parameters {
if p.In == "query" {
add(p.Name, p.Schema.Type, p.Description, p.Required)
// A declared header or cookie is a flag that rides as one. Query
// parameters are inputs too — an operation that takes its filters in the
// URL must offer them as flags, or the CLI silently loses half the API.
for _, in := range []string{"header", "cookie", "query"} {
for _, p := range op.Parameters {
if p.In == in {
where := in
if in == "query" {
where = ""
}
add(p.Name, p.Schema.Type, p.Description, p.Required, where)
}
}
}
if form := op.form(); form != nil {
s := d.resolve(form.Schema)
req := map[string]bool{}
for _, r := range s.Required {
req[r] = true
}
names := make([]string, 0, len(s.Properties))
for name := range s.Properties {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
p := s.Properties[name]
in, kind := "form", specType(p.Type)
if p.Format == "binary" || p.Items != nil && p.Items.Format == "binary" {
in, kind = "file", "file"
}
flags = append(flags, Flag{Name: kebab(name), Field: name, Type: kind, Help: p.Description, Required: req[name], In: in})
}
return args, flags
}
if op.bytes() {
flags = append(flags, Flag{Name: "body", Field: "body", Type: "file", Help: op.RequestBody.Description, In: "body"})
return args, flags
}
if body := op.body(); body != nil {
s := d.resolve(body.Schema)
req := map[string]bool{}
@@ -92,7 +126,7 @@ func (d specDoc) bind(op specOp, params []pathParam) ([]Arg, []Flag) {
sort.Strings(names) // a map has no order; a CLI must
for _, name := range names {
p := s.Properties[name]
add(name, p.Type, p.Description, req[name])
add(name, p.Type, p.Description, req[name], "")
}
}
return args, flags
@@ -133,8 +167,11 @@ type specOp struct {
Description string `json:"description"`
Parameters []specParam `json:"parameters"`
RequestBody *struct {
Content map[string]specMedia `json:"content"`
Description string `json:"description"`
Content map[string]specMedia `json:"content"`
} `json:"requestBody"`
// Socket is "websocket" for an op whose answer is an upgrade.
Socket string `json:"x-socket"`
// Responses is read by the SDK projection, which needs the ANSWER's type as
// well as the request's. A command does not: it prints whatever came back.
Responses map[string]struct {
@@ -183,6 +220,72 @@ func (o specOp) body() *specMedia {
return nil
}
// form returns the form request media, if the body is a form.
func (o specOp) form() *specMedia {
if o.RequestBody == nil {
return nil
}
for _, media := range []string{mimeMultipart, mimeForm} {
if m, ok := o.RequestBody.Content[media]; ok {
return &m
}
}
return nil
}
// bytes reports whether the request body is bytes taken as sent.
func (o specOp) bytes() bool {
if o.RequestBody == nil || o.body() != nil || o.form() != nil {
return false
}
for _, m := range o.RequestBody.Content {
if m.Schema.Format == "binary" {
return true
}
}
return false
}
// consumes are the request media when they are not application/json alone,
// sorted, as [ProjectCLI] spells them.
func (o specOp) consumes() []string {
if o.RequestBody == nil {
return nil
}
if _, ok := o.RequestBody.Content[mimeJSON]; ok && len(o.RequestBody.Content) == 1 {
return nil
}
out := make([]string, 0, len(o.RequestBody.Content))
for m := range o.RequestBody.Content {
out = append(out, m)
}
sort.Strings(out)
return out
}
// stream is the kind of answer that is not one JSON value: an upgrade, an
// event stream, or bytes.
func (o specOp) stream() string {
if o.Socket != "" {
return streamSocket
}
kind := ""
for code, r := range o.Responses {
if len(code) != 3 || code[0] != '2' {
continue
}
for media, m := range r.Content {
switch {
case media == "text/event-stream":
return streamSSE
case m.Schema.Format == "binary":
kind = streamBytes
}
}
}
return kind
}
// example is the op's example input, reassembled from wherever the document had
// to put it: the request body carries the whole object, and a bodyless op has no
// body, so its example lives on the parameters that carry the values. One
+1 -1
View File
@@ -68,7 +68,7 @@ func TestDeclaredOperationIDSurvivesComposition(t *testing.T) {
}
// The MCP tool name.
_, tools := host.composeTools()
_, tools, _ := host.composeTools()
if !tools["CreateRole"] {
var got []string
for n := range tools {
+236
View File
@@ -0,0 +1,236 @@
// 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/base64"
"encoding/json"
"mime/multipart"
"net/http/httptest"
"net/textproto"
"net/url"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// A form post — an OAuth token request, an upload with its fields — binds
// `form:` fields and *zip.File parts, and a `cookie:` field reads a request
// cookie. Each is a typed op with the wire it always had.
type tokenIn struct {
Grant string `json:"grant_type" form:"grant_type"`
Code string `json:"code" form:"code"`
Scope []string `json:"scope" form:"scope"`
Device string `json:"device" cookie:"device"`
}
type tokenOut struct {
Grant string `json:"grant_type"`
Code string `json:"code"`
Scope []string `json:"scope"`
Device string `json:"device"`
}
func token(_ context.Context, in *tokenIn) (*tokenOut, error) {
return &tokenOut{Grant: in.Grant, Code: in.Code, Scope: in.Scope, Device: in.Device}, nil
}
func tokenApp(opts ...zip.OpOption) *zip.App {
app := zip.New(zip.Config{AppName: "auth", DisableStartupMessage: true})
zip.Describe("POST /v1/token", zip.Doc{
Description: "Token trades a grant for a token.",
Fields: map[string]string{
"tokenIn.grant_type": "Grant names the grant.",
"tokenIn.code": "Code is the authorization code.",
"tokenIn.scope": "Scope lists what is asked for.",
"tokenIn.device": "Device is the browser's device cookie.",
},
})
app.Post("/v1/token", token, opts...)
return app
}
func postForm(t *testing.T, app *zip.App, media string, body []byte, cookie string) (int, tokenOut) {
t.Helper()
req := httptest.NewRequest("POST", "/v1/token", bytes.NewReader(body))
req.Header.Set("Content-Type", media)
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
var out tokenOut
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
func TestForm_BindsFieldsAndCookies(t *testing.T) {
app := tokenApp()
form := url.Values{"grant_type": {"authorization_code"}, "code": {"c1"}, "scope": {"read", "write"}}
code, got := postForm(t, app, "application/x-www-form-urlencoded", []byte(form.Encode()), "device=d9; other=x")
if code != 200 || got.Grant != "authorization_code" || got.Code != "c1" ||
strings.Join(got.Scope, ",") != "read,write" || got.Device != "d9" {
t.Errorf("%d %+v", code, got)
}
}
// A form op reads JSON only when it says it takes JSON. Otherwise a JSON body
// binds nothing, exactly as a form reader finds no fields in it.
func TestForm_ReadsJSONOnlyWhenItSaysSo(t *testing.T) {
body := []byte(`{"grant_type":"refresh_token","code":"c2"}`)
if _, got := postForm(t, tokenApp(), "application/json", body, ""); got.Grant != "" || got.Code != "" {
t.Errorf("a form op bound a JSON body it does not take: %+v", got)
}
both := tokenApp(zip.Consumes("application/x-www-form-urlencoded", "application/json"))
if _, got := postForm(t, both, "application/json", body, ""); got.Grant != "refresh_token" || got.Code != "c2" {
t.Errorf("a form op that takes JSON bound %+v", got)
}
}
func TestForm_IsPublishedAsAForm(t *testing.T) {
op := opIn(t, tokenApp(), "/v1/token", "post")
content := op["requestBody"].(map[string]any)["content"].(map[string]any)
form, ok := content["application/x-www-form-urlencoded"].(map[string]any)
if !ok || len(content) != 1 {
t.Fatalf("content = %v, want one url-encoded form", content)
}
props := form["schema"].(map[string]any)["properties"].(map[string]any)
for name, want := range map[string]string{
"grant_type": "Grant names the grant.", "code": "Code is the authorization code.", "scope": "Scope lists what is asked for.",
} {
if p, _ := props[name].(map[string]any); p["description"] != want {
t.Errorf("form %s = %v", name, props[name])
}
}
if _, ok := props["device"]; ok {
t.Error("the cookie is published as a form field")
}
var cookie map[string]any
for _, p := range op["parameters"].([]any) {
if m := p.(map[string]any); m["in"] == "cookie" {
cookie = m
}
}
if cookie["name"] != "device" || cookie["description"] != "Device is the browser's device cookie." {
t.Errorf("cookie parameter = %v", cookie)
}
}
// A multipart form binds its parts: a *zip.File is one part, a []zip.File every
// part of that name, and the fields beside them bind as a url-encoded form's do.
type uploadIn struct {
Title string `json:"title" form:"title"`
Avatar *zip.File `json:"avatar" form:"avatar"`
Pages []zip.File `json:"pages" form:"pages"`
}
type uploadOut struct {
Title string `json:"title"`
Avatar string `json:"avatar"`
Pages []string `json:"pages"`
}
func upload(_ context.Context, in *uploadIn) (*uploadOut, error) {
out := &uploadOut{Title: in.Title}
if in.Avatar != nil {
out.Avatar = in.Avatar.Name + "|" + in.Avatar.Type + "|" + string(in.Avatar.Bytes)
}
for _, p := range in.Pages {
out.Pages = append(out.Pages, p.Name+"|"+string(p.Bytes))
}
return out, nil
}
func TestForm_BindsMultipartParts(t *testing.T) {
app := zip.New(zip.Config{AppName: "up", DisableStartupMessage: true})
app.Post("/v1/upload", upload)
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("title", "Q3")
part := func(name, file, media, body string) {
h := textproto.MIMEHeader{}
h.Set("Content-Disposition", `form-data; name="`+name+`"; filename="`+file+`"`)
h.Set("Content-Type", media)
w, _ := mw.CreatePart(h)
_, _ = w.Write([]byte(body))
}
part("avatar", "me.png", "image/png", "PNG")
part("pages", "1.txt", "text/plain", "one")
part("pages", "2.txt", "text/plain", "two")
_ = mw.Close()
req := httptest.NewRequest("POST", "/v1/upload", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
var got uploadOut
_ = json.NewDecoder(resp.Body).Decode(&got)
if resp.StatusCode != 200 || got.Title != "Q3" || got.Avatar != "me.png|image/png|PNG" ||
strings.Join(got.Pages, ",") != "1.txt|one,2.txt|two" {
t.Errorf("%d %+v", resp.StatusCode, got)
}
op := opIn(t, app, "/v1/upload", "post")
content := op["requestBody"].(map[string]any)["content"].(map[string]any)
form, ok := content["multipart/form-data"].(map[string]any)
if !ok {
t.Fatalf("content = %v, want multipart/form-data", content)
}
props := form["schema"].(map[string]any)["properties"].(map[string]any)
if a := props["avatar"].(map[string]any); a["format"] != "binary" {
t.Errorf("avatar = %v, want binary", a)
}
if p := props["pages"].(map[string]any); p["items"].(map[string]any)["format"] != "binary" {
t.Errorf("pages = %v, want a list of binary", p)
}
// Over MCP a part is {name, type, bytes}, its bytes base64.
built(t, app)
env := mcpCall(t, app, zip.ID("POST", "/v1/upload"), map[string]any{
"title": "Q4",
"avatar": map[string]any{"name": "a.png", "type": "image/png", "bytes": base64.StdEncoding.EncodeToString([]byte("PNG"))},
})
var viaMCP uploadOut
if err := json.Unmarshal([]byte(mcpText(t, env["result"].(map[string]any))), &viaMCP); err != nil {
t.Fatal(err)
}
if viaMCP.Title != "Q4" || viaMCP.Avatar != "a.png|image/png|PNG" {
t.Errorf("over MCP: %+v", viaMCP)
}
}
// A cookie field is not a URL value: a link could otherwise set a session.
func TestForm_CookieIsNotAQueryValue(t *testing.T) {
type in struct {
Session string `json:"session" cookie:"session"`
}
app := zip.New(zip.Config{AppName: "c", DisableStartupMessage: true})
app.Get("/v1/me", func(_ context.Context, in *in) (*tokenOut, error) { return &tokenOut{Device: in.Session}, nil })
req := httptest.NewRequest("GET", "/v1/me?session=forged", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
var got tokenOut
_ = json.NewDecoder(resp.Body).Decode(&got)
if got.Device != "" {
t.Errorf("a query value set the cookie field: %q", got.Device)
}
for _, p := range opIn(t, app, "/v1/me", "get")["parameters"].([]any) {
if m := p.(map[string]any); m["in"] == "query" {
t.Errorf("the cookie is published as a query parameter: %v", m)
}
}
}
+1
View File
@@ -233,5 +233,6 @@ func (o *Operation[In, Out]) With(opts ...OpOption) *Operation[In, Out] {
for _, opt := range opts {
opt(o.op)
}
o.op.settle()
return o
}
+51
View File
@@ -0,0 +1,51 @@
// Package ws is the one WebSocket upgrade zip performs. wsx.Upgrade (an untyped
// route) and a typed op answering zip.Socket both reach it, so a connection
// is accepted, refused and configured the same way whichever door it came in.
package ws
import (
"github.com/fasthttp/websocket"
"github.com/valyala/fasthttp"
)
// Conn is an upgraded connection.
type Conn = websocket.Conn
// Config configures an upgrade.
type Config struct {
// ReadBufferSize and WriteBufferSize size the connection's I/O buffers in
// bytes; zero takes the library's default.
ReadBufferSize int
WriteBufferSize int
// Subprotocols are the protocols the server speaks, in its order of
// preference; the first one the client also offers is selected.
Subprotocols []string
// EnableCompression negotiates per-message deflate when the client offers it.
EnableCompression bool
// CheckOrigin decides whether a request's Origin may open a connection.
// Nil admits every origin: zip is multi-tenant and gates at the route.
CheckOrigin func(ctx *fasthttp.RequestCtx) bool
}
// Is reports whether the request asks to become a WebSocket.
func Is(rc *fasthttp.RequestCtx) bool { return websocket.FastHTTPIsWebSocketUpgrade(rc) }
// Upgrade answers 101 and runs fn on the connection once the handler that
// called it returns. The caller has already checked [Is].
func Upgrade(rc *fasthttp.RequestCtx, cfg Config, fn func(*Conn)) error {
up := &websocket.FastHTTPUpgrader{
ReadBufferSize: cfg.ReadBufferSize,
WriteBufferSize: cfg.WriteBufferSize,
Subprotocols: cfg.Subprotocols,
EnableCompression: cfg.EnableCompression,
CheckOrigin: cfg.CheckOrigin,
}
if up.CheckOrigin == nil {
up.CheckOrigin = func(*fasthttp.RequestCtx) bool { return true }
}
return up.Upgrade(rc, fn)
}
// Refusal is the sentence a plain request to a WebSocket address is answered
// with, beside 426 and Upgrade: websocket (RFC 9110 §15.5.22).
const Refusal = "this address speaks WebSocket: connect with an Upgrade: websocket request"
+12 -3
View File
@@ -39,7 +39,9 @@
//
// Anything else — a map, an interface, a channel, a FIXED-SIZE ARRAY — is
// refused at encode rather than dropped. A field that silently does not cross is
// the failure this package exists to make impossible.
// the failure this package exists to make impossible. A field that says so
// itself, with the tag zap:"-", is the exception: it does not cross, and it was
// never meant to.
//
// # A type that owns its wire is not reflected over
//
@@ -274,10 +276,12 @@ func buildLayout(t reflect.Type) (*layout, error) {
off := 0
for i := 0; i < t.NumField(); i++ {
sf := t.Field(i)
if !sf.IsExported() {
if !sf.IsExported() || Skipped(sf) {
// An unexported field cannot be read or written by reflection, so it
// cannot cross. Skipping it silently is right — it was never part of
// the contract — but it must not consume a slot either.
// the contract — but it must not consume a slot either. A field
// tagged zap:"-" says the same about itself: it is part of the value
// in this process and not of the message.
continue
}
f, err := fieldOf(sf.Type)
@@ -772,6 +776,11 @@ func f64bits(f float64) uint64 { return math.Float64bits(f) }
func f32from(u uint32) float32 { return math.Float32frombits(u) }
func f64from(u uint64) float64 { return math.Float64frombits(u) }
// Skipped reports whether a field opts out of the message with zap:"-": a value
// that lives only in the process holding it, such as a reader an answer
// streams from. It takes no slot and is not a loss.
func Skipped(f reflect.StructField) bool { return f.Tag.Get("zap") == "-" }
// ---- the layout, for whoever needs to write it down -------------------------
// Slot is where one field of a message sits on the wire, and what it is.
+112 -6
View File
@@ -654,6 +654,24 @@ func (e *extractor) fields(t types.Type, out map[string]string, seen map[*types.
return
}
seen[t] = true
// A union's alternatives are the result types of its OneOf method, and
// they are what its schema shows, so their fields are documented as if
// the op named each one.
for _, alt := range alternatives(t) {
e.fields(alt, out, seen)
}
// zip's answer and request kinds carry what reaches the wire as type
// arguments — the events of an Sse, the documented shape of a
// Verbatim, the messages of a Socket, the alternatives of an Or — and
// their own fields are not a wire shape at all.
if kinds[zipName(t)] {
if args := t.TypeArgs(); args != nil {
for i := 0; i < args.Len(); i++ {
e.fields(args.At(i), out, seen)
}
}
return
}
st, ok := t.Underlying().(*types.Struct)
if !ok {
e.fields(t.Underlying(), out, seen)
@@ -672,16 +690,48 @@ func (e *extractor) fields(t types.Type, out map[string]string, seen map[*types.
}
}
// kinds are zip's request and answer kinds: their type arguments are the wire
// shapes, and their own fields are not (a Body is bytes on the wire whatever
// it holds in the process).
var kinds = map[string]bool{"Body": true, "Or": true, "Sse": true, "Verbatim": true, "Socket": true, "Event": true}
// zipName is the name of a type declared in zip, or "".
func zipName(t *types.Named) string {
if pkg := t.Obj().Pkg(); pkg != nil && pkg.Path() == ZipPkg {
return t.Obj().Name()
}
return ""
}
// alternatives are the result types of t's OneOf method, which is how a union
// names what it may be. Nil when t declares none.
func alternatives(t *types.Named) []types.Type {
obj, _, _ := types.LookupFieldOrMethod(types.NewPointer(t), true, t.Obj().Pkg(), "OneOf")
fn, ok := obj.(*types.Func)
if !ok {
return nil
}
sig, ok := fn.Type().(*types.Signature)
if !ok || sig.Params().Len() != 0 || sig.Results().Len() < 2 {
return nil
}
out := make([]types.Type, sig.Results().Len())
for i := range out {
out[i] = sig.Results().At(i).Type()
}
return out
}
// structFields pairs the type-checked fields (authoritative for names and tags)
// with the parsed ones (which carry the comments). go/types preserves source
// order, so index i is the same field in both.
//
// A type defined over another (`type legalFiling Filing`) has the fields of
// the one it is defined over, written where that one is declared, so the
// prose is read there and filed under the defined type's own name.
func (e *extractor) structFields(name string, obj *types.TypeName, st *types.Struct, out map[string]string) {
spec := e.typeSpec(obj.Pos(), obj.Name())
if spec == nil {
return
}
lit, ok := spec.Type.(*ast.StructType)
if !ok {
lit := e.structLit(obj)
if lit == nil {
return
}
var flat []*ast.Field
@@ -716,6 +766,62 @@ func (e *extractor) structFields(name string, obj *types.TypeName, st *types.Str
}
}
// structLit is the struct literal that declares obj's fields: its own, or the
// literal of the type it is defined over, followed through each such step —
// within its package by name, and across an import by the file's import of
// it.
func (e *extractor) structLit(obj *types.TypeName) *ast.StructType {
for range 8 {
spec := e.typeSpec(obj.Pos(), obj.Name())
if spec == nil || obj.Pkg() == nil {
return nil
}
var next types.Object
switch x := ast.Unparen(spec.Type).(type) {
case *ast.StructType:
return x
case *ast.Ident:
next = obj.Pkg().Scope().Lookup(x.Name)
case *ast.SelectorExpr:
id, ok := x.X.(*ast.Ident)
if !ok {
return nil
}
if imp := e.imported(obj, id.Name); imp != nil {
next = imp.Scope().Lookup(x.Sel.Name)
}
}
named, ok := next.(*types.TypeName)
if !ok {
return nil
}
obj = named
}
return nil
}
// imported is the package a file refers to as name, from the file declaring
// obj: an import that names itself so, else the import whose package is so
// named.
func (e *extractor) imported(obj *types.TypeName, name string) *types.Package {
f, _ := e.file(obj.Pos())
if f == nil {
return nil
}
path := ""
for _, spec := range f.Imports {
if spec.Name != nil && spec.Name.Name == name {
path = strings.Trim(spec.Path.Value, `"`)
}
}
for _, imp := range obj.Pkg().Imports() {
if path != "" && imp.Path() == path || path == "" && imp.Name() == name {
return imp
}
}
return nil
}
// reflectName is the type's name as reflect.Type.Name reports it — which is what
// the schema builder keys on at run time. For an instantiated generic that
// includes the type arguments, qualified by package path.
+57
View File
@@ -0,0 +1,57 @@
package zipdoc
import "testing"
// Prose a type's fields carry has to reach the key the document reads, however
// the type reaches the op: through a generic, a type defined over another, an
// embedded struct, or one of zip's kinds.
func TestExtract_ShapesReachTheirProse(t *testing.T) {
const base = "github.com/zap-proto/zip/internal/zipdoc/testdata/"
for pkg, ops := range map[string]map[string]map[string]string{
"generic": {
"GET /v1/stores/:owner": {
"Envelope[[]" + base + "generic.Store].status": "Status is ok or error.",
"Envelope[[]" + base + "generic.Store].data": "Data is what the op answers.",
"Store.name": "Name is the store's name.",
},
"GET /v1/store/:owner": {
"Envelope[" + base + "generic.Store].status": "Status is ok or error.",
},
},
"defined": {
"GET /v1/filings/:id": {"legalFiling.name": "Name is the filing's title."},
"GET /v1/filings/:id/docket": {"docket.court": "Court is the court that holds the record."},
},
"embedded": {
"GET /v1/orders/:id": {
"Model.id": "ID addresses the record.",
"Model.created": "Created is when the record was made, RFC 3339.",
"Order.total": "Total is the order's total, in cents.",
},
},
"kinds": {
"POST /v1/stream": {"Chunk.text": "Text is the next piece of the completion.", "Prompt.stream": "Stream asks for events rather than one answer."},
"POST /v1/either": {"Chunk.text": "Text is the next piece of the completion.", "Done.answer": "Answer is the whole completion."},
"POST /v1/relay": {"Upstream.model": "Model is the model the upstream ran."},
"POST /v1/ingest": {"Event.kind": "Kind names the event.", "Batch.events": "Events are the events, oldest first."},
},
} {
p := load(t, pkg)
for key, want := range ops {
op := opByKey(t, p, key)
for field, prose := range want {
if got := op.Fields[field]; got != prose {
t.Errorf("%s %s: Fields[%q] = %q, want %q", pkg, key, field, got, prose)
}
}
// zip's own kinds are not wire shapes, so nothing of theirs is filed.
for field := range op.Fields {
for _, kind := range []string{"Sse[", "Or[", "Verbatim[", "Body."} {
if len(field) >= len(kind) && field[:len(kind)] == kind {
t.Errorf("%s %s: filed zip's own %q", pkg, key, field)
}
}
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
// Package court declares a record another package defines a type over.
package court
// Record is one court record.
type Record struct {
// Court is the court that holds the record.
Court string `json:"court"`
}
+41
View File
@@ -0,0 +1,41 @@
// Package defined answers with a type defined over another struct, whose
// fields are the other's and whose prose is written there.
package defined
import (
"context"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/internal/zipdoc/testdata/defined/court"
)
// Filing is one filing.
type Filing struct {
// Name is the filing's title.
Name string `json:"name"`
}
// legalFiling is a Filing as this package answers it.
type legalFiling Filing
// docket is a court record as this package answers it.
type docket court.Record
// In names a filing.
type In struct {
// ID addresses the filing.
ID string `json:"id" url:"id"`
}
// Get returns one filing.
func Get(ctx context.Context, in *In) (*legalFiling, error) { return &legalFiling{}, nil }
// Docket returns the filing's court record.
func Docket(ctx context.Context, in *In) (*docket, error) { return &docket{}, nil }
// Register declares the ops.
func Register(app *zip.App) {
app.Get("/v1/filings/:id", Get)
app.Get("/v1/filings/:id/docket", Docket)
}
+36
View File
@@ -0,0 +1,36 @@
// Package embedded answers with a struct that embeds another, so most of its
// fields are promoted and documented where they are declared.
package embedded
import (
"context"
"github.com/zap-proto/zip"
)
// Model is what every record carries.
type Model struct {
// ID addresses the record.
ID string `json:"id"`
// Created is when the record was made, RFC 3339.
Created string `json:"created"`
}
// Order is one order.
type Order struct {
Model
// Total is the order's total, in cents.
Total int64 `json:"total"`
}
// In names an order.
type In struct {
// ID addresses the order.
ID string `json:"id" url:"id"`
}
// Get returns one order.
func Get(ctx context.Context, in *In) (*Order, error) { return &Order{}, nil }
// Register declares the op.
func Register(app *zip.App) { app.Get("/v1/orders/:id", Get) }
+45
View File
@@ -0,0 +1,45 @@
// Package generic answers through an instantiated generic, whose fields are
// documented once on the generic and filed under each instantiation's name.
package generic
import (
"context"
"github.com/zap-proto/zip"
)
// Envelope is the answer every op here wraps its data in.
type Envelope[T any] struct {
// Status is ok or error.
Status string `json:"status"`
// Data is what the op answers.
Data T `json:"data"`
}
// Store is one store.
type Store struct {
// Name is the store's name.
Name string `json:"name"`
}
// ListIn asks for stores.
type ListIn struct {
// Owner is the org whose stores are listed.
Owner string `json:"owner" url:"owner"`
}
// List returns the owner's stores.
func List(ctx context.Context, in *ListIn) (*Envelope[[]Store], error) {
return &Envelope[[]Store]{Status: "ok"}, nil
}
// One returns one store.
func One(ctx context.Context, in *ListIn) (*Envelope[Store], error) {
return &Envelope[Store]{Status: "ok"}, nil
}
// Register declares the ops.
func Register(app *zip.App) {
app.Get("/v1/stores/:owner", List)
app.Get("/v1/store/:owner", One)
}
+76
View File
@@ -0,0 +1,76 @@
// Package kinds answers with zip's kinds: an event stream, a union, an
// either-or and a relay, each of whose wire shapes is a type argument or a
// OneOf result.
package kinds
import (
"context"
"github.com/zap-proto/zip"
)
// Chunk is one event of a completion.
type Chunk struct {
// Text is the next piece of the completion.
Text string `json:"text"`
}
// Done is a completion answered whole.
type Done struct {
// Answer is the whole completion.
Answer string `json:"answer"`
}
// Upstream is the upstream's answer.
type Upstream struct {
// Model is the model the upstream ran.
Model string `json:"model"`
}
// Event is one ingested event.
type Event struct {
// Kind names the event.
Kind string `json:"kind"`
}
// Batch is several events at once.
type Batch struct {
// Events are the events, oldest first.
Events []Event `json:"events"`
}
// Ingest is one event or a batch.
type Ingest struct{ one *Event }
// OneOf declares the alternatives.
func (Ingest) OneOf() (Event, Batch) { return Event{}, Batch{} }
// Prompt asks for a completion.
type Prompt struct {
// Stream asks for events rather than one answer.
Stream bool `json:"stream"`
}
// Stream streams a completion.
func Stream(ctx context.Context, in *Prompt) (*zip.Sse[Chunk], error) { return &zip.Sse[Chunk]{}, nil }
// Either answers whole or streams, as asked.
func Either(ctx context.Context, in *Prompt) (*zip.Or[Done, zip.Sse[Chunk]], error) {
return &zip.Or[Done, zip.Sse[Chunk]]{}, nil
}
// Relay passes the upstream's answer through as sent.
func Relay(ctx context.Context, in *Prompt) (*zip.Verbatim[Upstream], error) {
return &zip.Verbatim[Upstream]{}, nil
}
// Take ingests what was sent.
func Take(ctx context.Context, in *Ingest) (*struct{}, error) { return nil, nil }
// Register declares the ops.
func Register(app *zip.App) {
app.Post("/v1/stream", Stream)
app.Post("/v1/either", Either)
app.Post("/v1/relay", Relay)
app.Post("/v1/ingest", Take)
}
+119 -12
View File
@@ -112,19 +112,35 @@ type ManifestOp struct {
OAuth bool `json:"oauth,omitempty"`
// Stream says the answer is written as it is produced rather than as one
// JSON value: "sse" is a text/event-stream, "bytes" an octet stream. Empty
// is one value, Out.
// JSON value: "sse" is a text/event-stream, "bytes" an octet stream,
// "socket" an upgrade to a WebSocket. Empty is one value, Out. Beside an
// Out it means either: the op answers the value or the stream.
Stream string `json:"stream,omitempty"`
// Raw says the request body is read as bytes, as sent, and binds to no
// type: a relay's upload. Such an op names no In.
Raw bool `json:"raw,omitempty"`
// Consumes are the media the request body is declared in when it is not
// application/json; Produces the media of the answer when it is not
// application/json, or, for a stream of bytes, application/octet-stream.
Consumes []string `json:"consumes,omitempty"`
Produces []string `json:"produces,omitempty"`
// Event is the type id of one frame of the stream: an sse event's data, a
// socket's message. Empty says the frames are not typed.
Event string `json:"event,omitempty"`
// Verbatim says the answer is an upstream's, relayed as it was sent, so a
// refusal may be the upstream's own rather than this service's.
Verbatim bool `json:"verbatim,omitempty"`
}
// streams are the answers a manifest may say an op streams, by media type.
var streams = map[string]string{
"sse": "text/event-stream",
"bytes": "application/octet-stream",
streamSSE: "text/event-stream",
streamBytes: mimeOctet,
streamSocket: "",
}
// TypeDesc is one type, described by what it is made of and by what it looks
@@ -176,6 +192,18 @@ type TypeDesc struct {
Len int `json:"len,omitempty"`
Fields []FieldDesc `json:"fields,omitempty"`
// OneOf are the alternatives of a union (kind "union"): a value is exactly
// one of them.
OneOf []TypeRef `json:"oneOf,omitempty"`
// Status is the status an answer of this type states for itself, when it is
// one alternative of a union answer.
Status int `json:"status,omitempty"`
// Binary says the type is bytes where a body or a form part carries it: a
// request body taken as sent, a file part.
Binary bool `json:"binary,omitempty"`
}
// FieldDesc is one field of a struct.
@@ -185,10 +213,17 @@ type FieldDesc struct {
Name string `json:"name"`
JSON string `json:"json"`
// URL is the name a URL carries it under and Header the request header it
// reads. Both empty means it rides the body alone.
// URL is the name a URL carries it under, Header the request header and
// Cookie the request cookie it reads. All empty means it rides the body
// alone.
URL string `json:"url,omitempty"`
Header string `json:"header,omitempty"`
Cookie string `json:"cookie,omitempty"`
// Form is the form key it binds when the body is a form. Body says it IS
// the request body, taken as sent.
Form string `json:"form,omitempty"`
Body bool `json:"body,omitempty"`
Required bool `json:"required,omitempty"`
Doc string `json:"doc,omitempty"`
@@ -202,6 +237,10 @@ type FieldDesc struct {
// "map[string]info.LP" when the file says HashMap<String, Lp> is worse than
// saying nothing.
Spell string `json:"spell,omitempty"`
// from is the type that declares the field when it is promoted from an
// embedded struct, by its reflect name — where its prose is filed.
from string
}
// TypeRef names a field's type: a primitive by its spelling, a described type
@@ -264,6 +303,11 @@ func (m Manifest) Check() error {
return err
}
}
for _, alt := range td.OneOf {
if err := resolves(known, td.ID+" alternative", alt); err != nil {
return err
}
}
if td.Elem != nil {
if err := resolves(known, td.ID+" element", *td.Elem); err != nil {
return err
@@ -277,10 +321,13 @@ func (m Manifest) Check() error {
}
}
if _, ok := streams[op.Stream]; op.Stream != "" && !ok {
return fmt.Errorf("op %s streams %q; a stream is sse or bytes", op.ID, op.Stream)
return fmt.Errorf("op %s streams %q; a stream is sse, bytes or socket", op.ID, op.Stream)
}
if op.Stream != "" && op.Out != "" {
return fmt.Errorf("op %s streams and names output %q; a streamed answer is not one value", op.ID, op.Out)
if op.Stream == streamSocket && op.Out != "" {
return fmt.Errorf("op %s upgrades and names output %q; an upgrade is the whole answer", op.ID, op.Out)
}
if op.Event != "" && !known[op.Event] {
return fmt.Errorf("op %s names event %q, which nothing described", op.ID, op.Event)
}
if op.Raw && op.In != "" {
return fmt.Errorf("op %s reads its body raw and names input %q; a raw body binds to nothing", op.ID, op.In)
@@ -331,9 +378,14 @@ func (a *App) Manifest() Manifest {
Pkg: op.Pkg,
Origin: op.Origin,
In: d.describe(op.InType, op.Origin),
Out: d.describe(op.OutType, op.Origin),
Out: d.answer(op.ans, op.Origin),
Gated: op.rule != nil && op.rule() != nil,
OAuth: oauth[op.Method+" "+op.Path],
Stream: op.ans.stream,
Event: d.describe(op.ans.frame, op.Origin),
Consumes: op.Consumes,
Produces: op.Produces,
Verbatim: op.ans.verbatim,
}
if hasDoc {
mo.Description = doc.Description
@@ -375,6 +427,17 @@ func (d *describer) describe(t reflect.Type, origin string) string {
td := &TypeDesc{ID: id, Name: t.Name(), Pkg: t.PkgPath(), Spell: goName(t), Text: readsText(t)}
d.out = append(d.out, td) // claimed before the fields are walked: the cycle guard.
// A union is its alternatives. It writes itself, so its fields are not its
// wire form, and it states no schema of its own: the alternatives do.
if alts := alternatives(t); len(alts) > 0 {
td.Kind = "union"
for _, alt := range alts {
td.OneOf = append(td.OneOf, d.ref(alt, origin))
}
return id
}
td.Binary = t == bodyType || t == fileType
if isMarshaler(t) {
switch {
case declaredSchema(t) != nil:
@@ -414,6 +477,40 @@ func (d *describer) describe(t reflect.Type, origin string) string {
return id
}
// answer describes what an op answers with as one JSON value: the value's type,
// a union's own type, or an anonymous union of an [Or]'s JSON alternatives.
// Each alternative that states its own status carries it.
func (d *describer) answer(a answer, origin string) string {
id := ""
switch {
case a.union != nil:
id = d.describe(a.union, origin)
case len(a.json) == 1:
id = d.describe(a.json[0], origin)
case len(a.json) > 1:
d.anon++
td := &TypeDesc{ID: fmt.Sprintf("#%d", d.anon), Kind: "union"}
d.out = append(d.out, td)
for _, t := range a.json {
td.OneOf = append(td.OneOf, d.ref(t, origin))
}
id = td.ID
}
for t, code := range a.statuses {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if alt, ok := d.byType[t]; ok {
for _, td := range d.out {
if td.ID == alt {
td.Status = code
}
}
}
}
return id
}
// state gives a value type the shape it shows on the wire, since it has no
// fields to be described by. A type that already stated its own keeps it.
func (d *describer) state(td *TypeDesc, t reflect.Type) {
@@ -431,10 +528,16 @@ func (d *describer) fields(td *TypeDesc, t reflect.Type, origin string) {
JSON: jsonFieldName(f),
URL: urlFieldName(f),
Header: headerFieldName(f),
Cookie: cookieFieldName(f),
Form: formFieldName(f),
Body: f.Type == bodyType,
Required: strings.Contains(f.Tag.Get("validate"), "required"),
Type: d.ref(f.Type, origin),
Spell: goName(f.Type),
}
if from := declaring(t, f.Index); from != t {
fd.from = from.Name()
}
if fd.URL == fd.JSON {
fd.URL = "" // says nothing: the wire name is the URL name.
}
@@ -493,6 +596,10 @@ func (d *describer) prose(fields map[string]string) {
}
if s := fields[td.Name+"."+td.Fields[i].JSON]; s != "" {
td.Fields[i].Doc = s
} else if from := td.Fields[i].from; from != "" {
// Promoted from an embedded struct: its prose is filed under the
// type that declares it, which is where zipdoc read it.
td.Fields[i].Doc = fields[from+"."+td.Fields[i].JSON]
}
}
}
@@ -522,13 +629,13 @@ func (d *describer) id(t reflect.Type, origin string) string {
d.anon++
return fmt.Sprintf("#%d", d.anon)
}
base := qual + t.Name()
base := qual + componentName(t)
if !d.taken[base] {
d.taken[base] = true
return base
}
if p := t.PkgPath(); p != "" {
base = qual + p[strings.LastIndexByte(p, '/')+1:] + "." + t.Name()
base = qual + p[strings.LastIndexByte(p, '/')+1:] + "." + componentName(t)
}
for name, n := base, 2; ; n++ {
if !d.taken[name] {
+63 -82
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
@@ -246,9 +245,10 @@ func (a *App) hasCaller() bool {
// plugin at run time must not serve a list frozen at boot, and the alternative
// (rendering per request) would give back the memcpy that makes tools/list free.
func (a *App) renderTools() {
list, names := a.composeTools()
list, names, refused := a.composeTools()
a.mcpList.Store(&list)
a.mcpNames.Store(&names)
a.mcpRefused.Store(&refused)
}
// mcpTool is one tool descriptor with its name lifted out, so the composed list
@@ -266,7 +266,7 @@ type mcpTool struct {
// It returns the rendered bytes AND the set of names in them, because the
// per-caller half has to know what the build-time half already claims and reading
// that back out of the bytes would re-parse hundreds of schemas per request.
func (a *App) composeTools() (json.RawMessage, map[string]bool) {
func (a *App) composeTools() (json.RawMessage, map[string]bool, map[string]string) {
own := a.Registry()
pluginTools := a.tools()
all := make([]mcpTool, 0, len(own)+len(pluginTools))
@@ -282,29 +282,37 @@ func (a *App) composeTools() (json.RawMessage, map[string]bool) {
hidden[r.method+" "+o.abs(r.path)] = true
}
}
// An op kept out of the contract is kept out of the tool list.
//
// [Undeclared] promises exactly this — "and so in none of the projections
// built from it: the OpenAPI document, the MCP tool list, the CLI commands,
// the by-name call plane" — and the miss costs most here: a document nobody
// reads lists a dead address, but a tool list is what an agent CALLS.
skip := map[string]bool{}
for _, op := range own {
// An op kept out of the contract is kept out of the tool list.
//
// [Undeclared] promises exactly this — "and so in none of the
// projections built from it: the OpenAPI document, the MCP tool list,
// the CLI commands, the by-name call plane" — and this projection was
// the one that did not hold it, because the registry is every op that
// registered while the undeclared fact rides the route entry.
//
// It is the projection where the miss costs most: a document nobody
// reads lists a dead address, but a tool list is what an agent CALLS.
//
// Asked of Declares rather than tracked here, so there is one answer to
// "is this in the contract?" and no second list to fall out of step.
if hidden[op.Method+" "+op.Path] {
skip[opName(op)] = true
}
}
// The tools are [ProjectMCP] over this app's manifest: one projection,
// whether it is served here, embedded by a host as a catalogue, or read
// in-process through MCPTools.
m := a.Manifest()
for _, t := range ProjectMCP(m) {
name := t["name"].(string)
if skip[name] {
continue
}
b, err := json.Marshal(mcpToolOf(op))
b, err := json.Marshal(t)
if err != nil {
a.logger.Warn("zip mcp: op has no renderable schema", "op", opName(op), "err", err)
a.logger.Warn("zip mcp: op has no renderable schema", "op", name, "err", err)
continue
}
all = append(all, mcpTool{name: opName(op), raw: b})
all = append(all, mcpTool{name: name, raw: b})
}
refused := RefusedMCP(m)
for name := range skip {
delete(refused, name)
}
all = append(all, pluginTools...)
sort.Slice(all, func(i, j int) bool { return all[i].name < all[j].name })
@@ -317,9 +325,9 @@ func (a *App) composeTools() (json.RawMessage, map[string]bool) {
}
b, err := json.Marshal(raws)
if err != nil {
return json.RawMessage("[]"), names
return json.RawMessage("[]"), names, refused
}
return b, names
return b, names, refused
}
// installTools adds one plugin's catalogue to the composed surface, refusing a
@@ -433,7 +441,11 @@ func (a *App) MCP(ctx context.Context, f *zapmcp.Frame) *zapmcp.Frame {
"serverInfo": map[string]any{"name": a.mcpName(), "version": a.cfg.OpenAPI.Version},
}))
case "tools/list":
return f.Answer(mcpJSON(map[string]any{"tools": a.list(ctx)}))
result := map[string]any{"tools": a.list(ctx)}
if refused := a.mcpRefused.Load(); refused != nil && len(*refused) > 0 {
result["_meta"] = map[string]any{"refused": *refused}
}
return f.Answer(mcpJSON(result))
case "tools/call":
return a.tool(ctx, f)
case "ping":
@@ -634,65 +646,6 @@ func (a *App) mcpTools() []map[string]any {
return ProjectMCP(a.Manifest())
}
func (a *App) mcpToolsReflect() []map[string]any {
reg := a.Registry()
tools := make([]map[string]any, 0, len(reg))
for _, op := range reg {
tools = append(tools, mcpToolOf(op))
}
sort.Slice(tools, func(i, j int) bool {
return tools[i]["name"].(string) < tools[j]["name"].(string)
})
return tools
}
// mcpToolOf is the ONE op→tool descriptor. Both the in-process projection
// (MCPTools) and the composed list read it, so a host's catalogue and a plugin's
// own /mcp can never describe one op two ways.
func mcpToolOf(op *registeredOp) map[string]any {
doc, hasDoc := docFor(op.Pkg, op.Method, op.Path)
desc := op.Summary
if hasDoc && doc.Description != "" {
desc = doc.Description
}
return map[string]any{
"name": opName(op),
"description": desc,
"inputSchema": rootSchemaOf(op.InType, docFields(hasDoc, doc)),
// readOnlyHint is derived from the METHOD, because that is where the
// answer already lives: a GET op is a read by construction, and no op can
// be annotated inconsistently with the route it IS.
//
// It is not decoration. An MCP client that cannot tell a read from a write
// must assume WRITE, and Slackbot says so outright — an unclassified tool
// defaults to write classification and prompts the user Allow / Always /
// Deny before EVERY call. Measured on cloud's 1,323-tool surface: 0 carried
// annotations, so every read cost a confirmation. That is the difference
// between an agent that answers and one that interrogates.
//
// Only the true half is emitted. readOnlyHint:false is the client's own
// default, and writing it would claim we had classified a mutation when all
// we know is that it is not a GET.
"annotations": mcpAnnotationsOf(op),
}
}
// mcpAnnotationsOf carries what the protocol lets a server say about a tool
// beyond its schema. Today that is readOnlyHint alone.
//
// HEAD is included with GET: it is a read whose body is discarded, so a client
// that treats it as a mutation is wrong in the only direction that costs a
// prompt. Everything else — POST, PUT, PATCH, DELETE — is left unsaid rather
// than asserted safe, so an unknown or custom method fails toward caution.
func mcpAnnotationsOf(op *registeredOp) map[string]any {
switch op.Method {
case http.MethodGet, http.MethodHead:
return map[string]any{"readOnlyHint": true}
default:
return map[string]any{}
}
}
// tool runs a tools/call: find the op by name, invoke the SAME handler core the
// REST route uses, and return its JSON result as MCP text content. A handler
// error is reported as MCP isError content (not a JSON-RPC error), per the spec
@@ -725,17 +678,25 @@ func (a *App) tool(ctx context.Context, f *zapmcp.Frame) *zapmcp.Frame {
return f.Fail(zapmcp.CodeParams, "unknown tool: "+params.Name)
}
if op.ans.stream == streamSocket {
return a.answer(f, nil, Errorf(501, "%s: %s", opName(op), NotACall))
}
// No URL over MCP: a tools/call carries every argument in its JSON arguments
// object, so the body IS the whole input — neither query nor path binds.
// The header reader comes off the ctx, so it answers honestly on a transport
// that has a request behind it and nothing on one that does not.
out, err := op.invoke(withOp(ctx, servedOp(op)), jsonenc.Unmarshal, params.Arguments, nil, nil, headerOf(ctx))
out, err := op.invoke(withOp(ctx, servedOp(op)), input{dec: jsonenc.Unmarshal, body: params.Arguments, media: mimeJSON, header: headerOf(ctx)})
return a.answer(f, out, err)
}
// answer renders one tool result. A handler error is MCP isError content and not
// a JSON-RPC error, per the spec — the model sees the failure and can react. ONE
// renderer, so a typed op, a [Source] and a relayed plugin answer the same shape.
//
// An answer that is not one JSON value is read into content: bytes as text when
// their media type is text, as an image or an embedded resource's base64 blob
// otherwise, and an event stream as its events, one JSON line each — each
// bounded, saying so in _meta when the bound cut it.
func (a *App) answer(f *zapmcp.Frame, out any, err error) *zapmcp.Frame {
if err != nil {
return f.Answer(mcpJSON(map[string]any{
@@ -743,6 +704,13 @@ func (a *App) answer(f *zapmcp.Frame, out any, err error) *zapmcp.Frame {
"isError": true,
}))
}
out = unwrap(out)
if _, isBody := bodyOf(out); isBody {
return a.content(f, out)
}
if _, isStream := out.(stream); isStream {
return a.content(f, out)
}
text := "null"
if out != nil {
if b, mErr := jsonenc.Marshal(out); mErr == nil {
@@ -754,6 +722,19 @@ func (a *App) answer(f *zapmcp.Frame, out any, err error) *zapmcp.Frame {
}))
}
// content answers a tools/call whose answer is bytes or a stream.
func (a *App) content(f *zapmcp.Frame, out any) *zapmcp.Frame {
var params struct {
Name string `json:"name"`
}
_ = json.Unmarshal(f.Params, &params)
result, err := toolContent(params.Name, out)
if err != nil {
return a.answer(f, nil, err)
}
return f.Answer(mcpJSON(result))
}
// toolOwner is the plugin that declared name in its catalogue, or nil.
func (a *App) toolOwner(name string) *plugin {
for _, h := range a.hosts() {
+188
View File
@@ -0,0 +1,188 @@
// 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
import (
"bytes"
"encoding/json"
"fmt"
"reflect"
)
// A union is a type with a OneOf method. The method's RESULT TYPES are the
// alternatives, and nothing calls it: the signature is the declaration.
//
// // Ingest is one event, a list of them, or a batch envelope.
// type Ingest struct{ … }
// func (Ingest) OneOf() (Event, []Event, Batch) { return Event{}, nil, Batch{} }
//
// The document publishes oneOf over the alternatives, for a request body and
// for an answer alike, each alternative described exactly as it would be alone.
// The type decodes and encodes itself (UnmarshalJSON, MarshalJSON, or [Parser]
// for a request), because which alternative a body is belongs to the type.
//
// An answer's alternative may state its own status with a value-receiver
// StatusCode, which is read off its zero value: the document files the
// alternative under that status, and the op declares it. [Or] is the union
// zip writes for itself, and the one whose alternatives may be streams.
// alternatives are a union type's alternatives, or nil.
func alternatives(t reflect.Type) []reflect.Type {
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil {
return nil
}
m, ok := t.MethodByName("OneOf")
if !ok {
m, ok = reflect.PointerTo(t).MethodByName("OneOf")
}
if !ok || m.Type.NumIn() != 1 || m.Type.NumOut() < 2 {
return nil
}
out := make([]reflect.Type, m.Type.NumOut())
for i := range out {
out[i] = m.Type.Out(i)
}
return out
}
// Or is one of two answers, or one of two request bodies: exactly one of A and
// B is set. Or[Out, Sse[T]] is an op that answers one JSON value or an event
// stream; Or[Created, Existing] files each under the status it states.
//
// As a request body it decodes into the alternative the JSON is: an
// alternative whose kind (object, array, string, number, boolean) matches is
// tried first, refusing unknown fields, A before B; then each is tried as
// encoding/json would read it.
type Or[A, B any] struct {
// A is the first alternative, when it is the one this value holds.
A *A
// B is the second alternative, when it is the one this value holds.
B *B
}
// OneOf declares the alternatives.
func (Or[A, B]) OneOf() (A, B) {
var a A
var b B
return a, b
}
// isOr reports whether t is an [Or], whose alternatives the seam unwraps at
// run time; any other union is a value that writes itself.
func isOr(t reflect.Type) bool {
_, ok := reflect.New(t).Interface().(interface{ either() any })
return ok
}
// either is the alternative the value holds, nil when it holds none.
func (o Or[A, B]) either() any {
switch {
case o.A != nil:
return o.A
case o.B != nil:
return o.B
}
return nil
}
// MarshalJSON writes the alternative the value holds, null when it holds none.
func (o Or[A, B]) MarshalJSON() ([]byte, error) {
v := o.either()
if v == nil {
return []byte("null"), nil
}
return json.Marshal(v)
}
// UnmarshalJSON reads whichever alternative the JSON is.
func (o *Or[A, B]) UnmarshalJSON(data []byte) error {
o.A, o.B = nil, nil
if string(bytes.TrimSpace(data)) == "null" {
return nil
}
var a A
var b B
kind := jsonKind(data)
if kindJSON(reflect.TypeOf(&a).Elem()) == kind && strict(data, &a) == nil {
o.A = &a
return nil
}
if kindJSON(reflect.TypeOf(&b).Elem()) == kind && strict(data, &b) == nil {
o.B = &b
return nil
}
a, b = *new(A), *new(B)
errA := json.Unmarshal(data, &a)
if errA == nil {
o.A = &a
return nil
}
if json.Unmarshal(data, &b) == nil {
o.B = &b
return nil
}
return fmt.Errorf("the value is neither alternative: %w", errA)
}
// strict decodes refusing a field the type does not have.
func strict(data []byte, v any) error {
d := json.NewDecoder(bytes.NewReader(data))
d.DisallowUnknownFields()
return d.Decode(v)
}
// jsonKind is the kind of a JSON value by its first byte.
func jsonKind(data []byte) string {
data = bytes.TrimSpace(data)
if len(data) == 0 {
return ""
}
switch data[0] {
case '{':
return "object"
case '[':
return "array"
case '"':
return "string"
case 't', 'f':
return "boolean"
}
return "number"
}
// kindJSON is the kind of JSON value a type reads.
func kindJSON(t reflect.Type) string {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if isMarshaler(t) || readsText(t) {
switch schemaOf(t, nil, nil)["type"] {
case "integer", "number":
return "number"
case "boolean", "object", "array":
return schemaOf(t, nil, nil)["type"].(string)
}
return "string"
}
switch t.Kind() {
case reflect.Struct, reflect.Map:
return "object"
case reflect.Slice, reflect.Array:
if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 {
return "string"
}
return "array"
case reflect.String:
return "string"
case reflect.Bool:
return "boolean"
case reflect.Interface:
return ""
}
return "number"
}
+235
View File
@@ -0,0 +1,235 @@
// 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 (
"context"
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/zap-proto/zip"
)
// A union is one of several shapes. zip.Or is the stock one; a type with a
// OneOf method names its own alternatives by the method's result types. Either
// way the document publishes oneOf, and an alternative that states its own
// status is filed under it.
type created struct {
ID string `json:"id"`
}
func (created) StatusCode() int { return 201 }
type existing struct {
ID string `json:"id"`
Existed bool `json:"existed"`
}
func upsert(_ context.Context, in *mkIn) (*zip.Or[created, existing], error) {
if in.Name == "old" {
return &zip.Or[created, existing]{B: &existing{ID: "old", Existed: true}}, nil
}
return &zip.Or[created, existing]{A: &created{ID: in.Name}}, nil
}
func TestOr_FilesEachAlternativeUnderItsStatus(t *testing.T) {
app := zip.New(zip.Config{AppName: "u", DisableStartupMessage: true})
app.Post("/v1/things", upsert)
for name, want := range map[string]struct {
code int
body string
}{"new": {201, `{"id":"new"}`}, "old": {200, `{"id":"old","existed":true}`}} {
code, body := call2(t, app, "POST", "/v1/things", `{"name":"`+name+`"}`)
if code != want.code || body != want.body {
t.Errorf("%s: %d %s, want %d %s", name, code, body, want.code, want.body)
}
}
resp := opIn(t, app, "/v1/things", "post")["responses"].(map[string]any)
for code, typ := range map[string]string{"201": "/created", "200": "/existing"} {
ref, _ := resp[code].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)["$ref"].(string)
if !strings.HasSuffix(ref, typ) {
t.Errorf("%s = %v, want %s", code, resp[code], typ)
}
}
}
// Or[Out, Sse[T]] answers one value or a stream, as the handler chooses, and
// the document publishes both at the one status.
type piece struct {
Text string `json:"text"`
}
type eitherIn struct {
Stream bool `json:"stream"`
}
func either(_ context.Context, in *eitherIn) (*zip.Or[piece, zip.Sse[piece]], error) {
if !in.Stream {
return &zip.Or[piece, zip.Sse[piece]]{A: &piece{Text: "whole"}}, nil
}
return &zip.Or[piece, zip.Sse[piece]]{B: &zip.Sse[piece]{Send: func(emit func(zip.Event[piece]) error) error {
for _, w := range []string{"a", "b"} {
if err := emit(zip.Event[piece]{Data: piece{Text: w}}); err != nil {
return err
}
}
return emit(zip.Event[piece]{Text: "[DONE]"})
}}}, nil
}
func TestOr_ValueOrStream(t *testing.T) {
app := zip.New(zip.Config{AppName: "ask", DisableStartupMessage: true})
app.Post("/v1/ask", either)
code, body := call2(t, app, "POST", "/v1/ask", `{"stream":false}`)
if code != 200 || body != `{"text":"whole"}` {
t.Errorf("value: %d %s", code, body)
}
resp, err := app.Test(httptest.NewRequest("POST", "/v1/ask", strings.NewReader(`{"stream":true}`)))
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
if want := "data: {\"text\":\"a\"}\n\ndata: {\"text\":\"b\"}\n\ndata: [DONE]\n\n"; string(b) != want ||
resp.Header.Get("Content-Type") != "text/event-stream" {
t.Errorf("stream: %q as %q", b, resp.Header.Get("Content-Type"))
}
ok := opIn(t, app, "/v1/ask", "post")["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)
if _, has := ok["application/json"]; !has {
t.Errorf("200 = %v, want the value", ok)
}
sse, _ := ok["text/event-stream"].(map[string]any)
if ref, _ := sse["x-events"].(map[string]any)["$ref"].(string); !strings.HasSuffix(ref, "/piece") {
t.Errorf("200 = %v, want the stream with its events' type", ok)
}
}
// An event stream is text/event-stream frames, each flushed as it is sent; a
// quiet interval writes a comment so a proxy keeps the stream open.
func TestSse_FramesAndKeepAlive(t *testing.T) {
app := zip.New(zip.Config{AppName: "sse", DisableStartupMessage: true})
app.Get("/v1/events", func(context.Context, *struct{}) (*zip.Sse[piece], error) {
return &zip.Sse[piece]{Keep: 10 * time.Millisecond, Send: func(emit func(zip.Event[piece]) error) error {
if err := emit(zip.Event[piece]{Event: "start", ID: "1", Retry: 500, Data: piece{Text: "x\ny"}}); err != nil {
return err
}
time.Sleep(80 * time.Millisecond)
return emit(zip.Event[piece]{Event: "a\nb", Text: "line1\r\nline2"})
}}, nil
})
resp, err := app.Test(httptest.NewRequest("GET", "/v1/events", nil), zip.TestConfig{Timeout: 5 * time.Second})
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
got := string(b)
first := "event: start\nid: 1\nretry: 500\ndata: {\"text\":\"x\\ny\"}\n\n"
last := "event: ab\ndata: line1\ndata: line2\n\n"
if !strings.HasPrefix(got, first) || !strings.HasSuffix(got, last) || !strings.Contains(got, "\n:\n\n") {
t.Errorf("stream = %q", got)
}
sse := opIn(t, app, "/v1/events", "get")["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)["text/event-stream"]
if sse == nil {
t.Error("no text/event-stream in the document")
}
}
// Over MCP an event stream is collected: its events, one JSON line each.
func TestSse_IsCollectedForMCP(t *testing.T) {
app := zip.New(zip.Config{AppName: "ask", DisableStartupMessage: true})
app.Post("/v1/ask", either)
built(t, app)
env := mcpCall(t, app, zip.ID("POST", "/v1/ask"), map[string]any{"stream": true})
if got := mcpText(t, env["result"].(map[string]any)); got != "{\"text\":\"a\"}\n{\"text\":\"b\"}\n[DONE]\n" {
t.Errorf("tool text = %q", got)
}
}
// A request body may be a union too: a type whose OneOf method names its
// alternatives decodes itself and is published as oneOf.
type ingestEvent struct {
Kind string `json:"kind"`
}
type ingestBatch struct {
Events []ingestEvent `json:"events"`
}
type intake struct{ events []ingestEvent }
func (intake) OneOf() (ingestEvent, ingestBatch) { return ingestEvent{}, ingestBatch{} }
func (i *intake) UnmarshalJSON(b []byte) error {
var batch ingestBatch
if json.Unmarshal(b, &batch) == nil && batch.Events != nil {
i.events = batch.Events
return nil
}
var one ingestEvent
if err := json.Unmarshal(b, &one); err != nil {
return err
}
i.events = []ingestEvent{one}
return nil
}
func TestOneOf_RequestUnion(t *testing.T) {
app := zip.New(zip.Config{AppName: "in", DisableStartupMessage: true})
app.Post("/v1/intake", func(_ context.Context, in *intake) (*mkOut, error) {
return &mkOut{ID: strings.Repeat("e", len(in.events))}, nil
})
if _, body := call2(t, app, "POST", "/v1/intake", `{"events":[{"kind":"a"},{"kind":"b"}]}`); body != `{"id":"ee"}` {
t.Errorf("batch: %s", body)
}
if _, body := call2(t, app, "POST", "/v1/intake", `{"kind":"a"}`); body != `{"id":"e"}` {
t.Errorf("one: %s", body)
}
b, _ := json.Marshal(app.OpenAPISpec())
var doc struct {
Components struct {
Schemas map[string]map[string]any `json:"schemas"`
} `json:"components"`
}
_ = json.Unmarshal(b, &doc)
one, _ := doc.Components.Schemas["intake"]["oneOf"].([]any)
if len(one) != 2 {
t.Errorf("intake = %v, want oneOf its two alternatives", doc.Components.Schemas["intake"])
}
}
// zip.Or decodes a request into the alternative the JSON is.
func TestOr_DecodesTheAlternative(t *testing.T) {
var o zip.Or[ingestEvent, []ingestEvent]
if err := json.Unmarshal([]byte(`[{"kind":"a"}]`), &o); err != nil || o.B == nil || o.A != nil {
t.Errorf("list: %v %+v", err, o)
}
if err := json.Unmarshal([]byte(`{"kind":"a"}`), &o); err != nil || o.A == nil || o.B != nil {
t.Errorf("one: %v %+v", err, o)
}
if err := json.Unmarshal([]byte(`7`), &o); err == nil {
t.Errorf("a number decoded as %+v", o)
}
}
// A component name is a valid one whatever the type's arguments.
func TestOr_ComponentNameIsPlain(t *testing.T) {
app := zip.New(zip.Config{AppName: "u", DisableStartupMessage: true})
app.Post("/v1/things", func(context.Context, *zip.Or[ingestEvent, []ingestEvent]) (*mkOut, error) { return nil, nil })
b, _ := json.Marshal(app.OpenAPISpec())
if strings.Contains(string(b), "[") && strings.Contains(string(b), `"Or[`) {
t.Errorf("a schema is named with brackets: %s", b)
}
if !strings.Contains(string(b), `"#/components/schemas/OrIngestEventIngestEventList"`) {
t.Errorf("document = %s", b)
}
}
+128 -310
View File
@@ -5,7 +5,6 @@ import (
"maps"
"net/http"
"reflect"
"sort"
"strconv"
"strings"
"time"
@@ -63,263 +62,6 @@ func (a *App) buildOpenAPI() map[string]any {
return ProjectOpenAPI(a.Manifest())
}
func (a *App) buildOpenAPIReflect() map[string]any {
cfg := a.cfg.OpenAPI
if cfg.Title == "" {
cfg.Title = a.cfg.AppName
}
if cfg.Title == "" {
cfg.Title = "zip API"
}
if cfg.Version == "" {
cfg.Version = "0.0.0"
}
paths := map[string]map[string]any{}
// One registry for the whole document: a type reached by two ops is one
// definition in components.schemas that both point at.
reg := newSchemaRegistry(specDefs)
// The addresses whose refusals speak RFC 6749, read from the same walk the
// error handler is built from, so the document and the wire agree.
oauth := composeOAuth(a.plan())
// Sort ops by path,method for deterministic output.
ops := append([]*registeredOp{}, a.Registry()...)
sort.Slice(ops, func(i, j int) bool {
if ops[i].Path != ops[j].Path {
return ops[i].Path < ops[j].Path
}
return ops[i].Method < ops[j].Method
})
for _, op := range ops {
// Whose types these are. Empty for this app's own ops, so a document
// with nothing composed into it is byte-identical to one from an app
// that composes nothing.
reg.origin = op.Origin
// The document's spelling of the router's pattern, from [Template], which
// is the one place that rule lives. This built its own along the way —
// replace "/:" with "/{", then repair the missing brace by calling
// Template anyway — and the shortcut answered for two shapes it was never
// asked about. A path carrying a parameter AND a literal "}" satisfied the
// repair's guard without being repaired, so it published "{name" unclosed;
// and a wildcard has no "/:" at all, so "*" reached the document verbatim,
// which no path template can mean. A caller reading a route table named
// that segment one thing and this named it another, and the two resolve as
// different operations rather than one address spelled twice.
path := Template(op.Path)
if _, ok := paths[path]; !ok {
paths[path] = map[string]any{}
}
// opName is the ONE place the id rule lives, shared with the MCP tool
// list, the op-call plane and the Authorizer's Op — so an operation is
// addressed by the same token whichever projection you came through.
opObj := map[string]any{
"operationId": opName(op),
"summary": op.Summary,
}
// Prose and examples extracted from the source by cmd/zipdoc. Absent
// when the generator has not run, which degrades to the schema-only
// spec rather than failing — a spec without descriptions is still a
// usable spec.
doc, hasDoc := docFor(op.Pkg, op.Method, op.Path)
if hasDoc {
if doc.Description != "" {
opObj["description"] = doc.Description
}
if op.Summary == "" {
opObj["summary"] = firstSentence(doc.Description)
}
}
if len(op.Tags) > 0 {
opObj["tags"] = op.Tags
}
// Request body.
if hasRequestBody(op) {
media := map[string]any{"schema": schemaOf(op.InType, reg, docFields(hasDoc, doc))}
// An example is what makes a spec explorable — it is the difference
// between a reference someone reads and one they can press "try it"
// on.
if hasDoc && len(doc.Example) > 0 {
media["example"] = json.RawMessage(doc.Example)
}
opObj["requestBody"] = map[string]any{
"required": true,
"content": map[string]any{"application/json": media},
}
}
// Path parameters. OpenAPI requires every templated segment to be declared,
// so they are derived from the route pattern itself — the same string the
// router matches on, so the spec cannot describe a parameter the route does
// not have (or omit one it does).
// A parameter's prose lives with the field it binds to, under the same
// "<Type>.<field>" key the in-process CLI reads. Looking it up here
// rather than restating it is what keeps a generated client and a
// linked-in one describing the same argument the same way — the
// alternative is a spec that silently has nothing to say about an
// argument the registry documents fine.
fields := docFields(hasDoc, doc)
inName := typeName(op.InType)
// A parameter's example is that field's value in the op's OWN example —
// the one the doc comment already wrote, split across the parameters
// that carry it. A bodyless op has no requestBody for the example to
// live in, and an example that only survives for methods with a body is
// an example missing from every GET and DELETE in the document.
example := exampleFields(doc.Example)
// Two keys, because the two lookups are filed under different names. Prose
// is filed by the GO FIELD, which is what a doc comment documents; an
// example is a value in the op's own example document, so it is keyed by
// the name that value carries on the WIRE. They coincide for most
// parameters and do not for a wildcard, whose wire name is fiber's *N.
describe := func(decl map[string]any, docField, wire string) map[string]any {
if help := fields[inName+"."+docField]; help != "" {
decl["description"] = help
}
if v, ok := example[wire]; ok {
decl["example"] = v
}
return decl
}
// A parameter's TYPE is the type of the field it binds to, wherever the
// URL carried it from. bindURL is one binder over one set of fields, so
// the document reads that one set too — declaring every path param a
// string while consulting the input for query params described the same
// value two different ways depending on which half of the URL it rode in.
url := urlFields(op.InType)
params := pathParams(op.Path)
decls := make([]any, 0, len(params))
named := make(map[string]bool, len(params))
for _, p := range params {
// Both spellings are spoken for, so a field bound under either is not
// then described a second time as a query parameter.
named[strings.ToLower(p.Name)] = true
named[strings.ToLower(p.Key)] = true
decls = append(decls, describe(map[string]any{
"name": p.Name, "in": "path", "required": true,
"schema": url.paramSchema(p.Key),
}, url.docKey(p.Key), p.Key))
}
// Header parameters. A field carrying `header:"X-Foo"` is a REQUEST FACT
// the op declared, so the document names it — that is what makes reading
// a header part of the contract instead of something a middleware does
// off to the side where no projection can see it. Declared for every
// method, because a header rides a POST as readily as a GET, and excluded
// from the query list below so one field is never described twice.
hdr := headerFields(op.InType)
for _, h := range hdr {
named[strings.ToLower(h.field)] = true
decls = append(decls, describe(map[string]any{
"name": h.header, "in": "header", "required": h.required,
"schema": h.schema,
}, h.field, h.field))
}
// Query parameters. A bodyless method binds its input from the URL
// (typed.go bindURL), so every In field that is NOT already a path
// segment is reachable as `?field=` — and the document has to say so, or
// it describes a route nobody can call correctly. Declared only where
// there is no requestBody, because that is exactly where the binder
// treats the URL as the whole input.
if !hasBody(op.Method) {
for _, f := range url {
if named[strings.ToLower(f.name)] {
continue
}
decls = append(decls, describe(map[string]any{
"name": f.name, "in": "query", "required": f.required,
"schema": f.schema,
}, url.docKey(f.name), f.name))
}
}
if len(decls) > 0 {
opObj["parameters"] = decls
}
// The success response, keyed on the status the op DECLARED — the whole
// reason WithStatus is on the op rather than set per request: a 201 that
// only reached the wire would leave every generated client expecting a
// 200 the service never sends.
if op.OutType != nil && typeName(op.OutType) != "" {
respMedia := map[string]any{"schema": schemaOf(op.OutType, reg, docFields(hasDoc, doc))}
if hasDoc && len(doc.Response) > 0 {
respMedia["example"] = json.RawMessage(doc.Response)
}
resp := map[string]any{}
for _, code := range declaredStatuses(op, 200) {
entry := map[string]any{
"description": statusText(code),
"content": map[string]any{"application/json": respMedia},
}
if h := responseHeaderSchemas(op); h != nil {
entry["headers"] = h
}
resp[strconv.Itoa(code)] = entry
}
opObj["responses"] = resp
} else {
resp := map[string]any{}
for _, code := range declaredStatuses(op, 204) {
entry := map[string]any{"description": statusText(code)}
if h := responseHeaderSchemas(op); h != nil {
entry["headers"] = h
}
resp[strconv.Itoa(code)] = entry
}
opObj["responses"] = resp
}
// A gated service can hold ANY op, so its document publishes the held body
// beside every op's success response and a generated client reads the pair
// rather than mistaking a held op for a finished one.
//
// The op is asked, not the app, because they can differ: a composed child
// answers to whatever included it (see [adopt]), and a MOUNTED op has no
// local rule at all — it is another service's op, forwarded, so this
// document must not promise an answer this app would never send. Never
// over a 202 the op declared itself, either, which is the op's own meaning
// for that code.
if op.rule != nil && op.rule() != nil {
if resp, ok := opObj["responses"].(map[string]any); ok {
if _, taken := resp["202"]; !taken {
resp["202"] = map[string]any{
"description": "held for approval",
"content": map[string]any{
"application/json": map[string]any{"schema": schemaOf(approvalType, reg, nil)},
},
}
}
}
}
// Any status the op does not declare is a refusal, written in the body its
// address speaks (see problem.go). Publishing that body is what lets a
// client generated from this document type its errors.
if resp, ok := opObj["responses"].(map[string]any); ok {
resp["default"] = refusalResponse(reg, oauth[op.Method+" "+op.Path])
}
paths[path][strings.ToLower(op.Method)] = opObj
}
return map[string]any{
"openapi": "3.1.0",
"info": map[string]any{
"title": cfg.Title,
"description": cfg.Description,
"version": cfg.Version,
},
"paths": paths,
"components": map[string]any{
"schemas": reg.defs,
},
}
}
// The schema names a refusal body is published under. Neither can be the name
// of a Go type: a type name has no '-', and a qualified one contains a '.'.
const (
@@ -328,8 +70,10 @@ const (
)
// refusalResponse is an op's `default` response: an RFC 9457 problem document,
// or RFC 6749's error pair at an OAuth address.
func refusalResponse(reg *schemaRegistry, oauth bool) map[string]any {
// or RFC 6749's error pair at an OAuth address. A relay's refusal may also be
// the upstream's own, relayed as sent ([Verbatim]), which the response says
// beside this service's.
func refusalResponse(reg *schemaRegistry, oauth, relayed bool) map[string]any {
name, media, schema := problemSchema, mimeProblem, problemDocument
if oauth {
name, media, schema = oauthSchema, mimeJSON, oauthError
@@ -337,10 +81,15 @@ func refusalResponse(reg *schemaRegistry, oauth bool) map[string]any {
if _, ok := reg.defs[name]; !ok {
reg.defs[name] = schema()
}
return map[string]any{
out := map[string]any{
"description": "refused",
"content": map[string]any{media: map[string]any{"schema": reg.ref(name)}},
}
if relayed {
out["description"] = "refused, by this service or by the upstream, whose refusal is relayed as it was sent"
out["content"].(map[string]any)["*/*"] = map[string]any{"schema": map[string]any{}}
}
return out
}
// problemDocument is the shape [HTTPError.problem] writes. A refusal's own
@@ -369,8 +118,8 @@ func oauthError() map[string]any {
"type": "object",
"description": "An RFC 6749 error response.",
"properties": map[string]any{
"error": map[string]any{"type": "string"},
"error_description": map[string]any{"type": "string"},
"error": map[string]any{"type": "string", "description": "The RFC 6749 error code, such as invalid_request or invalid_grant."},
"error_description": map[string]any{"type": "string", "description": "What was refused, for a person to read."},
},
"required": []string{"error"},
}
@@ -770,6 +519,112 @@ func typeName(t reflect.Type) string {
return ""
}
// componentName is the name a type is published under: its own, except an
// instantiated generic. reflect names one Envelope[github.com/x/y.Store], which
// no schema name may contain, so it is published as the generic's name followed
// by its arguments' names — EnvelopeStore. A pointer argument is its element, a
// slice argument its element's name and List, a map argument its value's name
// and Map, a basic type its name capitalised.
//
// Prose stays filed under the reflect name, which is what zipdoc writes and
// what the field lookups read, so this changes what a schema is called and
// nothing about what it says.
func componentName(t reflect.Type) string { return plainName(typeName(t)) }
// plainName is a reflect type name with its type arguments folded in.
func plainName(n string) string {
open := strings.IndexByte(n, '[')
if open < 0 || !strings.HasSuffix(n, "]") {
return n
}
var b strings.Builder
b.WriteString(n[:open])
for _, arg := range typeArgs(n[open+1 : len(n)-1]) {
b.WriteString(argName(arg))
}
return b.String()
}
// typeArgs splits a type-argument list at its top-level commas.
func typeArgs(s string) []string {
var out []string
depth, start := 0, 0
for i := 0; i < len(s); i++ {
switch s[i] {
case '[', '(', '{':
depth++
case ']', ')', '}':
depth--
case ',':
if depth == 0 {
out = append(out, s[start:i])
start = i + 1
}
}
}
return append(out, s[start:])
}
// argName is one type argument's part of a published name.
func argName(s string) string {
s = strings.TrimSpace(s)
switch {
case strings.HasPrefix(s, "*"):
return argName(s[1:])
case strings.HasPrefix(s, "[]"):
return argName(s[2:]) + "List"
case strings.HasPrefix(s, "map["):
depth := 0
for i := 3; i < len(s); i++ {
switch s[i] {
case '[':
depth++
case ']':
if depth--; depth == 0 {
return argName(s[i+1:]) + "Map"
}
}
}
case strings.HasPrefix(s, "["):
if i := strings.IndexByte(s, ']'); i > 0 {
return argName(s[i+1:]) + "Array"
}
case s == "interface {}" || s == "any":
return "Any"
case strings.HasPrefix(s, "struct"), strings.HasPrefix(s, "interface"), strings.HasPrefix(s, "func"),
strings.HasPrefix(s, "chan"):
return "Value"
}
head, rest := s, ""
if i := strings.IndexByte(s, '['); i >= 0 {
head, rest = s[:i], s[i:]
}
// A type's name follows the last dot: a package path may hold dots of its
// own (gopkg.in/yaml.v3.Node), a type name never does.
if i := strings.LastIndexByte(head, '.'); i >= 0 {
head = head[i+1:]
}
if head != "" {
head = strings.ToUpper(head[:1]) + head[1:]
}
return plainName(head + rest)
}
// declaring is the struct that declares the field at index within t: t itself
// for its own fields, the embedded struct for a promoted one.
func declaring(t reflect.Type, index []int) reflect.Type {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
for _, i := range index[:len(index)-1] {
t = t.Field(i).Type
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
}
return t
}
// Where a projection keeps the definitions its schemas refer to. An OpenAPI
// document has one place for them; a schema sent on its own carries its own.
const (
@@ -853,12 +708,12 @@ func (r *schemaRegistry) nameFor(t reflect.Type) string {
if r.origin != "" {
qual = r.origin + "."
}
base := qual + typeName(t)
base := qual + componentName(t)
if _, taken := r.defs[base]; !taken {
return base
}
if p := t.PkgPath(); p != "" {
base = qual + p[strings.LastIndexByte(p, '/')+1:] + "." + typeName(t)
base = qual + p[strings.LastIndexByte(p, '/')+1:] + "." + componentName(t)
}
for name, n := base, 2; ; n++ {
if _, taken := r.defs[name]; !taken {
@@ -1016,6 +871,8 @@ func structSchema(into map[string]any, t reflect.Type, reg *schemaRegistry, fiel
fs := schemaOf(f.Type, reg, fields)
if d := fields[typeName(t)+"."+name]; d != "" {
fs["description"] = d
} else if d := fields[typeName(declaring(t, f.Index))+"."+name]; d != "" {
fs["description"] = d
}
props[name] = fs
if tag := f.Tag.Get("validate"); strings.Contains(tag, "required") {
@@ -1178,6 +1035,12 @@ func urlFieldName(f reflect.StructField) string {
if tag, ok := f.Tag.Lookup("url"); ok {
return jsontag.Name(f.Name, tag)
}
// The body taken as sent, a form field, a file part and a cookie each ride
// where they are declared, and a URL that could also carry one would be a
// second way to say it — for a cookie, a way for a link to set a session.
if f.Type == bodyType || formFieldName(f) != "" || cookieFieldName(f) != "" {
return "-"
}
return jsonFieldName(f)
}
@@ -1343,30 +1206,6 @@ const swaggerHTML = `<!doctype html>
// deriving it here is that there is only one.
func (a *App) OpenAPISpec() map[string]any { return a.buildOpenAPI() }
// primaryStatus is the code an op answers with when its output states nothing —
// the first it declared. The document keys the success response on it, and every
// OTHER declared status gets its own entry beside it (see multiStatus), because
// a code the service can send and the document omits is a code no generated
// client will handle.
// declaredStatuses is every success code an op may answer with — all of them,
// not just the first. An op that declares 200 and 201 can send either, so the
// document says both; publishing one would leave a generated client with no
// branch for the other, which is precisely the hole a per-request status slot
// left open.
func declaredStatuses(op *registeredOp, dflt int) []int {
if len(op.Statuses) == 0 {
return []int{dflt}
}
return op.Statuses
}
func primaryStatus(op *registeredOp) int {
if len(op.Statuses) == 0 {
return 0
}
return op.Statuses[0]
}
// headerField is one declared header parameter: the header it reads, the field
// it lands on, and the shape it carries.
type headerField struct {
@@ -1405,24 +1244,3 @@ func headerFields(t reflect.Type) []headerField {
}
return out
}
// responseHeaderSchemas is the `headers` object of a response, from what the op
// declared with [WithResponseHeader].
//
// A response header a caller relies on — a cache directive, a payment challenge,
// a Set-Cookie — is part of the contract, so the document names it. Without this
// the header would be set on the wire and described nowhere, which is the same
// invisibility that made a context slot the wrong home for it.
func responseHeaderSchemas(op *registeredOp) map[string]any {
if len(op.ResponseHeaders) == 0 {
return nil
}
out := make(map[string]any, len(op.ResponseHeaders))
for _, name := range op.ResponseHeaders {
out[name] = map[string]any{
"description": "Set by " + op.Method + " " + op.Path + ".",
"schema": map[string]any{"type": "string"},
}
}
return out
}
+132
View File
@@ -0,0 +1,132 @@
// 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)
}
}
+396 -114
View File
@@ -68,121 +68,27 @@ func ProjectOpenAPI(m Manifest) map[string]any {
if len(op.Tags) > 0 {
obj["tags"] = op.Tags
}
if op.Raw && hasBody(op.Method) {
obj["requestBody"] = map[string]any{
"required": true,
"content": map[string]any{
"application/octet-stream": map[string]any{"schema": binarySchema()},
},
}
} else if p.hasRequestBody(op) {
media := map[string]any{"schema": p.schema(TypeRef{Ref: op.In}, reg)}
if len(op.Example) > 0 {
media["example"] = json.RawMessage(op.Example)
}
obj["requestBody"] = map[string]any{
"required": true,
"content": map[string]any{"application/json": media},
}
if body := p.requestBody(op, reg); body != nil {
obj["requestBody"] = body
}
url := p.urlFields(op.In)
example := exampleFields(op.Example)
describe := func(decl map[string]any, docField, wire string) map[string]any {
if help := p.doc(op.In, docField); help != "" {
decl["description"] = help
}
if v, ok := example[wire]; ok {
decl["example"] = v
}
return decl
if params := p.parameters(op); len(params) > 0 {
obj["parameters"] = params
}
params := pathParams(op.Path)
decls := make([]any, 0, len(params))
named := make(map[string]bool, len(params))
for _, q := range params {
named[strings.ToLower(q.Name)] = true
named[strings.ToLower(q.Key)] = true
decls = append(decls, describe(map[string]any{
"name": q.Name, "in": "path", "required": true,
"schema": url.paramSchema(q.Key),
}, url.docKey(q.Key), q.Key))
}
for _, h := range p.headerFields(op.In) {
named[strings.ToLower(h.field)] = true
decls = append(decls, describe(map[string]any{
"name": h.header, "in": "header", "required": h.required,
"schema": h.schema,
}, h.field, h.field))
}
if !hasBody(op.Method) {
for _, f := range url {
if named[strings.ToLower(f.name)] {
continue
}
decls = append(decls, describe(map[string]any{
"name": f.name, "in": "query", "required": f.required,
"schema": f.schema,
}, url.docKey(f.name), f.name))
}
}
if len(decls) > 0 {
obj["parameters"] = decls
}
resp := map[string]any{}
if kind, ok := streams[op.Stream]; ok {
schema := binarySchema()
if op.Stream == "sse" {
schema = map[string]any{"type": "string"}
}
for _, code := range statuses(op.Statuses, 200) {
entry := map[string]any{
"description": statusText(code),
"content": map[string]any{kind: map[string]any{"schema": schema}},
}
if h := headerDecls(op); h != nil {
entry["headers"] = h
}
resp[strconv.Itoa(code)] = entry
}
} else if out := p.types[op.Out]; out != nil && out.Name != "" {
media := map[string]any{"schema": p.schema(TypeRef{Ref: op.Out}, reg)}
if len(op.Response) > 0 {
media["example"] = json.RawMessage(op.Response)
}
for _, code := range statuses(op.Statuses, 200) {
entry := map[string]any{
"description": statusText(code),
"content": map[string]any{"application/json": media},
}
if h := headerDecls(op); h != nil {
entry["headers"] = h
}
resp[strconv.Itoa(code)] = entry
}
} else {
for _, code := range statuses(op.Statuses, 204) {
entry := map[string]any{"description": statusText(code)}
if h := headerDecls(op); h != nil {
entry["headers"] = h
}
resp[strconv.Itoa(code)] = entry
}
if op.Stream == streamSocket {
obj["x-socket"] = "websocket"
}
resp := p.responses(op, reg)
if op.Gated {
if _, taken := resp["202"]; !taken {
resp["202"] = map[string]any{
"description": "held for approval",
"content": map[string]any{
"application/json": map[string]any{"schema": schemaOf(approvalType, reg, nil)},
"application/json": map[string]any{"schema": schemaOf(approvalType, reg, approvalProse)},
},
}
}
}
resp["default"] = refusalResponse(reg, op.OAuth)
resp["default"] = refusalResponse(reg, op.OAuth, op.Verbatim)
obj["responses"] = resp
paths[path][strings.ToLower(op.Method)] = obj
}
@@ -199,17 +105,312 @@ func ProjectOpenAPI(m Manifest) map[string]any {
}
}
// requestBody is an op's requestBody object, nil when it takes none. Its kind is
// read off the input: a field that IS the body (bytes as sent), form fields
// (one object, its file parts binary in a multipart form), or a value (the
// input's schema), each under the media the op consumes.
func (p *projector) requestBody(op ManifestOp, reg *schemaRegistry) map[string]any {
if !hasBody(op.Method) {
return nil
}
content := map[string]any{}
media := op.Consumes
td := p.types[op.In]
body := map[string]any{"required": true, "content": content}
switch {
case op.Raw || p.bodyField(td) != nil:
if len(media) == 0 {
media = []string{mimeOctet}
}
for _, m := range media {
content[m] = map[string]any{"schema": binarySchema()}
}
// The bytes are the field, so the field's prose is the body's.
if f := p.bodyField(td); f != nil && f.Doc != "" {
body["description"] = f.Doc
}
case p.takesForm(td):
for _, m := range media {
content[m] = map[string]any{"schema": p.formSchema(td, m == mimeMultipart, reg)}
}
case p.hasRequestBody(op):
if len(media) == 0 {
media = []string{mimeJSON}
}
for _, m := range media {
entry := map[string]any{"schema": p.schema(TypeRef{Ref: op.In}, reg)}
if len(op.Example) > 0 {
entry["example"] = json.RawMessage(op.Example)
}
content[m] = entry
}
default:
return nil
}
return body
}
// bodyField is the input's field that IS the request body, or nil.
func (p *projector) bodyField(td *TypeDesc) *FieldDesc {
if td == nil || td.Kind != "struct" {
return nil
}
for i := range td.Fields {
if td.Fields[i].Body {
return &td.Fields[i]
}
}
return nil
}
// takesForm reports whether the input's body is a form.
func (p *projector) takesForm(td *TypeDesc) bool {
if td == nil || td.Kind != "struct" {
return false
}
for _, f := range td.Fields {
if f.Form != "" {
return true
}
}
return false
}
// formSchema is a form body: one object of the input's form fields under their
// form names. A file part is binary in a multipart form and its {name, type,
// bytes} object where the form arrives as JSON.
func (p *projector) formSchema(td *TypeDesc, multipart bool, reg *schemaRegistry) map[string]any {
props := map[string]any{}
var required []string
for _, f := range td.Fields {
if f.Form == "" {
continue
}
var fs map[string]any
switch {
case multipart && p.binary(f.Type):
fs = binarySchema()
case multipart && f.Type.List != nil && p.binary(*f.Type.List):
fs = map[string]any{"type": "array", "items": binarySchema()}
default:
fs = p.schema(f.Type, reg)
}
if f.Doc != "" {
fs["description"] = f.Doc
}
props[f.Form] = fs
if f.Required {
required = append(required, f.Form)
}
}
out := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
out["required"] = required
}
return out
}
// binary reports whether a field's type is bytes where a form part carries it.
func (p *projector) binary(r TypeRef) bool {
td := p.types[r.Ref]
return td != nil && td.Binary
}
// parameters are an op's path, header, cookie and query parameters. The query
// carries the input's URL-borne fields whenever the body does not: for a method
// without one, and for a body that is bytes as sent or a form.
func (p *projector) parameters(op ManifestOp) []any {
url := p.urlFields(op.In)
example := exampleFields(op.Example)
describe := func(decl map[string]any, docField, wire string) map[string]any {
if help := p.doc(op.In, docField); help != "" {
decl["description"] = help
}
if v, ok := example[wire]; ok {
decl["example"] = v
}
return decl
}
params := pathParams(op.Path)
decls := make([]any, 0, len(params))
named := make(map[string]bool, len(params))
for _, q := range params {
named[strings.ToLower(q.Name)] = true
named[strings.ToLower(q.Key)] = true
decls = append(decls, describe(map[string]any{
"name": q.Name, "in": "path", "required": true,
"schema": url.paramSchema(q.Key),
}, url.docKey(q.Key), q.Key))
}
for _, h := range p.headerFields(op.In) {
named[strings.ToLower(h.field)] = true
decls = append(decls, describe(map[string]any{
"name": h.header, "in": "header", "required": h.required,
"schema": h.schema,
}, h.field, h.field))
}
if td := p.types[op.In]; td != nil && td.Kind == "struct" {
for _, f := range td.Fields {
if f.Cookie == "" {
continue
}
decls = append(decls, describe(map[string]any{
"name": f.Cookie, "in": "cookie", "required": f.Required,
"schema": p.schema(f.Type, nil),
}, f.JSON, f.JSON))
}
}
td := p.types[op.In]
if !hasBody(op.Method) || p.bodyField(td) != nil || p.takesForm(td) {
for _, f := range url {
if named[strings.ToLower(f.name)] {
continue
}
decls = append(decls, describe(map[string]any{
"name": f.name, "in": "query", "required": f.required,
"schema": f.schema,
}, url.docKey(f.name), f.name))
}
}
return decls
}
// responses are an op's answers by status: each declared status with what it
// carries. A 3xx carries no body, only the headers that send the client on. A
// stream is published at the op's first status that is not a redirect: an
// event stream with the event's type as x-events, bytes under the media the op
// produces, an upgrade as 101 with the message's type. A JSON answer is filed
// under its status; a union files each alternative under the status it states,
// several at one status being a oneOf.
func (p *projector) responses(op ManifestOp, reg *schemaRegistry) map[string]any {
resp := map[string]any{}
if op.Stream == streamSocket {
entry := map[string]any{"description": "switching protocols"}
if op.Event != "" {
entry["x-events"] = p.schema(TypeRef{Ref: op.Event}, reg)
}
resp["101"] = entry
return resp
}
dflt := 200
if op.Stream == "" && !p.answers(op.Out) {
dflt = 204
}
codes := statuses(op.Statuses, dflt)
primary := codes[0]
for _, code := range codes {
if code < 300 || code >= 400 {
primary = code
break
}
}
byStatus := p.alternatives(op.Out, primary)
for _, code := range codes {
entry := map[string]any{"description": statusText(code)}
if h := headerDecls(op); h != nil {
entry["headers"] = h
}
if code >= 300 && code < 400 {
resp[strconv.Itoa(code)] = entry
continue
}
content := map[string]any{}
if alts := byStatus[code]; len(alts) > 0 {
var schema map[string]any
if len(alts) == 1 {
schema = p.schema(alts[0], reg)
} else {
one := make([]any, len(alts))
for i, a := range alts {
one[i] = p.schema(a, reg)
}
schema = map[string]any{"oneOf": one}
}
media := map[string]any{"schema": schema}
if len(op.Response) > 0 && code == primary {
media["example"] = json.RawMessage(op.Response)
}
jsonMedia := []string{mimeJSON}
if len(op.Produces) > 0 && op.Stream != streamBytes {
jsonMedia = op.Produces
}
for _, m := range jsonMedia {
content[m] = media
}
}
if code == primary || (op.Stream == streamBytes && !p.answers(op.Out)) {
switch op.Stream {
case streamSSE:
entry := map[string]any{"schema": map[string]any{"type": "string"}}
if op.Event != "" {
entry["x-events"] = p.schema(TypeRef{Ref: op.Event}, reg)
}
content[streams[streamSSE]] = entry
case streamBytes:
media := op.Produces
if len(media) == 0 {
media = []string{mimeOctet}
}
for _, m := range media {
content[m] = map[string]any{"schema": binarySchema()}
}
}
}
if len(content) > 0 {
entry["content"] = content
}
resp[strconv.Itoa(code)] = entry
}
return resp
}
// answers reports whether an op answers a JSON value.
func (p *projector) answers(out string) bool {
td := p.types[out]
return td != nil && (td.Name != "" || td.Kind == "union")
}
// alternatives files an op's JSON answer by status: the value under the
// primary status, or each alternative of a union under the status it states.
func (p *projector) alternatives(out string, primary int) map[int][]TypeRef {
td := p.types[out]
if td == nil || !p.answers(out) {
return nil
}
if td.Kind != "union" {
return map[int][]TypeRef{primary: {{Ref: out}}}
}
by := map[int][]TypeRef{}
for _, alt := range td.OneOf {
code := primary
if a := p.types[alt.Ref]; a != nil && a.Status != 0 {
code = a.Status
}
by[code] = append(by[code], alt)
}
if td.Name != "" && len(by) == 1 {
// A named union answered at one status is its own schema, one
// component every op that answers it shares.
return map[int][]TypeRef{primary: {{Ref: out}}}
}
return by
}
// binarySchema is bytes sent as they are, which is what a raw body and a byte
// stream both are.
func binarySchema() map[string]any {
return map[string]any{"type": "string", "format": "binary"}
}
// ProjectMCP is m as an MCP tool list, in name order.
// ProjectMCP is m as an MCP tool list, in name order. An op that upgrades to a
// connection is not a tool ([NotACall]); [RefusedMCP] names it.
func ProjectMCP(m Manifest) []map[string]any {
p := newProjector(m)
tools := make([]map[string]any, 0, len(m.Ops))
for _, op := range m.Ops {
if op.Stream == streamSocket {
continue
}
desc := op.Summary
if op.Description != "" {
desc = op.Description
@@ -231,6 +432,18 @@ func ProjectMCP(m Manifest) []map[string]any {
return tools
}
// RefusedMCP names the ops of m that are not tools, each with the reason: what
// tools/list's _meta says about the ops it leaves out.
func RefusedMCP(m Manifest) map[string]string {
out := map[string]string{}
for _, op := range m.Ops {
if op.Stream == streamSocket {
out[op.ID] = NotACall
}
}
return out
}
// ProjectCLI is m as a command tree.
//
// It reads the manifest rather than the document it also produces, and that is
@@ -253,6 +466,11 @@ func ProjectCLI(m Manifest) []Command {
Method: op.Method,
Path: op.Path,
Example: op.Example,
Stream: op.Stream,
}
if len(op.Consumes) > 0 {
c.Consumes = append([]string(nil), op.Consumes...)
sort.Strings(c.Consumes)
}
c.Service, c.Name = commandName(op.Method, op.Path, op.ID)
if c.Summary == "" {
@@ -267,7 +485,14 @@ func ProjectCLI(m Manifest) []Command {
// bind splits an op's input into positional args and flags: the URL addresses
// the resource, so what addresses it is positional and what modifies the
// request is a flag.
// request is a flag. A declared header or cookie is a flag that rides as one;
// a body taken as sent is --body; a form field is a flag and a file part a
// --field; what is left rides the JSON body, or the query when the body is not
// JSON.
//
// The flags come in the order the document lists them — headers, cookies,
// then the query or the JSON body, then the form — so a command derived here
// and one derived from the document are the same command.
func (p *projector) bind(op ManifestOp) ([]Arg, []Flag) {
params := pathParams(op.Path)
td := p.types[op.In]
@@ -279,7 +504,7 @@ func (p *projector) bind(op ManifestOp) ([]Arg, []Flag) {
return args, nil
}
var flags []Flag
add := func(name, url, kind string, required bool) {
add := func(name, url, kind, help string, required bool, in string) {
if name == "-" || isParam(params, url) {
return
}
@@ -287,19 +512,61 @@ func (p *projector) bind(op ManifestOp) ([]Arg, []Flag) {
Name: kebab(name),
Field: name,
Type: kind,
Help: p.doc(op.In, name),
Help: help,
Required: required,
In: in,
})
}
if hasBody(op.Method) {
beside := map[string]bool{}
for _, f := range td.Fields {
if f.Header != "" {
add(f.Header, f.Header, p.flagType(f.Type), f.Doc, f.Required, "header")
beside[f.JSON] = true
}
}
for _, f := range td.Fields {
if f.Cookie != "" {
add(f.Cookie, f.Cookie, p.flagType(f.Type), f.Doc, f.Required, "cookie")
beside[f.JSON] = true
}
}
raw, form := p.bodyField(td), p.takesForm(td)
if hasBody(op.Method) && raw == nil && !form {
// Every field the JSON body carries, as the document's body schema
// lists it: a declared header or cookie rides the body too. A schema's
// properties have no order, so both derivations sort them by name.
n := len(flags)
for _, f := range td.Fields {
add(f.JSON, urlName(f), p.flagType(f.Type), f.Required)
add(f.JSON, urlName(f), p.flagType(f.Type), p.doc(op.In, f.JSON), f.Required, "")
}
} else {
for _, f := range p.urlFields(op.In) {
kind, _ := f.schema["type"].(string)
add(f.name, f.name, specType(kind), f.required)
body := flags[n:]
sort.Slice(body, func(i, j int) bool { return body[i].Field < body[j].Field })
return args, flags
}
for _, f := range p.urlFields(op.In) {
if beside[f.field] {
continue
}
kind, _ := f.schema["type"].(string)
add(f.name, f.name, specType(kind), p.doc(op.In, f.name), f.required, "")
}
if raw != nil {
flags = append(flags, Flag{Name: "body", Field: "body", Type: "file", Help: raw.Doc, In: "body"})
}
if form {
var parts []Flag
for _, f := range td.Fields {
if f.Form == "" {
continue
}
in, kind := "form", p.flagType(f.Type)
if p.binary(f.Type) || f.Type.List != nil && p.binary(*f.Type.List) {
in, kind = "file", "file"
}
parts = append(parts, Flag{Name: kebab(f.Form), Field: f.Form, Type: kind, Help: f.Doc, Required: f.Required, In: in})
}
sort.Slice(parts, func(i, j int) bool { return parts[i].Field < parts[j].Field })
flags = append(flags, parts...)
}
return args, flags
}
@@ -355,6 +622,13 @@ func (p *projector) schema(r TypeRef, reg *schemaRegistry) map[string]any {
return map[string]any{"type": "array", "items": p.schema(*td.Elem, reg)}
case "map":
return map[string]any{"type": "object", "additionalProperties": p.schema(*td.Elem, reg)}
case "union":
if reg == nil || td.Name == "" {
out := map[string]any{}
p.fill(out, td, reg)
return out
}
return reg.ref(p.define(td, reg))
case "struct":
if reg == nil {
return map[string]any{"type": "object"}
@@ -388,8 +662,16 @@ func (p *projector) define(td *TypeDesc, reg *schemaRegistry) string {
return name
}
// fill is [structSchema] over a described struct.
// fill is [structSchema] over a described struct, and a union's oneOf.
func (p *projector) fill(into map[string]any, td *TypeDesc, reg *schemaRegistry) {
if td.Kind == "union" {
one := make([]any, len(td.OneOf))
for i, alt := range td.OneOf {
one[i] = p.schema(alt, reg)
}
into["oneOf"] = one
return
}
props := map[string]any{}
var required []string
for _, f := range td.Fields {
+6 -6
View File
@@ -170,18 +170,18 @@ func remoteApp(parent *App, prefix, addr string, d Declaration) (*App, error) {
// The rules it obeys are zip's own, read from the one place each lives: path
// parameters are substituted out of the arguments, and hasBody decides whether
// what remains rides as a body or as a query string.
func remoteInvoke(client Client, host, method, pattern string) func(context.Context, decoder, []byte, map[string]string, map[string]string, func(string) string) (any, error) {
return func(ctx context.Context, dec decoder, rawIn []byte, query, path map[string]string, header func(string) string) (any, error) {
func remoteInvoke(client Client, host, method, pattern string) func(context.Context, input) (any, error) {
return func(ctx context.Context, in input) (any, error) {
args := map[string]any{}
if len(rawIn) > 0 {
if err := dec(rawIn, &args); err != nil {
if len(in.body) > 0 {
if err := in.dec(in.body, &args); err != nil {
return nil, ErrBadRequest("invalid body: " + err.Error())
}
}
for k, v := range query {
for k, v := range in.query {
args[k] = v
}
for k, v := range path {
for k, v := range in.path {
args[k] = v
}
+77
View File
@@ -0,0 +1,77 @@
// 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 (
"context"
"encoding/json"
"testing"
"github.com/zap-proto/zip"
)
// Prose written once reaches every field it describes: a promoted field carries
// the comment its embedded struct wrote, and an instantiated generic is
// published under a name a schema may have while its prose stays filed under
// the name zipdoc wrote.
// Record is what every record carries.
type Record struct {
ID string `json:"id"`
}
type purchase struct {
Record
Total int64 `json:"total"`
}
type envelope[T any] struct {
Status string `json:"status"`
Data T `json:"data"`
}
func TestShapes_ProseReachesPromotedAndGenericFields(t *testing.T) {
app := zip.New(zip.Config{AppName: "shapes", DisableStartupMessage: true})
zip.Describe("GET /v1/orders/:id", zip.Doc{
Description: "Get returns one purchase.",
Fields: map[string]string{
"Record.id": "ID addresses the record.",
"purchase.total": "Total is in cents.",
"envelope[github.com/zap-proto/zip_test.purchase].status": "Status is ok or error.",
"envelope[github.com/zap-proto/zip_test.purchase].data": "Data is the purchase.",
"reportIn.id": "ID names the purchase.",
},
})
app.Get("/v1/orders/:id", func(_ context.Context, in *reportIn) (*envelope[purchase], error) {
return &envelope[purchase]{Status: "ok"}, nil
})
b, err := json.Marshal(app.OpenAPISpec())
if err != nil {
t.Fatal(err)
}
var doc struct {
Components struct {
Schemas map[string]struct {
Properties map[string]struct {
Description string `json:"description"`
} `json:"properties"`
} `json:"schemas"`
} `json:"components"`
}
if err := json.Unmarshal(b, &doc); err != nil {
t.Fatal(err)
}
env, ok := doc.Components.Schemas["envelopePurchase"]
if !ok {
t.Fatalf("no envelopePurchase in %s", b)
}
if env.Properties["status"].Description != "Status is ok or error." || env.Properties["data"].Description != "Data is the purchase." {
t.Errorf("envelopePurchase = %+v", env)
}
o := doc.Components.Schemas["purchase"]
if o.Properties["id"].Description != "ID addresses the record." || o.Properties["total"].Description != "Total is in cents." {
t.Errorf("purchase = %+v, want the promoted id described where Record declares it", o)
}
}
+161
View File
@@ -0,0 +1,161 @@
// 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"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/fasthttp/websocket"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/wsx"
)
// An op whose answer is a WebSocket validates, authorizes and binds its input
// like any other, and then the connection is upgraded and served. Its document
// shows the upgrade and the type of one message; it is no MCP tool, and
// tools/list says why.
type roomIn struct {
Room string `json:"room" url:"room" validate:"required"`
}
type line struct {
Text string `json:"text"`
}
func room(_ context.Context, in *roomIn) (*zip.Socket[line], error) {
return &zip.Socket[line]{Serve: func(conn *wsx.Conn) error {
for {
_, msg, err := conn.ReadMessage()
if err != nil {
return err
}
if err := conn.WriteMessage(websocket.TextMessage, []byte(in.Room+":"+strings.ToUpper(string(msg)))); err != nil {
return err
}
}
}}, nil
}
func socketApp() *zip.App {
app := zip.New(zip.Config{AppName: "chat", DisableStartupMessage: true})
app.Get("/v1/rooms", room)
return app
}
// serveHTTP starts app on a fresh port and answers its host:port once it
// accepts connections.
func serveHTTP(t *testing.T, app *zip.App) string {
t.Helper()
addr := freeAddr(t)
go func() { _ = app.Listen("http://" + addr) }()
t.Cleanup(func() { _ = app.Shutdown() })
for i := 0; i < 100; i++ {
if c, err := net.Dial("tcp", addr); err == nil {
_ = c.Close()
return addr
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("%s never accepted", addr)
return ""
}
func TestSocket_UpgradesAfterTheContract(t *testing.T) {
addr := serveHTTP(t, socketApp())
d := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
conn, resp, err := d.Dial("ws://"+addr+"/v1/rooms?room=r1", nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
if resp.StatusCode != http.StatusSwitchingProtocols {
t.Errorf("handshake = %d", resp.StatusCode)
}
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
if err := conn.WriteMessage(websocket.TextMessage, []byte("hi")); err != nil {
t.Fatal(err)
}
if _, got, err := conn.ReadMessage(); err != nil || string(got) != "r1:HI" {
t.Fatalf("got %q, %v", got, err)
}
// The input is validated before anything upgrades.
_, resp, err = d.Dial("ws://"+addr+"/v1/rooms", nil)
if err == nil || resp == nil || resp.StatusCode != 400 {
t.Errorf("an invalid request upgraded: %v %v", resp, err)
}
}
// A request that does not ask to upgrade is told how, as wsx tells it.
func TestSocket_APlainRequestIsTold426(t *testing.T) {
resp, err := socketApp().Test(httptest.NewRequest("GET", "/v1/rooms?room=r1", nil))
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 426 || resp.Header.Get("Upgrade") != "websocket" || !strings.Contains(string(body), "WebSocket") {
t.Errorf("%d %q %s", resp.StatusCode, resp.Header.Get("Upgrade"), body)
}
}
func TestSocket_IsPublishedAsAnUpgrade(t *testing.T) {
app := socketApp()
op := opIn(t, app, "/v1/rooms", "get")
if op["x-socket"] != "websocket" {
t.Errorf("x-socket = %v", op["x-socket"])
}
up, ok := op["responses"].(map[string]any)["101"].(map[string]any)
if !ok {
t.Fatalf("responses = %v, want 101", op["responses"])
}
if ref, _ := up["x-events"].(map[string]any)["$ref"].(string); !strings.HasSuffix(ref, "/line") {
t.Errorf("101 = %v, want the message type", up)
}
built(t, app)
for _, tool := range app.MCPTools() {
t.Errorf("a socket is a tool: %v", tool["name"])
}
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
var env struct {
Result struct {
Meta struct {
Refused map[string]string `json:"refused"`
} `json:"_meta"`
} `json:"result"`
}
_ = json.NewDecoder(resp.Body).Decode(&env)
if got := env.Result.Meta.Refused[zip.ID("GET", "/v1/rooms")]; got != zip.NotACall {
t.Errorf("tools/list _meta.refused = %v", env.Result.Meta.Refused)
}
}
// A socket op may only be a GET, and its upgrade is the whole answer.
func TestSocket_RefusesWhatCannotUpgrade(t *testing.T) {
defer func() {
if recover() == nil {
t.Error("a POST that upgrades registered")
}
}()
zip.New(zip.Config{DisableStartupMessage: true}).Post("/v1/rooms", room)
}
+63
View File
@@ -2,6 +2,7 @@ package zip_test
import (
"bufio"
"context"
"strings"
"testing"
"time"
@@ -116,3 +117,65 @@ func readOneEvent(br *bufio.Reader) (string, error) {
type streamTimeout struct{}
func (*streamTimeout) Error() string { return "timed out waiting for streamed event over ZAP" }
// A typed op's event stream crosses ZAP the way an untyped one does: as
// zap-proto/http's head, data and end frames, each event as it is sent. A
// typed op that upgrades is answered 501 over ZAP, which carries no upgrade.
func TestListenZAP_TypedStream(t *testing.T) {
release := make(chan struct{})
app := zip.New(zip.Config{AppName: "typed-stream", DisableStartupMessage: true})
app.Get("/v1/events", func(context.Context, *struct{}) (*zip.Sse[piece], error) {
return &zip.Sse[piece]{Send: func(emit func(zip.Event[piece]) error) error {
for _, w := range []string{"a", "b"} {
<-release
if err := emit(zip.Event[piece]{Data: piece{Text: w}}); err != nil {
return err
}
}
return nil
}}, nil
})
app.Get("/v1/rooms", room)
addr := freeAddr(t)
go func() { _ = app.Listen(addr) }() // bare addr = ZAP
defer func() { _ = app.Shutdown() }()
tr := http.Dial("tcp", addr)
defer tr.CloseIdleConnections()
do := func(path string) *fasthttp.Response {
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
resp := fasthttp.AcquireResponse()
req.SetRequestURI(path)
req.Header.SetMethod("GET")
var err error
for i := 0; i < 50; i++ {
if err = tr.Do(req, resp); err == nil {
return resp
}
time.Sleep(40 * time.Millisecond)
}
t.Fatalf("GET %s over ZAP: %v", path, err)
return nil
}
resp := do("/v1/events")
defer fasthttp.ReleaseResponse(resp)
if !resp.IsBodyStream() || string(resp.Header.ContentType()) != "text/event-stream" {
t.Fatalf("not a stream: %q", resp.Header.ContentType())
}
br := bufio.NewReader(resp.BodyStream())
for _, w := range []string{"a", "b"} {
release <- struct{}{}
got, err := readOneEvent(br)
if err != nil || got != "data: {\"text\":\""+w+"\"}\n\n" {
t.Fatalf("event %s = %q, %v", w, got, err)
}
}
up := do("/v1/rooms?room=r1")
defer fasthttp.ReleaseResponse(up)
if up.StatusCode() != 501 {
t.Errorf("an upgrade over ZAP answered %d, want 501", up.StatusCode())
}
}
+1 -1
View File
@@ -76,7 +76,7 @@ func TestProjectOpenAPIStreams(t *testing.T) {
func TestCheckRefusesACrookedStream(t *testing.T) {
for name, op := range map[string]zip.ManifestOp{
"unknown stream": {Method: "GET", Path: "/a", Stream: "ws"},
"stream and out": {Method: "GET", Path: "/a", Stream: "sse", Out: "Prompt"},
"socket and out": {Method: "GET", Path: "/a", Stream: "socket", Out: "Prompt"},
"raw and in": {Method: "POST", Path: "/a", Raw: true, In: "Prompt"},
} {
m := streamed()
+2 -2
View File
@@ -23,7 +23,7 @@ func TestToolNames_RideTheOperationIDRule(t *testing.T) {
t.Fatalf("build: %v", err)
}
raw, names := host.composeTools()
raw, names, _ := host.composeTools()
_ = raw
var got []string
for n := range names {
@@ -90,7 +90,7 @@ func TestToolNames_AreNotPositional(t *testing.T) {
if err := host.Build(); err != nil {
t.Fatalf("build: %v", err)
}
_, set := host.composeTools()
_, set, _ := host.composeTools()
var out []string
for n := range set {
out = append(out, n)
+84 -61
View File
@@ -1,7 +1,6 @@
package zip
import (
"cmp"
"context"
"encoding"
"fmt"
@@ -56,6 +55,15 @@ type registeredOp struct {
Tags []string
InType reflect.Type
OutType reflect.Type
// Consumes and Produces are the media the op's request body and answer are
// declared in when they are not application/json. See [Consumes] and
// [Produces].
Consumes []string
Produces []string
// req is how the input arrives and ans what the answer may be, each read
// once from In and Out at registration.
req intake
ans answer
// Pkg is the import path of the package that REGISTERED this op. It
// namespaces the op's entry in the process-wide documentation map, where an
// address alone is not unique: two chains in one node both answer
@@ -78,7 +86,7 @@ type registeredOp struct {
// and for the same reason as rule: composition settles it at build, and the
// op was registered before that.
result func() func(context.Context, Op, error)
invoke func(ctx context.Context, dec decoder, rawIn []byte, query, path map[string]string, header func(string) string) (any, error)
invoke func(ctx context.Context, in input) (any, error)
// direct is invoke with the decoding removed: the In arrives as the *In the
// caller already holds. It exists for the one transport that is not a
// transport — a call whose two ends are the same process (see [Here]) —
@@ -180,15 +188,20 @@ func WithStatus(codes ...int) OpOption {
type StatusCoder interface{ StatusCode() int }
// statusOf is the code an answer carries: what the value states if it states
// anything, else the op's first declared status, else the default.
func statusOf(op *registeredOp, out any) (int, error) {
if sc, ok := out.(StatusCoder); ok {
// anything, else the op's first declared status, else the default. A value that
// states zero states nothing. relayed says the answer is an upstream's, whose
// 4xx and 5xx pass through as the upstream's refusal ([Verbatim]).
func statusOf(op *registeredOp, out any, relayed bool) (int, error) {
if sc, ok := out.(StatusCoder); ok && sc.StatusCode() != 0 {
got := sc.StatusCode()
for _, declared := range op.Statuses {
if declared == got {
return got, nil
}
}
if relayed && got >= 400 && got <= 599 {
return got, nil
}
// An ERROR, not a panic: this is a runtime value, so it cannot be caught
// at boot, and a panic on a serving goroutine is a worse answer than a
// refusal the error chain renders. The document publishes exactly the
@@ -198,6 +211,13 @@ func statusOf(op *registeredOp, out any) (int, error) {
"declare it with zip.WithStatus(%d) so the document, the SDKs and the CLI publish it",
op.Method, op.Path, got, got))
}
if _, redirect := out.(*Redirect); redirect {
for _, code := range op.Statuses {
if code >= 300 && code < 400 {
return code, nil
}
}
}
if len(op.Statuses) > 0 {
return op.Statuses[0], nil
}
@@ -607,6 +627,19 @@ var mainPath = sync.OnceValue(func() string {
return "main"
})
// settle completes the op's declaration from what its In and Out say — the
// media it consumes, the statuses and headers its answer states — and refuses
// one the wire cannot carry. It runs after every set of options, at
// registration and again after [Operation.With], so a declaration made either
// way is checked the same.
func (op *registeredOp) settle() {
if len(op.Consumes) > 0 && !hasBody(op.Method) {
panic(fmt.Sprintf("zip: %s %s carries no request body, so it consumes nothing", op.Method, op.Path))
}
op.Consumes = consumes(op)
op.ans.settle(op)
}
// registerTyped declares one typed op. depth is how many frames sit between it
// and the line the programmer wrote, NOT counting the public door itself: zero
// for [Get] and its four siblings, one for a scope's verb method, which reaches
@@ -633,6 +666,9 @@ func registerTyped[In, Out any](depth int, on *App, method, path string, fn Type
for _, o := range opts {
o(op)
}
op.req = intakeOf(method, op.InType)
op.ans = answerOf(op.OutType)
op.settle()
op.readsHeaders = len(headerFields(op.InType)) > 0
op.rule = app.rule
op.result = app.result
@@ -736,33 +772,38 @@ func registerTyped[In, Out any](depth int, on *App, method, path string, fn Type
return run(ctx, v)
}
// The transport-agnostic core: decode raw JSON args → In, bind the URL, then
// the contract above. REST and MCP both call THIS — one handler, many
// The transport-agnostic core: decode the input the way the op takes it,
// bind the request's declared facts and the URL, then the contract above.
// REST, MCP, the CLI and the call plane all call THIS — one handler, many
// projections.
op.invoke = func(ctx context.Context, dec decoder, rawIn []byte, query, path map[string]string, header func(string) string) (any, error) {
op.invoke = func(ctx context.Context, wire input) (any, error) {
var in In
if len(rawIn) > 0 {
if err := dec(rawIn, &in); err != nil {
return nil, ErrBadRequest("invalid body: " + err.Error())
}
if err := op.req.decode(&in, wire, op.Consumes); err != nil {
return nil, err
}
// The three sources bind in increasing authority: body, then query, then
// path. Query beats the body because it is part of the URL; path beats
// query because it is the part the router MATCHED on.
if !wire.sent {
op.req.defaultBodyType(&in, op.Consumes)
}
// The sources bind in increasing authority: body, then headers and
// cookies, then query, then path. Query beats the body because it is
// part of the URL; path beats query because it is the part the router
// MATCHED on.
//
// The URL is the addressing authority: PATCH /users/acme/bob updates
// acme/bob whatever the body claims. This is also what keeps the authorizer
// honest — it runs below on this same decoded value, so the target
// authorized is the target the URL named, and a body cannot smuggle a
// different one past it.
// Headers first, then the URL: the URL is the addressing authority and
// must win, exactly as it does over the body. Skipped entirely unless
// the input declares one, so an op that reads no header pays nothing.
// acme/bob whatever the body claims. This is also what keeps the
// authorizer honest — it runs below on this same decoded value, so the
// target authorized is the target the URL named, and a body cannot
// smuggle a different one past it. Header and cookie binding is skipped
// entirely unless the input declares one, so an op that reads none
// pays nothing.
if op.readsHeaders {
bindHeaders(&in, header)
bindHeaders(&in, wire.header)
}
bindURL(&in, query)
bindURL(&in, path)
if op.req.cookies {
bindCookies(&in, wire.cookie)
}
bindURL(&in, wire.query)
bindURL(&in, wire.path)
return run(ctx, &in)
}
handler := func(c fiber.Ctx) error {
@@ -770,15 +811,18 @@ func registerTyped[In, Out any](depth int, on *App, method, path string, fn Type
// by the document and the CLI's remote invoker. Reading the body for a
// method the document says has none is how a DELETE came to accept an
// input no generated client would ever send.
var body []byte
wire := input{dec: jsonenc.Unmarshal, sent: true}
if hasBody(method) {
body = c.Body()
wire.body = c.Body()
wire.media = c.Get(fiber.HeaderContentType)
if op.req.form {
wire.form = func() (formData, error) { return readForm(c, wire.media) }
}
}
var path map[string]string
if names := c.Route().Params; len(names) > 0 {
path = make(map[string]string, len(names))
wire.path = make(map[string]string, len(names))
for _, n := range names {
path[n] = segment(c.Params(n))
wire.path[n] = segment(c.Params(n))
}
}
// The query string is the OTHER half of the URL. Without it a typed GET
@@ -786,12 +830,15 @@ func registerTyped[In, Out any](depth int, on *App, method, path string, fn Type
// API — so every route that carries `?q=` had to stay an untyped handler,
// invisible to OpenAPI and MCP. Reading it here is what makes those routes
// expressible as ops.
// The header reader is built only for an op that declares one: the
// closure captures c, so it is per-request, and almost every op reads no
// header at all.
var header func(string) string
wire.query = c.Queries()
// The header and cookie readers are built only for an op that declares
// one: each closure captures c, so it is per-request, and almost every op
// reads neither.
if op.readsHeaders {
header = func(k string) string { return c.Get(k) }
wire.header = func(k string) string { return c.Get(k) }
}
if op.req.cookies {
wire.cookie = func(k string) string { return c.Cookies(k) }
}
// THE MATCHED ROUTE IS THE ADDRESS. materialise mounts each occurrence at
// its absolute path, so the pattern fiber matched is where this op is
@@ -805,7 +852,7 @@ func registerTyped[In, Out any](depth int, on *App, method, path string, fn Type
if served.OperationID == "" {
served.OperationID = ID(meta.Method, served.Path)
}
out, err := op.invoke(withOp(callerContext(c), served), jsonenc.Unmarshal, body, c.Queries(), path, header)
out, err := op.invoke(withOp(callerContext(c), served), wire)
if err != nil {
return err
}
@@ -817,31 +864,7 @@ func registerTyped[In, Out any](depth int, on *App, method, path string, fn Type
if a, ok := out.(*Approval); ok {
return c.Status(202).JSON(a)
}
if out == nil {
// A void op answers with the status it DECLARED, else the 204 a nil
// Out has always meant.
code, serr := statusOf(op, nil)
if serr != nil {
return serr
}
c.Status(cmp.Or(code, 204))
return nil
}
hdrs, herr := responseHeadersOf(op, out)
if herr != nil {
return herr
}
for name, v := range hdrs {
c.Set(name, v)
}
code, serr := statusOf(op, out)
if serr != nil {
return serr
}
if code != 0 {
c.Status(code)
}
return c.JSON(out)
return writeAnswer(c, op, out)
}
// With() middleware composes around the op only when there IS any: wrapping
// unconditionally would materialise a *Ctx on every typed request to hand to
+1 -1
View File
@@ -29,7 +29,7 @@ func TestAnUndeclaredTypedOpIsNotAnMCPTool(t *testing.T) {
return &hiddenOut{OK: true}, nil
})
tools, _ := app.composeTools()
tools, _, _ := app.composeTools()
var got []struct {
Name string `json:"name"`
}
+12 -27
View File
@@ -8,6 +8,10 @@
// _ = c.WriteMessage(wsx.TextMessage, msg)
// }
// }))
//
// A typed op that answers a WebSocket returns a [zip.Socket] instead; both run
// the same upgrade (internal/ws), so a connection is accepted and refused the
// same way at either kind of route.
package wsx
import (
@@ -15,10 +19,11 @@ import (
"github.com/valyala/fasthttp"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/internal/ws"
)
// Conn is the WebSocket connection passed to wsx handlers.
type Conn = websocket.Conn
type Conn = ws.Conn
// Message-type constants re-exported for convenience.
const (
@@ -32,16 +37,9 @@ const (
// Handler is the wsx handler signature.
type Handler func(c *Conn) error
// Config configures the WebSocket upgrade.
type Config struct {
ReadBufferSize int
WriteBufferSize int
Subprotocols []string
EnableCompression bool
// CheckOrigin defaults to allow-all (zip is multi-tenant; gate via
// CORS or auth middleware at the route level instead).
CheckOrigin func(ctx *fasthttp.RequestCtx) bool
}
// Config configures the WebSocket upgrade. It is the type [zip.Socket]'s
// Config field holds.
type Config = ws.Config
// Upgrade returns a zip.Handler that upgrades the HTTP connection to a
// WebSocket and calls fn with the established *Conn.
@@ -50,17 +48,6 @@ func Upgrade(fn Handler, opts ...Config) zip.Handler {
if len(opts) > 0 {
cfg = opts[0]
}
up := &websocket.FastHTTPUpgrader{
ReadBufferSize: cfg.ReadBufferSize,
WriteBufferSize: cfg.WriteBufferSize,
Subprotocols: cfg.Subprotocols,
EnableCompression: cfg.EnableCompression,
}
if cfg.CheckOrigin != nil {
up.CheckOrigin = cfg.CheckOrigin
} else {
up.CheckOrigin = func(ctx *fasthttp.RequestCtx) bool { return true }
}
// TERMINAL: the upgrade takes over the connection, so this answers the
// address it is registered at and never yields to anything after it.
// zip.Terminal is what makes app.Use(wsx.Upgrade(…)) a build error instead
@@ -72,12 +59,10 @@ func Upgrade(fn Handler, opts ...Config) zip.Handler {
// (RFC 9110 §15.5.22) with the protocol to switch to, as a client error,
// rather than handed to the upgrader, whose handshake error is reported
// as a server fault.
if !websocket.FastHTTPIsWebSocketUpgrade(rc) {
if !ws.Is(rc) {
c.SetHeader("Upgrade", "websocket")
return zip.Errorf(fasthttp.StatusUpgradeRequired, "this address speaks WebSocket: connect with an Upgrade: websocket request")
return zip.Errorf(fasthttp.StatusUpgradeRequired, ws.Refusal)
}
return up.Upgrade(rc, func(ws *Conn) {
_ = fn(ws)
})
return ws.Upgrade(rc, cfg, func(conn *Conn) { _ = fn(conn) })
})
}
+1 -1
View File
@@ -316,7 +316,7 @@ func (a *App) schemaOp(name string, in, out reflect.Type) {
unimplemented := func() error {
return Errorf(501, "%s is declared by a schema; no implementation of it is linked into this process", name)
}
op.invoke = func(context.Context, decoder, []byte, map[string]string, map[string]string, func(string) string) (any, error) {
op.invoke = func(context.Context, input) (any, error) {
return nil, unimplemented()
}
op.direct = func(context.Context, any) (any, error) { return nil, unimplemented() }
+1 -1
View File
@@ -309,7 +309,7 @@ func TestReadZAP_ADeclaredOpDoesNotRun(t *testing.T) {
} else if !strings.Contains(err.Error(), "no implementation") {
t.Errorf("refusal does not say why: %v", err)
}
if _, err := op.invoke(context.Background(), nil, nil, nil, nil, nil); err == nil {
if _, err := op.invoke(context.Background(), input{}); err == nil {
t.Error("a declared op ran over a transport")
}
}
+5
View File
@@ -40,6 +40,8 @@ import (
"sort"
"strconv"
"strings"
"github.com/zap-proto/zip/internal/zapenc"
)
// ZAPSchema renders the ZAP IDL that apps' typed ops describe, under pkg.
@@ -458,6 +460,9 @@ func (e *emitter) opacity(t reflect.Type, decl string, s Slot) {
func (e *emitter) dropped(t reflect.Type, decl string) {
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if zapenc.Skipped(f) {
continue // it says it does not cross: not a loss
}
if !f.IsExported() {
// An unexported field crosses nowhere and is nobody's contract —
// EXCEPT an embedded type, whose field name is its type name and so is
+5
View File
@@ -330,6 +330,11 @@ type App struct {
// without re-parsing the array on every request.
mcpNames atomic.Pointer[map[string]bool]
// mcpRefused names the ops that are not tools, and why — rendered with
// mcpList and answered in tools/list's _meta, so a client reading the list
// sees every op accounted for.
mcpRefused atomic.Pointer[map[string]string]
// The ZAP-native MCP listener, when Config.MCP.Addr named one. It is a
// zapmcp.Server over App.MCP — the door served on the wire it was designed
// for, with no HTTP anywhere in the path. Guarded by mcpMu so a second Listen