mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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] {
|
||||
|
||||
@@ -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, ¶ms)
|
||||
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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user