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