mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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:
@@ -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):
|
||||
|
||||
@@ -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
@@ -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.selection()
|
||||
return p.inline()
|
||||
}
|
||||
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) {
|
||||
|
||||
+645
-249
File diff suppressed because it is too large
Load Diff
+487
-14
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user