One GraphQL engine: a resolver is handed the whole selection

A graph served for something else (MountGraphFor) now gets what a
second engine had been written for:

- The request is gathered before anything resolves: fragments spread
  once per selection set, cycles and unknown fragments refused at
  parse, @skip and @include decided (on inline fragments too), fields
  under one key merged, variables put in place. A Resolver receives
  the field as a Selection with its whole subtree, so it can fetch
  only what is selected or refuse a request too deep to answer.
- An Object answer is asked for each selected field with that field's
  own arguments, so nesting can be traversal. Selection.Bind decodes
  arguments into a struct, refusing unknown ones.
- The schema is rendered per request with the caller's context, and a
  schema that fails is a 500 that says so.
- Bounds on the gathered tree refuse whole: 64 root fields, 10000
  fields in all, depth 64.
- A query's root fields resolve at once; a mutation's in order. A
  panic fails its field.
- Keys go out in the order selected. A given variable the operation
  does not declare is ignored, and a declared one given nothing leaves
  its argument out, both as the specification says.
- MountGraphFor's routes are declared, so a host routes to them and a
  document describes them; Headers(ctx) relays a request whole.
This commit is contained in:
2026-10-18 20:08:59 -07:00
parent 9f1ab16dcc
commit 5331b2e456
5 changed files with 1249 additions and 279 deletions
+14 -1
View File
@@ -46,7 +46,7 @@ Patch tags, one above the highest `v*` on GitHub. Push the commit to main, read
| `compose.go`, `walk.go`, `build.go`, `generation.go`, `host.go` | the program (`Use`, `Group`), the walk every projection reduces, generations, `Serve` and `Host` |
| `transport.go` | `Listen`, `RegisterTransport`, `NetworkOf` |
| `load.go`, `remote.go`, `status.go`, `idle.go` | plugins (`Load`, `Plugin`, `Reload`, `Unload`), `Proxy`, `Plugins`, `Evict` |
| `resolve.go`, `graphql.go`, `docs_gen.go` | `GraphQL`; `DocsMarkdown` |
| `document.go`, `resolve.go`, `graphql.go`, `docs_gen.go` | the GraphQL engine: the parser, `GraphQL`, `MountGraph`, `MountGraphFor`, `Resolver`, `Selection`, `Object`, and `GraphQLSDL`; `DocsMarkdown` |
| `middleware/`, `wsx/`, `js/` | middleware; WebSocket upgrades; goja handlers |
| `contract/` | collector contract tests, a module of its own |
| `examples/local-service/` | the README's service, a module of its own |
@@ -75,6 +75,19 @@ Patch tags, one above the highest `v*` on GitHub. Push the commit to main, read
- 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. An op that takes the body itself (a `Body` field, a form, a `Parser`) reads its content through `content`: each Content-Encoding (every header line) undone and held to BodyLimit, 415 for a coding it does not undo or a chain of more than three, 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 url-encoded one of 10000 or more `&` (more than 10000 pieces, fields or empty) is 400 before any is decoded, and a form keeps only the keys In binds. Over HTTP fasthttp has read a multipart body as a form before routing, so a `Body` field holds that form re-encoded (what `c.Body()` gives a raw handler), not the bytes as sent; ZAP carries them as sent. 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.
## GraphQL: one engine
zip's is the only GraphQL parser and executor in the estate. `document.go` parses; `resolve.go` gathers and runs. The app's own projection (`MountGraph`, and `installGraph` at `/.well-known/graph`) and a graph the app serves for something else (`MountGraphFor(path, schema, resolve)`) differ only in what answers a root field: a local op through `op.direct`, or a `Resolver`. hanzo-inc/cloud serves both of its graphs on it: the fleet's `/v1/graphql` (`client.UseGraph`, one hop per root field to the app that owns the op) and the knowledge graph's `/v1/graph/graphql` (`apps/graph/graphql.go`, an `Object` per type).
- A request is gathered whole before any field resolves: fragments spread once per selection set, as the specification's CollectFields does (a cycle or an unknown fragment is refused at parse, `gqlDoc.check`); `@skip`/`@include` decided, on inline fragments too; fields under one response key merged (two different fields under one key refused); variables put in place. A declared variable given nothing leaves its argument out; a reference to an undeclared one is refused; a given variable the operation does not declare is ignored.
- The bounds are checked on the gathered tree and refuse whole, 400 with no data: depth 64 (`depthLimit`, fragments included), 64 root fields (`rootLimit`), 10000 fields in all (`fieldLimit`, which is what stops a fragment chain that doubles per link).
- A query's root fields resolve at once, a mutation's one after another in the order written. A panic in a field fails that field.
- A `Resolver` gets `(ctx, kind, Selection)`: kind so it can refuse a write selected under a query; the `Selection` with its whole subtree (`Fields`, `Args` with variables in place, `Depth`, `Bind` for decoding the arguments into a struct with `validate` tags and refusing unknown ones). An `Object` answer (`Type`, `Field`) is asked for each selected field with that field's own arguments, so nesting can be traversal; anything else is read through JSON and filtered by the selection. A typed nil is null.
- `schema` is `func(ctx) (string, error)`, called per request with the caller's context, so a schema may be composed as the deployment changes or differ by caller; an error is a 500 that says so.
- `MountGraphFor` registers two declared routes (GET schema, POST run) where it is called, so mount it before `Build`; a host routes to them and a document describes them. `MountGraph` is control plane and undeclared.
- Introspection is refused and names GET of the address the graph answers at. On the wire an answer's keys follow the selection (`GraphResponse.MarshalJSON`); in Go `Data` is plain maps.
- `Headers(ctx)` is every header of the request behind a context, for a resolver that relays the request whole (cloud's fleet hop).
## Rust
`rust/` is a Cargo workspace: `zip` (runtime), `zip-macros` (the two macros), `examples/info` (the corpus, compared byte for byte with `corpus/info` by `conformance_test.go`) and `examples/stream`. It is not published to crates.io; a service takes it as a git dependency pinned by commit, under the key `zip`, since the macros expand to `::zip::` paths (a service that also needs crates.io's `zip` archive crate renames that one):
+19
View File
@@ -3,6 +3,7 @@ package zip
import (
"cmp"
"context"
"net/http"
"strconv"
"strings"
"sync"
@@ -182,6 +183,24 @@ func Header(ctx context.Context, name string) string {
return get(name)
}
// Headers is every header of the request behind a handler's context, copied, and
// nil when there is no request behind it.
//
// It is [Header] for the caller that relays the request whole: a host that
// answers one address by asking the process that owns the operation, and must
// present the caller to that process exactly as the caller presented itself —
// the credential, the cookies, and whatever the edge in front of it set.
func Headers(ctx context.Context) http.Header {
var h http.Header
request(ctx, func(rc *fasthttp.RequestCtx) {
h = http.Header{}
for k, v := range rc.Request.Header.All() {
h.Add(string(k), string(v))
}
})
return h
}
// headerOf is the declared-header reader an op's invoke seam takes, resolved
// from the context rather than from a transport's own request object.
//
+84 -15
View File
@@ -54,13 +54,15 @@ type gqlVar struct {
def any
}
// gqlSel is one selected thing: a field, or a spread of a named fragment.
// gqlSel is one selected thing: a field, a spread of a named fragment, or an
// inline fragment.
type gqlSel struct {
alias string
name string
args map[string]any
sel []*gqlSel
spread string // non-empty: spread this fragment here instead
inline bool // an inline fragment: sel is its body
skipIf any
includeIf any
}
@@ -361,7 +363,63 @@ func parseGraph(src string) (*gqlDoc, error) {
return nil, p.err
}
}
return d, p.err
if p.err != nil {
return nil, p.err
}
if err := d.check(); err != nil {
return nil, err
}
return d, nil
}
// check refuses a document whose fragments cannot be expanded: a spread of a
// fragment nobody defined, and a fragment that reaches itself through any chain
// of spreads. Both are found here, before anything runs, because a cycle found
// during execution has already spent the work up to it.
func (d *gqlDoc) check() error {
const (
open = 1 // on the path being walked
done = 2 // walked, and reaches no cycle
)
state := map[string]int{}
var walk func(sel []*gqlSel) error
walk = func(sel []*gqlSel) error {
for _, s := range sel {
if s.spread == "" {
if err := walk(s.sel); err != nil {
return err
}
continue
}
body, ok := d.frags[s.spread]
if !ok {
return fmt.Errorf("unknown fragment %q", s.spread)
}
switch state[s.spread] {
case open:
return fmt.Errorf("fragment %q spreads itself", s.spread)
case done:
continue
}
state[s.spread] = open
if err := walk(body); err != nil {
return err
}
state[s.spread] = done
}
return nil
}
for _, o := range d.ops {
if err := walk(o.sel); err != nil {
return err
}
}
for _, body := range d.frags {
if err := walk(body); err != nil {
return err
}
}
return nil
}
func (p *gqlParser) operation() (*gqlOp, error) {
@@ -510,7 +568,7 @@ func (p *gqlParser) selection() ([]*gqlSel, error) {
if err != nil {
return nil, err
}
out = append(out, inner...)
out = append(out, inner)
continue
}
f, err := p.field()
@@ -528,24 +586,22 @@ func (p *gqlParser) selection() ([]*gqlSel, error) {
// spread reads `...Name` or `... on Type { ... }`.
//
// An inline fragment is flattened into the selection that holds it, and its type
// condition carries no meaning here: every type this schema publishes is a
// concrete object, no field answers with an interface or a union, so a condition
// can only ever name the type the field already has.
func (p *gqlParser) spread() ([]*gqlSel, error) {
// An inline fragment keeps its own directives, so `... @include(if: $v) { a b }`
// decides both fields at once. Its type condition carries no meaning here: every
// type a graph publishes is a concrete object, no field answers with an
// interface or a union, so a condition can only ever name the type the field
// already has.
func (p *gqlParser) spread() (*gqlSel, error) {
p.advance() // "..."
if p.tok.kind == 'n' && p.tok.text == "on" {
p.advance()
if _, ok := p.name(); !ok {
return nil, fmt.Errorf("expected a type after `on`")
}
if _, _, err := p.directives(); err != nil {
return nil, err
return p.inline()
}
return p.selection()
}
if p.is("{") {
return p.selection()
if p.is("{") || p.is("@") {
return p.inline()
}
name, ok := p.name()
if !ok {
@@ -557,7 +613,20 @@ func (p *gqlParser) spread() ([]*gqlSel, error) {
return nil, err
}
s.skipIf, s.includeIf = skip, incl
return []*gqlSel{s}, nil
return s, nil
}
// inline reads an inline fragment's directives and body.
func (p *gqlParser) inline() (*gqlSel, error) {
skip, incl, err := p.directives()
if err != nil {
return nil, err
}
sel, err := p.selection()
if err != nil {
return nil, err
}
return &gqlSel{inline: true, sel: sel, skipIf: skip, includeIf: incl}, nil
}
func (p *gqlParser) field() (*gqlSel, error) {
+621 -225
View File
File diff suppressed because it is too large Load Diff
+487 -14
View File
@@ -5,11 +5,14 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// The claim under test: a GraphQL field reaches an op through the SAME contract
@@ -542,12 +545,12 @@ func foreignApp(t *testing.T, r Resolver) *App {
app.Get("/v1/local", func(ctx context.Context, _ *resGet) (*resUser, error) {
return &resUser{ID: "local"}, nil
}, WithOperationID("local"))
app.MountGraphFor("/v1/fleet", func(context.Context) (string, error) {
return "type Query {\n farUser(id: String!): FarUser\n}\n\ntype FarUser {\n id: String\n email: String\n}\n", nil
}, r)
if err := app.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
app.MountGraphFor("/v1/fleet", func() string {
return "type Query {\n farUser(id: String!): FarUser\n}\n\ntype FarUser {\n id: String\n email: String\n}\n"
}, r)
return app
}
@@ -575,8 +578,8 @@ func askAt(t *testing.T, app *App, path, query string, vars ...map[string]any) r
}
func TestAForeignFieldIsResolvedAndProjected(t *testing.T) {
app := foreignApp(t, func(ctx context.Context, kind, field string, args map[string]any) (any, error) {
return map[string]any{"id": args["id"], "email": "[email protected]", "secret": "no"}, nil
app := foreignApp(t, func(ctx context.Context, kind string, s Selection) (any, error) {
return map[string]any{"id": s.Args["id"], "email": "[email protected]", "secret": "no"}, nil
})
r := askAt(t, app, "/v1/fleet", `{ farUser(id: "u1") { id email } }`)
if len(r.Errors) != 0 {
@@ -597,7 +600,7 @@ func TestAForeignFieldIsResolvedAndProjected(t *testing.T) {
// reachable as a read has to be enforceable where the knowledge is.
func TestAForeignResolverIsToldTheOperationKind(t *testing.T) {
var kinds []string
app := foreignApp(t, func(ctx context.Context, kind, field string, args map[string]any) (any, error) {
app := foreignApp(t, func(ctx context.Context, kind string, s Selection) (any, error) {
kinds = append(kinds, kind)
if kind != "mutation" {
return nil, errors.New("that field is a write and this is a query")
@@ -622,8 +625,8 @@ func TestAForeignResolverIsToldTheOperationKind(t *testing.T) {
// resolver sees plain arguments and answers a plain value.
func TestAForeignResolverSeesResolvedArguments(t *testing.T) {
var seen map[string]any
app := foreignApp(t, func(ctx context.Context, kind, field string, args map[string]any) (any, error) {
seen = args
app := foreignApp(t, func(ctx context.Context, kind string, s Selection) (any, error) {
seen = s.Args
return map[string]any{"id": "x", "boss": map[string]any{"id": "y", "email": "b@e"}}, nil
})
r := askAt(t, app, "/v1/fleet", `query F($who: String!) { a: farUser(id: $who) { boss { id } } }`,
@@ -644,7 +647,7 @@ func TestAForeignResolverSeesResolvedArguments(t *testing.T) {
// absent field is null rather than an invented error. (A LOCAL field still names
// it — see TestAFieldTheTypeDoesNotHaveIsNamed.)
func TestAnAbsentForeignFieldIsNullNotAnError(t *testing.T) {
app := foreignApp(t, func(ctx context.Context, kind, field string, args map[string]any) (any, error) {
app := foreignApp(t, func(ctx context.Context, kind string, s Selection) (any, error) {
return map[string]any{"id": "x"}, nil
})
r := askAt(t, app, "/v1/fleet", `{ farUser(id: "u1") { id email } }`)
@@ -663,13 +666,13 @@ func TestTheForeignSchemaIsRenderedPerRequest(t *testing.T) {
n := 0
app := New(Config{AppName: "host", DisableStartupMessage: true})
app.Get("/v1/local", func(ctx context.Context, _ *resGet) (*resUser, error) { return nil, nil }, WithOperationID("local"))
app.MountGraphFor("/v1/fleet", func(context.Context) (string, error) {
n++
return "type Query {\n n" + strings.Repeat("x", n) + ": String\n}\n", nil
}, nil)
if err := app.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
app.MountGraphFor("/v1/fleet", func() string {
n++
return "type Query {\n n" + strings.Repeat("x", n) + ": String\n}\n"
}, nil)
first, _ := io.ReadAll(mustGet(t, app, "/v1/fleet"))
second, _ := io.ReadAll(mustGet(t, app, "/v1/fleet"))
@@ -691,7 +694,7 @@ func mustGet(t *testing.T, app *App, path string) io.Reader {
// The local graph is unchanged by any of this: same app, same door, same answer.
func TestTheLocalGraphStillAnswers(t *testing.T) {
app := foreignApp(t, func(ctx context.Context, kind, field string, args map[string]any) (any, error) {
app := foreignApp(t, func(ctx context.Context, kind string, s Selection) (any, error) {
return nil, errors.New("should not be asked")
})
app.MountGraph("/v1/own")
@@ -703,3 +706,473 @@ func TestTheLocalGraphStillAnswers(t *testing.T) {
t.Fatalf("local op did not run: %#v", r.Data)
}
}
// ── what a resolver is handed, and what it may hand back ─────────────────────
// mountFor builds an app serving one foreign graph at /v1/g, mounted before the
// app is built — the routes are the app's own, so they are composed with it.
func mountFor(t *testing.T, sdl string, r Resolver) *App {
t.Helper()
app := New(Config{AppName: "host", DisableStartupMessage: true})
app.MountGraphFor("/v1/g", func(context.Context) (string, error) { return sdl, nil }, r)
if err := app.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
return app
}
// rawAt posts a request and returns the status and the bytes, for assertions
// about the wire itself.
func rawAt(t *testing.T, app *App, path string, req GraphRequest, header ...string) (int, string) {
t.Helper()
body, _ := json.Marshal(req)
r := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
for i := 0; i+1 < len(header); i += 2 {
r.Header.Set(header[i], header[i+1])
}
resp, err := app.Fiber().Test(r)
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(raw)
}
// ★ A resolver holds the whole of what was asked below its field: fragments
// spread, skipped fields gone, variables in place, aliases kept. That is what
// lets one that fronts a planner fetch only what is selected.
func TestAResolverReceivesTheGatheredSelection(t *testing.T) {
var got Selection
app := mountFor(t, "type Query { a: JSON }", func(ctx context.Context, kind string, s Selection) (any, error) {
got = s
return map[string]any{}, nil
})
q := `query Q($n: Int, $off: Boolean!) {
top: a(x: 1) {
...F
b(n: $n) { c }
gone @skip(if: $off)
... @skip(if: $off) { also }
}
}
fragment F on A { first: d e }`
r := askAt(t, app, "/v1/g", q, map[string]any{"n": 7, "off": true})
if len(r.Errors) != 0 {
t.Fatalf("unexpected errors: %s", r.messages())
}
if got.Name != "a" || got.Alias != "top" || got.Key() != "top" {
t.Fatalf("the field is named %q under %q, want a under top", got.Name, got.Key())
}
if got.Args["x"] != int64(1) {
t.Fatalf("args = %#v", got.Args)
}
var keys []string
for _, f := range got.Fields {
keys = append(keys, f.Key())
}
if strings.Join(keys, ",") != "first,e,b" {
t.Fatalf("selection = %v, want the fragment spread, the skipped field and the inline fragment gone", keys)
}
b := got.Fields[2]
if b.Args["n"] != float64(7) || len(b.Fields) != 1 || b.Fields[0].Name != "c" {
t.Fatalf("the nested field lost its variable or its selection: %#v", b)
}
if got.Depth() != 3 {
t.Errorf("Depth = %d, want 3 (a, b, c)", got.Depth())
}
}
// A declared variable that was given nothing leaves its argument out, as the
// specification says; a reference to one the operation never declared is
// refused before anything runs; and a variable given and never declared is
// ignored, because nothing can refer to it.
func TestVariablesTheRequestDidNotSettle(t *testing.T) {
var got Selection
app := mountFor(t, "type Query { a(n: Int): JSON }", func(ctx context.Context, kind string, s Selection) (any, error) {
got = s
return "ok", nil
})
if r := askAt(t, app, "/v1/g", `query($n: Int) { a(n: $n) }`); len(r.Errors) != 0 {
t.Fatalf("an optional variable left unset failed: %s", r.messages())
}
if _, present := got.Args["n"]; present {
t.Errorf("the unset variable reached the resolver as %#v", got.Args)
}
r := askAt(t, app, "/v1/g", `{ a(n: $nope) }`)
if r.code != http.StatusBadRequest || r.Data != nil {
t.Errorf("an undeclared variable was run (%d): %#v", r.code, r.Data)
}
if r := askAt(t, app, "/v1/g", `{ a }`, map[string]any{"extra": 1}); len(r.Errors) != 0 {
t.Errorf("a variable the operation does not declare was refused: %s", r.messages())
}
if r := askAt(t, app, "/v1/g", `query($n: Int = 3) { a(n: $n) }`); len(r.Errors) != 0 || got.Args["n"] != int64(3) {
t.Errorf("the default did not apply: %v %s", got.Args, r.messages())
}
}
// Fields answering under one key are one field: their selections join, and the
// resolver runs once. Two different fields under one key cannot both answer and
// are refused rather than resolved by whichever came last.
func TestFieldsUnderOneKeyMerge(t *testing.T) {
calls := 0
var got Selection
app := mountFor(t, "type Query { a: JSON }", func(ctx context.Context, kind string, s Selection) (any, error) {
calls++
got = s
return map[string]any{"x": 1, "y": 2}, nil
})
r := askAt(t, app, "/v1/g", `{ a { x } ...F } fragment F on Query { a { y } }`)
if len(r.Errors) != 0 {
t.Fatalf("unexpected errors: %s", r.messages())
}
if calls != 1 || len(got.Fields) != 2 {
t.Fatalf("calls = %d, selection = %#v; want one call selecting x and y", calls, got.Fields)
}
if m := r.Data["a"].(map[string]any); m["x"] != float64(1) || m["y"] != float64(2) {
t.Fatalf("the merged field lost half its selection: %#v", m)
}
clash := askAt(t, app, "/v1/g", `{ a: a { x } a: b }`)
if clash.code != http.StatusBadRequest || len(clash.Errors) == 0 {
t.Fatalf("two fields under one key were run (%d): %#v", clash.code, clash.Data)
}
}
// A fragment that reaches itself, through any chain, and a spread of a fragment
// nobody wrote are refused before any field resolves.
func TestABrokenFragmentNeverRuns(t *testing.T) {
ran := false
app := mountFor(t, "type Query { a: JSON }", func(ctx context.Context, kind string, s Selection) (any, error) {
ran = true
return map[string]any{}, nil
})
for _, q := range []string{
`{ a { ...A } } fragment A on T { ...B } fragment B on T { x ...A }`,
`{ a { ...Missing } }`,
`{ a } fragment A on T { ...A }`,
} {
r := askAt(t, app, "/v1/g", q)
if r.code != http.StatusBadRequest || r.Data != nil || len(r.Errors) == 0 {
t.Errorf("%s: answered %d with %#v", q, r.code, r.Data)
}
}
if ran {
t.Error("a field resolved for a document that cannot be expanded")
}
}
// Fragments that double at every link would be a small document asking for an
// enormous answer. The count is taken while the request is gathered, so the
// refusal comes before anything is spent.
func TestAnExpansionPastTheBoundIsRefused(t *testing.T) {
ran := false
app := mountFor(t, "type Query { a: JSON }", func(ctx context.Context, kind string, s Selection) (any, error) {
ran = true
return map[string]any{}, nil
})
var b strings.Builder
b.WriteString("{ a { ...F0 } }\n")
for i := 0; i < 20; i++ {
fmt.Fprintf(&b, "fragment F%d on T { l { ...F%d } r { ...F%d } }\n", i, i+1, i+1)
}
b.WriteString("fragment F20 on T { leaf }\n")
r := askAt(t, app, "/v1/g", b.String())
if r.code != http.StatusBadRequest || ran {
t.Fatalf("a 2^20 expansion was run (%d, ran=%v): %s", r.code, ran, r.messages())
}
}
// The width of a request is bounded, and the bound refuses rather than truncates.
func TestTheRootIsBounded(t *testing.T) {
app := mountFor(t, "type Query { a: JSON }", func(ctx context.Context, kind string, s Selection) (any, error) {
return "ok", nil
})
query := func(n int) string {
var b strings.Builder
b.WriteString("{")
for i := 0; i < n; i++ {
fmt.Fprintf(&b, " f%d: a", i)
}
return b.String() + " }"
}
if r := askAt(t, app, "/v1/g", query(rootLimit)); len(r.Errors) != 0 || len(r.Data) != rootLimit {
t.Fatalf("%d root fields: %s", rootLimit, r.messages())
}
r := askAt(t, app, "/v1/g", query(rootLimit+1))
if r.code != http.StatusBadRequest || r.Data != nil {
t.Fatalf("%d root fields were run (%d)", rootLimit+1, r.code)
}
}
// ★ The root fields of a query resolve at once, so a request costs its slowest
// field; a mutation's run one after another, in the order written.
func TestQueriesRunAtOnceAndMutationsInOrder(t *testing.T) {
var mu sync.Mutex
var order []string
app := mountFor(t, "type Query { a: JSON }", func(ctx context.Context, kind string, s Selection) (any, error) {
time.Sleep(150 * time.Millisecond)
mu.Lock()
order = append(order, s.Key())
mu.Unlock()
return s.Key(), nil
})
start := time.Now()
if r := askAt(t, app, "/v1/g", `{ a1: a a2: a a3: a a4: a }`); len(r.Errors) != 0 {
t.Fatalf("query: %s", r.messages())
}
if took := time.Since(start); took > 450*time.Millisecond {
t.Errorf("four 150ms fields took %v: the query ran them one after another", took)
}
order = nil
start = time.Now()
if r := askAt(t, app, "/v1/g", `mutation { m1: a m2: a m3: a }`); len(r.Errors) != 0 {
t.Fatalf("mutation: %s", r.messages())
}
if took := time.Since(start); took < 450*time.Millisecond {
t.Errorf("three 150ms mutation fields took %v: they overlapped", took)
}
if strings.Join(order, ",") != "m1,m2,m3" {
t.Errorf("mutation fields ran as %v, want the order written", order)
}
}
// A field that panics on its own goroutine fails that field, not the process.
func TestAPanickingFieldFailsAlone(t *testing.T) {
app := mountFor(t, "type Query { a: JSON }", func(ctx context.Context, kind string, s Selection) (any, error) {
if s.Key() == "bad" {
panic("boom")
}
return "ok", nil
})
r := askAt(t, app, "/v1/g", `{ good: a bad: a }`)
if r.Data["good"] != "ok" || r.Data["bad"] != nil || !strings.Contains(r.messages(), "boom") {
t.Fatalf("data %#v, errors %s", r.Data, r.messages())
}
}
// ★ An answer's keys come in the order the request selected them, as the
// specification asks — not sorted, which is what a map would give.
func TestTheAnswerKeepsTheSelectionsOrder(t *testing.T) {
app := mountFor(t, "type Query { a: JSON }", func(ctx context.Context, kind string, s Selection) (any, error) {
return map[string]any{"z": 1, "m": map[string]any{"b": 1, "a": 2}, "a": 3}, nil
})
_, raw := rawAt(t, app, "/v1/g", GraphRequest{Query: `{ second: a { z m { b a } a } first: a { a } }`})
want := `{"data":{"second":{"z":1,"m":{"b":1,"a":2},"a":3},"first":{"a":3}}}`
if raw != want {
t.Fatalf("wire:\n got %s\nwant %s", raw, want)
}
}
// ── objects: a value that answers its own fields ─────────────────────────────
type gnode struct {
key string
asked *[]string
}
func (n gnode) Type() string { return "Node" }
func (n gnode) Field(ctx context.Context, s Selection) (any, error) {
*n.asked = append(*n.asked, n.key+"."+s.Key())
switch s.Name {
case "key":
return n.key, nil
case "next":
var in struct {
Step int `json:"step" validate:"required"`
}
if err := s.Bind(&in); err != nil {
return nil, err
}
return []*gnode{{key: fmt.Sprintf("%s+%d", n.key, in.Step), asked: n.asked}}, nil
case "none":
return (*gnode)(nil), nil
case "data":
return map[string]any{"x": 1, "y": 2}, nil
}
return nil, fmt.Errorf("no field %q on Node", s.Name)
}
// ★ Nesting is traversal: each object is asked for the fields selected on it,
// with that field's own arguments, so two aliases of one field with different
// arguments are two answers, and a field nobody selected is never computed.
func TestAnObjectAnswersItsOwnFields(t *testing.T) {
var asked []string
app := mountFor(t, "type Query { root: Node }", func(ctx context.Context, kind string, s Selection) (any, error) {
return gnode{key: "r", asked: &asked}, nil
})
r := askAt(t, app, "/v1/g", `{ root {
__typename
key
one: next(step: 1) { key }
two: next(step: 2) { key next(step: 3) { key } }
none { key }
data { y }
} }`)
if len(r.Errors) != 0 {
t.Fatalf("unexpected errors: %s", r.messages())
}
root := r.Data["root"].(map[string]any)
if root["__typename"] != "Node" || root["key"] != "r" {
t.Fatalf("root = %#v", root)
}
one := root["one"].([]any)[0].(map[string]any)
two := root["two"].([]any)[0].(map[string]any)
if one["key"] != "r+1" || two["key"] != "r+2" {
t.Fatalf("the aliases did not keep their arguments apart: one=%#v two=%#v", one, two)
}
if deep := two["next"].([]any)[0].(map[string]any); deep["key"] != "r+2+3" {
t.Fatalf("the second hop did not resolve: %#v", deep)
}
if root["none"] != nil {
t.Errorf("a nil object answered %#v, want null", root["none"])
}
if d := root["data"].(map[string]any); d["y"] != float64(2) || d["x"] != nil {
t.Errorf("plain data under an object was not projected: %#v", d)
}
for _, a := range asked {
if strings.HasSuffix(a, ".__typename") {
t.Errorf("__typename was asked of the object (%s) rather than read off its Type", a)
}
}
if len(asked) != 9 {
t.Errorf("the objects were asked %d times (%v), want once per selected field", len(asked), asked)
}
}
// An object's field failing fails that field, at its own path, and its siblings
// answer. An object with nothing selected on it, and a scalar with a selection,
// are named rather than answered.
func TestAnObjectsFailuresAreItsFieldsAlone(t *testing.T) {
var asked []string
app := mountFor(t, "type Query { root: Node }", func(ctx context.Context, kind string, s Selection) (any, error) {
return gnode{key: "r", asked: &asked}, nil
})
r := askAt(t, app, "/v1/g", `{ root { key nope next { key } } }`)
if root := r.Data["root"].(map[string]any); root["key"] != "r" || root["nope"] != nil || root["next"] != nil {
t.Fatalf("root = %#v", root)
}
if len(r.Errors) != 2 {
t.Fatalf("errors = %s, want the unknown field and the missing argument", r.messages())
}
for _, e := range r.Errors {
if len(e.Path) != 2 || e.Path[0] != "root" {
t.Errorf("error %q is at %v, want under root", e.Message, e.Path)
}
}
if !strings.Contains(r.messages(), `"step" is required`) {
t.Errorf("Bind did not apply the validate tag: %s", r.messages())
}
for q, want := range map[string]string{
`{ root }`: "needs a selection",
`{ root { next(step: 1) } }`: "needs a selection",
`{ root { key { x } } }`: "cannot have a selection",
`{ root { next(step: 1, extra: 2) { key } } }`: `unknown argument "extra"`,
`{ root { next(step: "x") { key } } }`: "invalid arguments",
} {
if got := askAt(t, app, "/v1/g", q); !strings.Contains(got.messages(), want) {
t.Errorf("%s: errors %q, want one saying %q", q, got.messages(), want)
}
}
}
// ── where the graph lives, and what it is told about the caller ─────────────
// ★ The schema is rendered for the caller asking, so a schema may differ by who
// is asking — an org's own fields are that org's schema.
func TestTheSchemaIsRenderedForTheCaller(t *testing.T) {
app := New(Config{AppName: "host", DisableStartupMessage: true})
app.MountGraphFor("/v1/g", func(ctx context.Context) (string, error) {
return "type Query { for_" + CallerOf(ctx).Org + ": String }", nil
}, nil)
if err := app.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
for _, org := range []string{"acme", "zeta"} {
req := httptest.NewRequest(http.MethodGet, "/v1/g", nil)
req.Header.Set(HeaderOrg, org)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if !strings.Contains(string(b), "for_"+org) {
t.Errorf("the schema %s was asked for did not see the caller: %s", org, b)
}
}
}
// A schema that cannot render is a 500 that says so, not an empty schema.
func TestASchemaThatFailsSaysSo(t *testing.T) {
app := New(Config{AppName: "host", DisableStartupMessage: true})
app.MountGraphFor("/v1/g", func(context.Context) (string, error) {
return "", errors.New("the composition failed")
}, nil)
if err := app.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
resp, err := app.Fiber().Test(httptest.NewRequest(http.MethodGet, "/v1/g", nil))
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError || !strings.Contains(string(b), "the composition failed") {
t.Fatalf("answered %d: %s", resp.StatusCode, b)
}
}
// A foreign graph's routes are the app's own and are declared, so a host routes
// to them and a document describes them; zip's projection of the app's own ops
// is control plane and is not.
func TestAForeignGraphIsDeclaredAndTheProjectionIsNot(t *testing.T) {
app := New(Config{AppName: "host", DisableStartupMessage: true})
app.Get("/v1/local", func(ctx context.Context, _ *resGet) (*resUser, error) { return nil, nil }, WithOperationID("local"))
app.MountGraphFor("/v1/g", func(context.Context) (string, error) { return "", nil }, nil)
app.MountGraph("/v1/own")
if err := app.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
for _, m := range []string{http.MethodGet, http.MethodPost} {
if !app.Declares(m, "/v1/g") {
t.Errorf("%s /v1/g is not declared", m)
}
if app.Declares(m, "/v1/own") {
t.Errorf("%s /v1/own, zip's projection, is declared", m)
}
}
}
// Introspection is refused with the address the schema IS at — the mount's own,
// not zip's control plane, which a foreign graph does not live at.
func TestIntrospectionNamesTheMountsAddress(t *testing.T) {
app := mountFor(t, "type Query { a: JSON }", func(context.Context, string, Selection) (any, error) { return nil, nil })
r := askAt(t, app, "/v1/g", `{ __schema { types { name } } __typename }`)
if !strings.Contains(r.messages(), "GET /v1/g") {
t.Errorf("the refusal does not say where the schema is: %s", r.messages())
}
if r.Data["__typename"] != "Query" {
t.Errorf("__typename = %#v, want Query", r.Data["__typename"])
}
}
// Headers is the request whole, for a resolver that relays it: the credential,
// the cookies, and what the edge set.
func TestHeadersRelaysTheRequest(t *testing.T) {
var got http.Header
app := mountFor(t, "type Query { a: JSON }", func(ctx context.Context, kind string, s Selection) (any, error) {
got = Headers(ctx)
return "ok", nil
})
rawAt(t, app, "/v1/g", GraphRequest{Query: `{ a }`},
"Authorization", "Bearer t", "Cookie", "s=1", HeaderOrg, "acme")
if got.Get("Authorization") != "Bearer t" || got.Get("Cookie") != "s=1" || got.Get(HeaderOrg) != "acme" {
t.Fatalf("headers = %v", got)
}
if Headers(context.Background()) != nil {
t.Error("a context with no request behind it reported headers")
}
}