mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
Take a body's content or refuse it; keep the graph to plain ops
An op that takes the body itself (a Body field, a form, a Parser) read it through fiber's Body, which answers a Content-Encoding it cannot undo with the error's text in place of the bytes, so the handler ran on "gzip: invalid header". It now reads the content: each coding undone and held to BodyLimit, 415 for a coding the server does not undo, 413 past the limit and 400 for one that does not decode, all before the handler runs. A form is read from that content, so a gzip form binds its fields and a multipart form is parsed in memory from a body the limit already bounds; a small request can no longer decode into a part of any size. A JSON op reads fiber's Body as before. An answer refused after its handler ran (an undeclared status or header) closes its Reader, so a relay no longer leaks the upstream body. The graph carries only plain ops, one JSON value in and one out: bytes, forms, Parsers, streams, sockets, redirects, relays and unions are no field, so none of their handlers runs there without the checks its own door makes. A cookie: field is never a graph argument, and Here refuses a socket op before its handler, as the call plane and MCP do.
This commit is contained in:
@@ -73,7 +73,7 @@ Patch tags, one above the highest `v*` on GitHub. Push the commit to main, read
|
||||
- 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"`, `"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 a form: `*zip.File` parts make multipart, and `form:` fields bind only when the op `Consumes` a form media type (otherwise the tags are another binder's and JSON binds as before). A `cookie:` field is an `in: cookie` parameter set by the request's cookie and nothing else: the body, an argument object and the URL never set it, and no schema lists it as a body field. An answer that is not one JSON value is a `Body` (bytes, filename, its own status and headers; a `Reader` streams chunked; no bytes and no Type sends no Content-Type), `Verbatim[T]` (a relay's bytes documented as T; the upstream's 4xx/5xx pass undeclared), `Sse[T]` (`Send(ctx, emit)` on its own goroutine; ctx ends with the stream), `Socket[M]` (GET only; the handshake is decided before the handler runs: 501 over ZAP, 426 without an upgrade or for another version, 400 without a key, 403 for a browser origin the op does not admit — its own, plus `zip.Origins(...)`, `"*"` for any), `Redirect` or a `CookieCoder`; a type embedding `Body` or `Redirect` is that answer, so one with a `Cookies` method sets them beside it. A JSON answer keeps fiber's `application/json; charset=utf-8` unless the op `Produces` another. Every declared status carries the Out's schema, except one a union's alternative claims with its value-receiver `StatusCode()`. A union is a type whose `OneOf()` result types are its alternatives; `Or[A,B]` is the stock one. Over MCP bytes are base64 (in) and tool content (out); bytes, a Reader and a stream (as JSON lines) are bounded at `toolBound` and a Reader or stream at `toolWait`, and a socket op is no tool (`_meta.refused`). 204, 304 and a redirecting op's 3xx carry no body in the document; any other declared status carries the Out and its example. The call plane writes an `Sse` as the REST door does, refuses a `Socket` before its handler, and carries a union that holds a value as the union. A header field is a CLI flag under its field's name, as before; there are no header flags.
|
||||
- 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 a form: `*zip.File` parts make multipart, and `form:` fields bind only when the op `Consumes` a form media type (otherwise the tags are another binder's and JSON binds as before). A `cookie:` field is an `in: cookie` parameter set by the request's cookie and nothing else: the body, an argument object and the URL never set it, and no schema lists it as a body field. An answer that is not one JSON value is a `Body` (bytes, filename, its own status and headers; a `Reader` streams chunked; no bytes and no Type sends no Content-Type), `Verbatim[T]` (a relay's bytes documented as T; the upstream's 4xx/5xx pass undeclared), `Sse[T]` (`Send(ctx, emit)` on its own goroutine; ctx ends with the stream), `Socket[M]` (GET only; the handshake is decided before the handler runs: 501 over ZAP, 426 without an upgrade or for another version, 400 without a key, 403 for a browser origin the op does not admit — its own, plus `zip.Origins(...)`, `"*"` for any), `Redirect` or a `CookieCoder`; a type embedding `Body` or `Redirect` is that answer, so one with a `Cookies` method sets them beside it. A JSON answer keeps fiber's `application/json; charset=utf-8` unless the op `Produces` another. Every declared status carries the Out's schema, except one a union's alternative claims with its value-receiver `StatusCode()`. A union is a type whose `OneOf()` result types are its alternatives; `Or[A,B]` is the stock one. Over MCP bytes are base64 (in) and tool content (out); bytes, a Reader and a stream (as JSON lines) are bounded at `toolBound` and a Reader or stream at `toolWait`, and a socket op is no tool (`_meta.refused`). 204, 304 and a redirecting op's 3xx carry no body in the document; any other declared status carries the Out and its example. The call plane writes an `Sse` as the REST door does, refuses a `Socket` before its handler, and carries a union that holds a value as the union. A header field is a CLI flag under its field's name, as before; there are no header flags. An op that takes the body itself (a `Body` field, a form, a `Parser`) reads its content through `content`: each Content-Encoding undone and held to BodyLimit, 415 for a coding it does not undo, 413 past the limit, 400 for one that does not decode, all before the handler; a multipart form is parsed in memory from that content. A JSON op still reads fiber's `Body` as it always has. An answer refused after its handler ran closes its `Reader`. The graph (`/.well-known/graph`) and `Here` carry only plain ops (`registeredOp.plain`: one JSON value in, one out); a `cookie:` field is never a graph argument, and `Here` refuses a socket op before its handler.
|
||||
|
||||
## Rust
|
||||
|
||||
|
||||
@@ -611,10 +611,12 @@ func writeAnswer(c fiber.Ctx, op *registeredOp, out any) error {
|
||||
b, isBody := bodyOf(out)
|
||||
hdrs, err := responseHeadersOf(op, out)
|
||||
if err != nil {
|
||||
b.release()
|
||||
return err
|
||||
}
|
||||
code, err := statusOf(op, out, op.ans.verbatim)
|
||||
if err != nil {
|
||||
b.release()
|
||||
return err
|
||||
}
|
||||
if cc, ok := out.(CookieCoder); ok {
|
||||
|
||||
@@ -5,15 +5,19 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zap-proto/fiber/v3"
|
||||
|
||||
"github.com/zap-proto/zip/internal/jsonenc"
|
||||
@@ -91,6 +95,18 @@ func (b *Body) drain() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// release closes Reader, when it is an io.Closer, for an answer refused after
|
||||
// its handler ran: a relay's Reader is an upstream body, and nothing else will
|
||||
// read it. A nil Body releases nothing.
|
||||
func (b *Body) release() {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
if c, ok := b.Reader.(io.Closer); ok {
|
||||
_ = c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ResponseHeaders are the headers the answer states.
|
||||
func (b Body) ResponseHeaders() map[string]string { return b.Header }
|
||||
|
||||
@@ -240,6 +256,10 @@ func (r *intake) settle(method string, in reflect.Type, media []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// whole says the op takes the request body itself — as bytes, as a form or
|
||||
// through a Parser — rather than as one JSON value the seam decodes.
|
||||
func (r intake) whole() bool { return r.raw != nil || r.form || r.parse }
|
||||
|
||||
// formMedia reports whether a media list names a form encoding.
|
||||
func formMedia(media []string) bool {
|
||||
for _, m := range media {
|
||||
@@ -580,20 +600,90 @@ func (r intake) defaultBodyType(v any, media []string) {
|
||||
// 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) {
|
||||
// content is a REST request's body with its Content-Encoding undone, each
|
||||
// coding in turn and the result held to the app's BodyLimit, for an op that
|
||||
// takes the body itself.
|
||||
//
|
||||
// It does not read through fiber's Body, which answers a coding it cannot undo
|
||||
// with the error's text in place of the bytes (and sometimes a status on the
|
||||
// response): a handler that takes the bytes as they are would run on that
|
||||
// text. Here a coding this server does not undo is 415, a body that decodes
|
||||
// past the limit is 413, and one that does not decode is 400, each before the
|
||||
// handler runs. The bytes are the op's own: the decoders allocate, and an
|
||||
// unencoded body is fiber's copy.
|
||||
func content(c fiber.Ctx) ([]byte, error) {
|
||||
coding := strings.TrimSpace(string(c.Request().Header.ContentEncoding()))
|
||||
if coding == "" {
|
||||
return c.Body(), nil
|
||||
}
|
||||
limit := c.App().Config().BodyLimit
|
||||
body := c.Request().Body()
|
||||
// The last coding listed was applied last (RFC 9110 §8.4), so it is undone
|
||||
// first.
|
||||
codings := strings.Split(strings.ToLower(coding), ",")
|
||||
for i := len(codings) - 1; i >= 0; i-- {
|
||||
var err error
|
||||
if body, err = undo(strings.TrimSpace(codings[i]), body, limit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return append([]byte(nil), body...), nil
|
||||
}
|
||||
|
||||
// undo removes one content coding from b, held to limit bytes.
|
||||
func undo(coding string, b []byte, limit int) ([]byte, error) {
|
||||
var r fasthttp.Request
|
||||
r.SetBodyRaw(b)
|
||||
var (
|
||||
out []byte
|
||||
err error
|
||||
)
|
||||
switch coding {
|
||||
case "identity":
|
||||
return b, nil
|
||||
case "gzip", "x-gzip":
|
||||
out, err = r.BodyGunzipWithLimit(limit)
|
||||
case "deflate":
|
||||
out, err = r.BodyInflateWithLimit(limit)
|
||||
case "br":
|
||||
out, err = r.BodyUnbrotliWithLimit(limit)
|
||||
case "zstd":
|
||||
out, err = r.BodyUnzstdWithLimit(limit)
|
||||
default:
|
||||
return nil, Errorf(http.StatusUnsupportedMediaType, "Content-Encoding %q is not one this server undoes; send gzip, deflate, br, zstd or none", coding)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, fasthttp.ErrBodyTooLarge):
|
||||
return nil, Errorf(http.StatusRequestEntityTooLarge, "the request body decodes to more than %d bytes", limit)
|
||||
case err != nil:
|
||||
return nil, ErrBadRequest(fmt.Sprintf("invalid body: its %s coding does not decode: %v", coding, err))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readForm reads a REST request's form from its content: url-encoded fields,
|
||||
// or a multipart form's fields and parts, each part read whole. The content is
|
||||
// already decoded and held to the body limit, and a multipart form is parsed
|
||||
// from it in memory, so no part is larger than the body that carried it.
|
||||
func readForm(c fiber.Ctx, media string, body []byte) (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() {
|
||||
var args fasthttp.Args
|
||||
args.ParseBytes(body)
|
||||
for k, v := range args.All() {
|
||||
fd.values[string(k)] = append(fd.values[string(k)], string(v))
|
||||
}
|
||||
return fd, nil
|
||||
}
|
||||
form, err := c.RequestCtx().MultipartForm()
|
||||
boundary := string(c.Request().Header.MultipartFormBoundary())
|
||||
if boundary == "" {
|
||||
return fd, errors.New("a multipart form names no boundary")
|
||||
}
|
||||
form, err := multipart.NewReader(bytes.NewReader(body), boundary).ReadForm(int64(len(body)) + 1)
|
||||
if err != nil {
|
||||
return fd, err
|
||||
}
|
||||
defer func() { _ = form.RemoveAll() }()
|
||||
for k, v := range form.Value {
|
||||
fd.values[k] = v
|
||||
}
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
// 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"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/fasthttp/websocket"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// An op that takes the request body itself — bytes, a form, a Parser — takes
|
||||
// its content: the Content-Encoding undone and held to the body limit, or a
|
||||
// refusal before the handler runs. It never takes the text fiber puts in place
|
||||
// of a body it could not decode, and a multipart form is never larger than the
|
||||
// body that carried it.
|
||||
|
||||
func gzipped(t *testing.T, b []byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
if _, err := zw.Write(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// contentIn takes the body as bytes; contentForm as a form with one part.
|
||||
type contentIn struct {
|
||||
Body zip.Body `json:"body"`
|
||||
}
|
||||
|
||||
type contentForm struct {
|
||||
Note string `json:"note" form:"note"`
|
||||
Part *zip.File `json:"part" form:"part"`
|
||||
}
|
||||
|
||||
type contentOut struct {
|
||||
Got string `json:"got"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
func contentApp(ran *atomic.Int32) *zip.App {
|
||||
app := zip.New(zip.Config{AppName: "content", DisableStartupMessage: true, BodyLimit: 1 << 16})
|
||||
app.Post("/v1/bytes", func(_ context.Context, in *contentIn) (*contentOut, error) {
|
||||
ran.Add(1)
|
||||
return &contentOut{Got: string(in.Body.Bytes), Size: len(in.Body.Bytes)}, nil
|
||||
})
|
||||
app.Post("/v1/form", func(_ context.Context, in *contentForm) (*contentOut, error) {
|
||||
ran.Add(1)
|
||||
out := &contentOut{Got: in.Note}
|
||||
if in.Part != nil {
|
||||
out.Size = len(in.Part.Bytes)
|
||||
}
|
||||
return out, nil
|
||||
}, zip.Consumes("application/x-www-form-urlencoded", "multipart/form-data"))
|
||||
return app
|
||||
}
|
||||
|
||||
func send(t *testing.T, app *zip.App, path, media, coding string, body []byte) (int, string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", media)
|
||||
if coding != "" {
|
||||
req.Header.Set("Content-Encoding", coding)
|
||||
}
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
func TestContent_ABodyIsDecodedOrRefused(t *testing.T) {
|
||||
var ran atomic.Int32
|
||||
app := contentApp(&ran)
|
||||
plain := []byte("the statement, as sent")
|
||||
|
||||
code, body := send(t, app, "/v1/bytes", "text/plain", "gzip", gzipped(t, plain))
|
||||
if code != 200 || !strings.Contains(body, `"got":"the statement, as sent"`) {
|
||||
t.Errorf("gzip: %d %s, want the decoded bytes", code, body)
|
||||
}
|
||||
code, body = send(t, app, "/v1/bytes", "text/plain", "identity", plain)
|
||||
if code != 200 || !strings.Contains(body, `"got":"the statement, as sent"`) {
|
||||
t.Errorf("identity: %d %s, want the bytes as sent", code, body)
|
||||
}
|
||||
|
||||
ran.Store(0)
|
||||
bomb := gzipped(t, bytes.Repeat([]byte{'a'}, 1<<20))
|
||||
for _, c := range []struct {
|
||||
name, coding string
|
||||
body []byte
|
||||
code int
|
||||
}{
|
||||
{"corrupt gzip", "gzip", []byte("not gzip at all"), 400},
|
||||
{"a coding nobody undoes", "compress", plain, 415},
|
||||
{"an unknown coding", "rot13", plain, 415},
|
||||
{"past the limit once decoded", "gzip", bomb, 413},
|
||||
} {
|
||||
code, body := send(t, app, "/v1/bytes", "text/plain", c.coding, c.body)
|
||||
if code != c.code {
|
||||
t.Errorf("%s: %d %s, want %d", c.name, code, body, c.code)
|
||||
}
|
||||
}
|
||||
if n := ran.Load(); n != 0 {
|
||||
t.Errorf("the handler ran %d times on a body it could not have", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContent_AFormIsReadFromItsDecodedContent(t *testing.T) {
|
||||
var ran atomic.Int32
|
||||
app := contentApp(&ran)
|
||||
|
||||
code, body := send(t, app, "/v1/form", "application/x-www-form-urlencoded", "gzip", gzipped(t, []byte("note=kept")))
|
||||
if code != 200 || !strings.Contains(body, `"got":"kept"`) {
|
||||
t.Errorf("a gzip url-encoded form: %d %s, want its field", code, body)
|
||||
}
|
||||
|
||||
form := func(part []byte) (string, []byte) {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
_ = mw.WriteField("note", "with a part")
|
||||
w, _ := mw.CreateFormFile("part", "p.bin")
|
||||
_, _ = w.Write(part)
|
||||
_ = mw.Close()
|
||||
return mw.FormDataContentType(), buf.Bytes()
|
||||
}
|
||||
media, small := form([]byte("small part"))
|
||||
code, body = send(t, app, "/v1/form", media, "gzip", gzipped(t, small))
|
||||
if code != 200 || !strings.Contains(body, `"got":"with a part"`) || !strings.Contains(body, `"size":10`) {
|
||||
t.Errorf("a gzip multipart form: %d %s, want its field and part", code, body)
|
||||
}
|
||||
|
||||
// A small request that decodes to a part far past the limit is refused at
|
||||
// the limit, before any part is read and before the handler runs.
|
||||
ran.Store(0)
|
||||
media, big := form(bytes.Repeat([]byte{'z'}, 8<<20))
|
||||
code, body = send(t, app, "/v1/form", media, "gzip", gzipped(t, big))
|
||||
if code != 413 || ran.Load() != 0 {
|
||||
t.Errorf("a multipart bomb: %d %s, ran %d times; want 413 and no run", code, body, ran.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// bareIn names nothing; the relay answers whatever its upstream did.
|
||||
type bareIn struct{}
|
||||
|
||||
type relayed struct {
|
||||
// Text is the upstream's answer.
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// closer records whether the reader it stands for was closed.
|
||||
type closer struct {
|
||||
io.Reader
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (c *closer) Close() error { c.closed.Store(true); return nil }
|
||||
|
||||
// An answer refused after its handler ran — a status or a header the op did
|
||||
// not declare — still closes its Reader: a relay's Reader is the upstream's
|
||||
// body, and nothing else will.
|
||||
func TestContent_ARefusedAnswerClosesItsReader(t *testing.T) {
|
||||
var last atomic.Pointer[closer]
|
||||
app := zip.New(zip.Config{AppName: "relay", DisableStartupMessage: true})
|
||||
app.Get("/v1/relay", func(context.Context, *bareIn) (*zip.Verbatim[relayed], error) {
|
||||
c := &closer{Reader: strings.NewReader(`{"text":"partial"}`)}
|
||||
last.Store(c)
|
||||
return &zip.Verbatim[relayed]{Body: zip.Body{Type: "application/json", Status: http.StatusPartialContent, Reader: c}}, nil
|
||||
})
|
||||
app.Get("/v1/header", func(context.Context, *bareIn) (*zip.Verbatim[relayed], error) {
|
||||
c := &closer{Reader: strings.NewReader(`{"text":"tagged"}`)}
|
||||
last.Store(c)
|
||||
return &zip.Verbatim[relayed]{Body: zip.Body{Type: "application/json", Header: map[string]string{"X-Undeclared": "1"}, Reader: c}}, nil
|
||||
})
|
||||
for _, path := range []string{"/v1/relay", "/v1/header"} {
|
||||
for _, method := range []string{"GET", "HEAD"} {
|
||||
resp, err := app.Test(httptest.NewRequest(method, path, nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode < 500 {
|
||||
t.Errorf("%s %s answered %d, want the refusal of an undeclared answer", method, path, resp.StatusCode)
|
||||
}
|
||||
if c := last.Load(); c == nil || !c.closed.Load() {
|
||||
t.Errorf("%s %s left the upstream body open", method, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A field of the graph is one JSON value in and one out. Every op that is not
|
||||
// that is absent from the schema and refused by the executor without running,
|
||||
// a cookie is never an argument, and [zip.Here] refuses a socket op before its
|
||||
// handler runs.
|
||||
func TestContent_TheGraphCarriesOnlyPlainOps(t *testing.T) {
|
||||
var ran atomic.Int32
|
||||
count := func() { ran.Add(1) }
|
||||
app := zip.New(zip.Config{AppName: "graph", DisableStartupMessage: true})
|
||||
app.Get("/v1/live", func(context.Context, *bareIn) (*zip.Socket[line], error) {
|
||||
count()
|
||||
return &zip.Socket[line]{Serve: func(*websocket.Conn) error { return nil }}, nil
|
||||
}, zip.WithOperationID("live"))
|
||||
app.Get("/v1/events", func(context.Context, *bareIn) (*zip.Sse[line], error) {
|
||||
count()
|
||||
return &zip.Sse[line]{}, nil
|
||||
}, zip.WithOperationID("events"))
|
||||
app.Get("/v1/file", func(context.Context, *bareIn) (*zip.Body, error) {
|
||||
count()
|
||||
return &zip.Body{Reader: strings.NewReader("x")}, nil
|
||||
}, zip.WithOperationID("file"))
|
||||
app.Get("/v1/away", func(context.Context, *bareIn) (*zip.Redirect, error) {
|
||||
count()
|
||||
return &zip.Redirect{To: "/"}, nil
|
||||
}, zip.WithOperationID("away"))
|
||||
app.Get("/v1/either", func(context.Context, *bareIn) (*zip.Or[relayed, contentOut], error) {
|
||||
count()
|
||||
return &zip.Or[relayed, contentOut]{A: &relayed{}}, nil
|
||||
}, zip.WithOperationID("either"))
|
||||
app.Post("/v1/upload", func(context.Context, *contentIn) (*contentOut, error) {
|
||||
count()
|
||||
return &contentOut{}, nil
|
||||
}, zip.WithOperationID("upload"))
|
||||
app.Get("/v1/me", func(_ context.Context, in *sessionIn) (*sessionOut, error) {
|
||||
return &sessionOut{Session: in.Session}, nil
|
||||
}, zip.WithOperationID("me"))
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sdl := app.GraphQLSDL()
|
||||
for _, name := range []string{"live", "events", "file", "away", "either", "upload"} {
|
||||
if strings.Contains(sdl, " "+name) {
|
||||
t.Errorf("the schema publishes %s, which is not one JSON value in and one out:\n%s", name, sdl)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(sdl, " me: SessionOut") {
|
||||
t.Errorf("the schema should publish me with no session argument:\n%s", sdl)
|
||||
}
|
||||
|
||||
for _, q := range []string{"{ live { text } }", "{ events { text } }", "{ file { Type } }", "{ away { to } }", "{ either { text } }"} {
|
||||
g := app.GraphQL(context.Background(), zip.GraphRequest{Query: q})
|
||||
if len(g.Errors) == 0 {
|
||||
t.Errorf("%s resolved: %+v", q, g.Data)
|
||||
}
|
||||
}
|
||||
g := app.GraphQL(context.Background(), zip.GraphRequest{Query: `mutation { upload(body: "eA==") { size } }`})
|
||||
if len(g.Errors) == 0 {
|
||||
t.Errorf("upload resolved: %+v", g.Data)
|
||||
}
|
||||
g = app.GraphQL(context.Background(), zip.GraphRequest{Query: `{ me(session: "attacker-chosen") { session } }`})
|
||||
if len(g.Errors) == 0 {
|
||||
t.Errorf("a cookie was taken as an argument: %+v", g.Data)
|
||||
}
|
||||
if n := ran.Load(); n != 0 {
|
||||
t.Errorf("the graph ran %d handlers it does not carry", n)
|
||||
}
|
||||
|
||||
_, err := zip.Here[bareIn, zip.Socket[line]](context.Background(), app, "live", &bareIn{})
|
||||
var he *zip.HTTPError
|
||||
if !errors.As(err, &he) || he.Status != http.StatusNotImplemented || ran.Load() != 0 {
|
||||
t.Errorf("Here on a socket op: %v, ran %d times; want 501 before the handler", err, ran.Load())
|
||||
}
|
||||
}
|
||||
|
||||
type sessionIn struct {
|
||||
// Session is the browser's session cookie.
|
||||
Session string `json:"session" cookie:"session"`
|
||||
}
|
||||
|
||||
type sessionOut struct {
|
||||
// Session is the session the op read.
|
||||
Session string `json:"session"`
|
||||
}
|
||||
+13
-5
@@ -55,6 +55,12 @@ func (a *App) GraphQLSDL() string {
|
||||
// the first time the path did.
|
||||
continue
|
||||
}
|
||||
if !op.plain() {
|
||||
// A field is one JSON value in and one out. Bytes, a form, a
|
||||
// stream, an upgrade, a redirect and a union are not that, so they
|
||||
// are absent rather than published as a shape they do not have.
|
||||
continue
|
||||
}
|
||||
f := g.field(op)
|
||||
if op.Method == "GET" {
|
||||
query = append(query, f)
|
||||
@@ -124,11 +130,13 @@ func (g *sdl) field(op *registeredOp) string {
|
||||
func (g *sdl) args(t reflect.Type) string {
|
||||
var out []string
|
||||
for _, f := range wireFields(t) {
|
||||
if headerFieldName(f) != "" {
|
||||
// A header is AMBIENT, not an argument. Over REST its value comes from
|
||||
// the request — set by whatever runs in front of the handler — so
|
||||
// publishing it as an argument would invite a client to supply its own
|
||||
// instead. The executor refuses it by the same rule.
|
||||
if headerFieldName(f) != "" || cookieFieldName(f) != "" {
|
||||
// A header or a cookie is AMBIENT, not an argument. Over REST its
|
||||
// value comes from the request — set by whatever runs in front of the
|
||||
// handler, or by the browser — so publishing it as an argument would
|
||||
// invite a client to supply its own instead. The executor refuses it
|
||||
// by the same rule, and binds no cookie: the graph, like MCP, carries
|
||||
// no browser credential.
|
||||
continue
|
||||
}
|
||||
name := jsonFieldName(f)
|
||||
|
||||
+8
-5
@@ -25,9 +25,12 @@ package zip
|
||||
//
|
||||
// # What is not served, by name
|
||||
//
|
||||
// Subscriptions, because no op is a stream. Introspection, because the schema is
|
||||
// published whole at GET [GraphPath], which is what a code generator reads.
|
||||
// Both are refused in words rather than answered with a confusing nothing.
|
||||
// Subscriptions, and every op that is not one JSON value in and one out (see
|
||||
// registeredOp.plain): a field resolves to a value, so bytes, a form, a stream,
|
||||
// an upgrade, a redirect and a union are no field. Introspection, because the
|
||||
// schema is published whole at GET [GraphPath], which is what a code generator
|
||||
// reads. Each is refused in words rather than answered with a confusing
|
||||
// nothing.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -130,7 +133,7 @@ func (a *App) graph(ctx context.Context, req GraphRequest, foreign Resolver) Gra
|
||||
func (a *App) graphFields(kind string) map[string]*registeredOp {
|
||||
out := map[string]*registeredOp{}
|
||||
for _, op := range a.Registry() {
|
||||
if op.OperationID == "" {
|
||||
if op.OperationID == "" || !op.plain() {
|
||||
continue
|
||||
}
|
||||
if (kind == "query") != (op.Method == http.MethodGet) {
|
||||
@@ -367,7 +370,7 @@ func (e *graph) input(op *registeredOp, args map[string]any) (any, error) {
|
||||
func graphArgs(t reflect.Type) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, f := range wireFields(t) {
|
||||
if headerFieldName(f) != "" {
|
||||
if headerFieldName(f) != "" || cookieFieldName(f) != "" {
|
||||
continue // ambient; see this file's header
|
||||
}
|
||||
n := jsonFieldName(f)
|
||||
|
||||
@@ -103,6 +103,15 @@ type registeredOp struct {
|
||||
direct func(ctx context.Context, in any) (any, error)
|
||||
}
|
||||
|
||||
// plain says the op takes one JSON value and answers one: no body taken as
|
||||
// bytes, as a form or by a Parser, and no answer that is bytes, a relay, a
|
||||
// stream, an upgrade, a redirect or a union. It is the shape a graph field
|
||||
// resolves to.
|
||||
func (op *registeredOp) plain() bool {
|
||||
return !op.req.whole() && !op.req.files && op.ans.stream == "" && !op.ans.verbatim &&
|
||||
!op.ans.redirect && op.ans.union == nil && len(op.ans.json) <= 1
|
||||
}
|
||||
|
||||
// decoder reads a request body into an op's In. It is a PARAMETER rather than a
|
||||
// property of the op because the encoding belongs to the transport, not to the
|
||||
// contract: the same op answers a browser over JSON and a sibling service over
|
||||
@@ -782,6 +791,11 @@ func registerTyped[In, Out any](depth int, on *App, method, path string, fn Type
|
||||
if !ok {
|
||||
return nil, ErrBadRequest(fmt.Sprintf("%s takes %T, not %T", opName(op), (*In)(nil), in))
|
||||
}
|
||||
// No connection reaches an op this way, so a socket op is refused
|
||||
// before its handler runs, as over the call plane and MCP.
|
||||
if op.ans.stream == streamSocket {
|
||||
return nil, Errorf(fiber.StatusNotImplemented, "%s: %s", opName(op), NotACall)
|
||||
}
|
||||
return run(ctx, v)
|
||||
}
|
||||
|
||||
@@ -827,10 +841,20 @@ func registerTyped[In, Out any](depth int, on *App, method, path string, fn Type
|
||||
// input no generated client would ever send.
|
||||
wire := input{dec: jsonenc.Unmarshal, sent: true}
|
||||
if hasBody(method) {
|
||||
wire.body = c.Body()
|
||||
wire.media = c.Get(fiber.HeaderContentType)
|
||||
if op.req.whole() {
|
||||
// An op that takes the body itself takes its content or a
|
||||
// refusal, never fiber's text for a coding it could not undo.
|
||||
body, err := content(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wire.body = body
|
||||
} else {
|
||||
wire.body = c.Body()
|
||||
}
|
||||
if op.req.form {
|
||||
wire.form = func() (formData, error) { return readForm(c, wire.media) }
|
||||
wire.form = func() (formData, error) { return readForm(c, wire.media, wire.body) }
|
||||
}
|
||||
}
|
||||
if names := c.Route().Params; len(names) > 0 {
|
||||
|
||||
Reference in New Issue
Block a user