Answer a redirect or page with cookies, a bare 304, a safe filename

A type that embeds Redirect or Body is that answer, so a sign-in leg
answers 302 with two Set-Cookie lines and no body, and a confirmation
page answers its HTML with a cookie, through CookieCoder on the
embedding type. Verbatim reads its Body the same way instead of by
reflection.

An answer with no bytes and no Type carries no Content-Type, so a 304
is a Body with Status 304 and nothing else, as a handler that wrote
nothing never sent one. A filename has its quote and backslash escaped
and its control characters dropped, so it cannot end the header.

Every field of Command, Arg and Flag carries its own comment, so the
command projection's shape is described where a document publishes it.
The registry's body flags keep field order.
This commit is contained in:
2026-10-09 09:42:00 -07:00
parent 999e2153b3
commit 3fff93e62d
8 changed files with 213 additions and 54 deletions
+44 -28
View File
@@ -6,7 +6,6 @@ package zip
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
@@ -284,6 +283,10 @@ const NotACall = "a connection is not a call"
// 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.
//
// A type that embeds Redirect is a redirect too, so one that also sets cookies
// is a struct embedding it with a Cookies method ([CookieCoder]): the sign-in
// leg that clears one cookie and sets another answers both.
type Redirect struct {
// To is the location the client is sent to.
To string `json:"to"`
@@ -294,7 +297,11 @@ type Redirect struct {
// 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{})
// redirect is the Redirect an answer is, promoted to a type that embeds one.
func (r *Redirect) redirect() *Redirect { return r }
// redirector is an answer that is a [Redirect], or embeds one.
type redirector interface{ redirect() *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
@@ -380,12 +387,10 @@ func (a *answer) add(t reflect.Type, top bool) {
case upgrader:
a.setStream(streamSocket, v.(interface{ frame() reflect.Type }).frame())
return
}
switch {
case t == bodyType:
case answerBody:
a.setStream(streamBytes, nil)
return
case t == redirectType:
case redirector:
a.redirect = true
return
}
@@ -500,18 +505,12 @@ func containsInt(list []int, v int) bool {
return false
}
// bodyOf is the [Body] an answer carries, when it carries one: a Body or a
// Verbatim.
// bodyOf is the [Body] an answer carries, when it carries one: a Body, a
// Verbatim, or any type that embeds a Body.
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
}
if b, ok := out.(answerBody); ok {
if v := reflect.ValueOf(out); v.Kind() == reflect.Pointer && !v.IsNil() {
return b.body(), true
}
}
return nil, false
@@ -566,8 +565,8 @@ func writeAnswer(c fiber.Ctx, op *registeredOp, out any) error {
c.Set(name, v)
}
switch x := out.(type) {
case *Redirect:
c.Set("Location", x.To)
case redirector:
c.Set("Location", x.redirect().To)
c.Status(nonZero(code, http.StatusFound))
return nil
case stream:
@@ -581,13 +580,17 @@ func writeAnswer(c fiber.Ctx, op *registeredOp, out any) error {
c.Status(code)
}
if isBody {
// No bytes and no stated type is no Content-Type: a 304 or an empty
// answer carries none, as a handler that wrote nothing never did.
media := b.Type
if media == "" {
if media == "" && (len(b.Bytes) > 0 || b.Reader != nil) {
media = produces(op, mimeOctet)
}
c.Set(fiber.HeaderContentType, media)
if media != "" {
c.Set(fiber.HeaderContentType, media)
}
if b.Name != "" {
c.Set(fiber.HeaderContentDisposition, `attachment; filename="`+strings.ReplaceAll(b.Name, `"`, `\"`)+`"`)
c.Set(fiber.HeaderContentDisposition, disposition(b.Name))
}
if b.Reader != nil {
r := b.Reader
@@ -598,6 +601,25 @@ func writeAnswer(c fiber.Ctx, op *registeredOp, out any) error {
return c.JSON(out, produces(op, mimeJSON))
}
// disposition is the Content-Disposition that saves an answer as name: the
// name quoted, its quote and backslash escaped, and any control character —
// which could end the header and start another — dropped.
func disposition(name string) string {
var b strings.Builder
b.WriteString(`attachment; filename="`)
for _, r := range name {
switch {
case r < 0x20 || r == 0x7f:
continue
case r == '"' || r == '\\':
b.WriteByte('\\')
}
b.WriteRune(r)
}
b.WriteByte('"')
return b.String()
}
// 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) {
@@ -720,9 +742,3 @@ func toolContent(id string, out any) (map[string]any, error) {
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
}
+67
View File
@@ -177,3 +177,70 @@ func TestAnswer_JSONWireIsUnchanged(t *testing.T) {
t.Errorf("%q %s", resp.Header.Get("Content-Type"), b)
}
}
// signIn is the callback leg of a browser sign-in: it sends the browser on and,
// on the same answer, clears the one-use flow cookie and sets the session.
type signIn struct {
zip.Redirect
}
func (signIn) Cookies() []*http.Cookie {
return []*http.Cookie{
{Name: "flow", Value: "", Path: "/", MaxAge: -1},
{Name: "sid", Value: "s1", Path: "/", HttpOnly: true},
}
}
// confirmed is a page that also sets a cookie.
type confirmed struct {
zip.Body
}
func (confirmed) Cookies() []*http.Cookie {
return []*http.Cookie{{Name: "__Host-link", Value: "l1", Path: "/", Secure: true}}
}
// A redirect or a page that sets cookies is a type embedding Redirect or Body
// with a Cookies method: the redirect keeps its empty body, the page its
// bytes, and each Set-Cookie goes out on its own line.
func TestCookies_OnARedirectAndAPage(t *testing.T) {
app := zip.New(zip.Config{AppName: "s", DisableStartupMessage: true})
app.Get("/v1/callback", func(context.Context, *struct{}) (*signIn, error) {
return &signIn{zip.Redirect{To: "/home"}}, nil
})
app.Get("/v1/linked", func(context.Context, *struct{}) (*confirmed, error) {
return &confirmed{zip.Body{Type: "text/html; charset=utf-8", Bytes: []byte("<p>linked</p>")}}, nil
}, zip.Produces("text/html"))
resp, err := app.Test(httptest.NewRequest("GET", "/v1/callback", nil))
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
got := resp.Header.Values("Set-Cookie")
if resp.StatusCode != 302 || resp.Header.Get("Location") != "/home" || len(b) != 0 || len(got) != 2 {
t.Errorf("callback: %d %q %q %q", resp.StatusCode, resp.Header.Get("Location"), b, got)
}
if _, ok := resp.Header["Content-Type"]; ok {
t.Errorf("callback carries Content-Type %q", resp.Header.Get("Content-Type"))
}
r302 := opIn(t, app, "/v1/callback", "get")["responses"].(map[string]any)["302"].(map[string]any)
h := r302["headers"].(map[string]any)
if _, ok := h["Set-Cookie"]; !ok || h["Location"] == nil {
t.Errorf("302 headers = %v, want Location and Set-Cookie", h)
}
resp, err = app.Test(httptest.NewRequest("GET", "/v1/linked", nil))
if err != nil {
t.Fatal(err)
}
b, _ = io.ReadAll(resp.Body)
if resp.StatusCode != 200 || string(b) != "<p>linked</p>" || resp.Header.Get("Content-Type") != "text/html; charset=utf-8" ||
resp.Header.Get("Set-Cookie") != "__Host-link=l1; Path=/; Secure" {
t.Errorf("linked: %d %q %q %q", resp.StatusCode, b, resp.Header.Get("Content-Type"), resp.Header.Get("Set-Cookie"))
}
content := opIn(t, app, "/v1/linked", "get")["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)
if m, ok := content["text/html"].(map[string]any); !ok || m["schema"].(map[string]any)["format"] != "binary" {
t.Errorf("linked content = %v, want binary text/html", content)
}
}
+10 -1
View File
@@ -43,7 +43,9 @@ import (
// 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.
// ([WithStatus], [WithResponseHeader]), as for any answer. An answer with no
// bytes and no Type carries no Content-Type, so a 304 is a Body whose Status is
// 304 and nothing else.
//
// 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
@@ -70,6 +72,13 @@ type Body struct {
// StatusCode is the status the answer states; zero is the op's own.
func (b Body) StatusCode() int { return b.Status }
// body is the Body an answer is, promoted to a type that embeds one: a
// [Verbatim], or a page that also sets a cookie.
func (b *Body) body() *Body { return b }
// answerBody is an answer that is a [Body], or embeds one.
type answerBody interface{ body() *Body }
// drain reads Reader into Bytes, for a transport whose answer is one message.
func (b *Body) drain() error {
r := b.Reader
+59
View File
@@ -314,3 +314,62 @@ func opIn(t *testing.T, app *zip.App, path, method string) map[string]any {
}
return op
}
// tagIn asks for an asset the caller may already hold.
type tagIn struct {
Match string `json:"match" header:"If-None-Match"`
}
// An asset answers its bytes under a tag, and a caller holding that tag gets
// 304 with no body and no Content-Type, as a handler that wrote nothing did.
func TestBody_NotModifiedCarriesNoType(t *testing.T) {
app := zip.New(zip.Config{AppName: "tag", DisableStartupMessage: true})
app.Get("/v1/tag.js", func(_ context.Context, in *tagIn) (*zip.Body, error) {
h := map[string]string{"ETag": `"v1"`}
if in.Match == `"v1"` {
return &zip.Body{Status: 304, Header: h}, nil
}
return &zip.Body{Type: "application/javascript", Bytes: []byte("1"), Header: h}, nil
}, zip.WithStatus(200, 304), zip.WithResponseHeader("ETag"))
req := httptest.NewRequest("GET", "/v1/tag.js", nil)
req.Header.Set("If-None-Match", `"v1"`)
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 304 || len(b) != 0 || resp.Header.Get("ETag") != `"v1"` {
t.Errorf("answered %d %q etag %q", resp.StatusCode, b, resp.Header.Get("ETag"))
}
if ct, ok := resp.Header["Content-Type"]; ok {
t.Errorf("304 carries Content-Type %q", ct)
}
resp, err = app.Test(httptest.NewRequest("GET", "/v1/tag.js", nil))
if err != nil {
t.Fatal(err)
}
b, _ = io.ReadAll(resp.Body)
if resp.StatusCode != 200 || string(b) != "1" || resp.Header.Get("Content-Type") != "application/javascript" {
t.Errorf("answered %d %q as %q", resp.StatusCode, b, resp.Header.Get("Content-Type"))
}
}
// A filename is the handler's to choose and often a caller's to supply, so
// one that carries a line break cannot end the header and start another.
func TestBody_FilenameCannotSplitTheHeader(t *testing.T) {
app := zip.New(zip.Config{AppName: "dl", DisableStartupMessage: true})
app.Get("/v1/dl", func(context.Context, *struct{}) (*zip.Body, error) {
return &zip.Body{Type: "text/plain", Bytes: []byte("x"), Name: "a\r\nSet-Cookie: s=1\\\".txt"}, nil
})
resp, err := app.Test(httptest.NewRequest("GET", "/v1/dl", nil))
if err != nil {
t.Fatal(err)
}
if got := resp.Header.Get("Content-Disposition"); got != `attachment; filename="aSet-Cookie: s=1\\\".txt"` {
t.Errorf("Content-Disposition = %q", got)
}
if c := resp.Header.Get("Set-Cookie"); c != "" {
t.Errorf("the filename set a cookie: %q", c)
}
}
+27 -16
View File
@@ -63,10 +63,10 @@ import (
// the argument parser read. It is the CLI's whole surface — a derivation fills
// it in, the runner consumes it, and neither knows how the other got there.
type Command struct {
// Service is the first non-version segment of the path ("billing"), and
// Name is the operation token under it ("invoices-list").
// Service is the first non-version segment of the path ("billing").
Service string
Name string
// Name is the operation token under the service ("invoices-list").
Name string
// OperationID is the op's identity — the SAME token the OpenAPI document's
// operationId, the MCP tool's name and zip.Call all address it by. A command
@@ -74,15 +74,17 @@ type Command struct {
// is what says WHICH op it projects.
OperationID string
// Summary is the one-line help; Description is the full prose. Both come
// from the handler's doc comment when cmd/zipdoc has run.
Summary string
// Summary is the one-line help, from the handler's doc comment when
// cmd/zipdoc has run.
Summary string
// Description is the full prose, from the same doc comment.
Description string
// Method and Path are the operation's identity — the route pattern in
// fiber's ":name" form, whatever the derivation read it from.
// Method is the operation's HTTP method.
Method string
Path string
// Path is the operation's route pattern in fiber's ":name" form, whatever
// the derivation read it from.
Path string
// Args are the path parameters, in path order, as positional arguments. The
// URL is the addressing authority (see bindPath), so what addresses the
@@ -115,16 +117,25 @@ type Command struct {
// Arg is one path parameter as a positional argument.
type Arg struct {
Name string // the param name, "app"
// Name is the path parameter's name, "app".
Name string
// Help is the parameter's prose, from the field it binds.
Help string
}
// 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 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
// Name is the flag, kebab-cased and without the dashes: "organization-id".
Name string
// Field is the name it is sent under: the JSON field, the header, the
// cookie or the form key.
Field string
// Type is the kind of value it takes: string, integer, number, boolean,
// json or file.
Type string
// Help is the flag's prose, from the field it sets.
Help string
// Required says the command refuses to run without it.
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
@@ -1179,8 +1190,8 @@ func writeResult(ctx context.Context, w io.Writer, in io.Reader, res any) error
return nil
}
switch x := res.(type) {
case *Redirect:
_, err := fmt.Fprintln(w, x.To)
case redirector:
_, err := fmt.Fprintln(w, x.redirect().To)
return err
case *ws.Conn:
return bridge(ctx, x, in, w)
+1 -1
View File
@@ -42,8 +42,8 @@ func kindsApp() *zip.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"`
Org string `json:"org" header:"X-Org-Id"`
}
// meIn reads a session cookie.
+4 -7
View File
@@ -491,8 +491,9 @@ func ProjectCLI(m Manifest) []Command {
// 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.
// then the query or the JSON body, then the form. The JSON body's fields come
// in field order, which a schema's properties cannot carry, so a document
// lists them by name.
func (p *projector) bind(op ManifestOp) ([]Arg, []Flag) {
params := pathParams(op.Path)
td := p.types[op.In]
@@ -533,14 +534,10 @@ func (p *projector) bind(op ManifestOp) ([]Arg, []Flag) {
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)
// lists it: a declared header or cookie rides the body too.
for _, f := range td.Fields {
add(f.JSON, urlName(f), p.flagType(f.Type), p.doc(op.In, f.JSON), 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) {
+1 -1
View File
@@ -211,7 +211,7 @@ func statusOf(op *registeredOp, out any, relayed bool) (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 {
if _, redirect := out.(redirector); redirect {
for _, code := range op.Statuses {
if code >= 300 && code < 400 {
return code, nil