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:
2026-10-09 10:29:22 -07:00
parent 8f3e825d41
commit ebb52ca808
7 changed files with 437 additions and 18 deletions
+1 -1
View File
@@ -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
+2
View File
@@ -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 {
+95 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+26 -2
View File
@@ -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 {