mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
A host can serve a graph it does not own
A deployment that answers with many processes has one surface and no single registry that holds it. Describing that surface as a graph meant either a second GraphQL implementation in the host, or a schema naming fields nothing could run. So the resolver becomes an input. MountGraphFor takes a schema function and a Resolver; the parsing, variables, fragments, directives and projection are the same code a local graph runs, and only the answer to "what is this field" comes from elsewhere. The resolver is told the operation kind as well as the field, because there is no registry entry here to check a write against — the rule that a write is not reachable as a read still has to be enforceable, so it is enforceable where the knowledge is. A value with no Go type behind it is projected by its own keys, and a field absent from it is null rather than an error: with a type, an absent field and a misspelt one are distinguishable and the misspelt one is still named; without one there is nothing to name it against, and inventing an error would refuse valid answers. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+104
-3
@@ -77,6 +77,10 @@ type GraphError struct {
|
||||
// an op declares. That is what lets the same executor serve an HTTP POST, a
|
||||
// test, or an in-process caller without any of them holding a transport.
|
||||
func (a *App) GraphQL(ctx context.Context, req GraphRequest) GraphResponse {
|
||||
return a.graph(ctx, req, nil)
|
||||
}
|
||||
|
||||
func (a *App) graph(ctx context.Context, req GraphRequest, foreign Resolver) GraphResponse {
|
||||
doc, err := parseGraph(req.Query)
|
||||
if err != nil {
|
||||
return GraphResponse{Errors: []GraphError{{Message: err.Error()}}}
|
||||
@@ -105,6 +109,8 @@ func (a *App) GraphQL(ctx context.Context, req GraphRequest) GraphResponse {
|
||||
case strings.HasPrefix(s.name, "__"):
|
||||
e.fail(at, "introspection is not served here; the schema is published at GET "+GraphPath)
|
||||
data[key] = nil
|
||||
case foreign != nil:
|
||||
data[key] = e.callForeign(ctx, foreign, op.kind, s, at)
|
||||
default:
|
||||
f, ok := fields[s.name]
|
||||
if !ok {
|
||||
@@ -148,6 +154,31 @@ func (a *App) installGraph() {
|
||||
a.MountGraph(GraphPath)
|
||||
}
|
||||
|
||||
// Resolver answers one root field for a graph this app serves but does not own.
|
||||
//
|
||||
// kind is "query" or "mutation" — the operation the field was selected under. It
|
||||
// is passed because only the resolver knows whether a foreign field is safe to
|
||||
// read: this app has no registry entry to check, so the rule that a write is not
|
||||
// reachable as a read has to be enforced where the knowledge is.
|
||||
//
|
||||
// args arrive with variables already substituted, named as the schema publishes
|
||||
// them. The value returned is projected through the selection set exactly as a
|
||||
// local op's result is.
|
||||
type Resolver func(ctx context.Context, kind, field string, args map[string]any) (any, error)
|
||||
|
||||
// MountGraphFor serves a graph whose schema and resolver come from elsewhere.
|
||||
//
|
||||
// The parsing, the variables, the fragments, the directives and the projection
|
||||
// are the same ones a local graph gets — only the answer to "what is this field"
|
||||
// changes. That is the whole reason this exists: a deployment that answers with
|
||||
// many processes has one surface and should not grow a second GraphQL
|
||||
// implementation to describe it.
|
||||
//
|
||||
// schema is called per request, so a composed schema stays current.
|
||||
func (a *App) MountGraphFor(path string, schema func() string, resolve Resolver) {
|
||||
a.mountGraph(path, schema, resolve)
|
||||
}
|
||||
|
||||
// MountGraph serves the graph projection at path: GET renders the schema, POST
|
||||
// runs a request against it.
|
||||
//
|
||||
@@ -160,9 +191,13 @@ func (a *App) installGraph() {
|
||||
// mount this before it finishes registering: the document always describes the
|
||||
// registry as it stands when someone asks.
|
||||
func (a *App) MountGraph(path string) {
|
||||
a.mountGraph(path, a.GraphQLSDL, nil)
|
||||
}
|
||||
|
||||
func (a *App) mountGraph(path string, schema func() string, resolve Resolver) {
|
||||
a.control(fiber.MethodGet, path, func(fc fiber.Ctx) error {
|
||||
fc.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
return fc.SendString(a.GraphQLSDL())
|
||||
return fc.SendString(schema())
|
||||
})
|
||||
a.control(fiber.MethodPost, path, func(fc fiber.Ctx) error {
|
||||
var req GraphRequest
|
||||
@@ -170,7 +205,7 @@ func (a *App) MountGraph(path string) {
|
||||
if err := jsonenc.Unmarshal(fc.Body(), &req); err != nil {
|
||||
resp.Errors = []GraphError{{Message: "invalid request body: " + err.Error()}}
|
||||
} else {
|
||||
resp = a.GraphQL(callerContext(fc), req)
|
||||
resp = a.graph(callerContext(fc), req, resolve)
|
||||
}
|
||||
body, err := jsonenc.Marshal(resp)
|
||||
if err != nil {
|
||||
@@ -236,6 +271,45 @@ func (e *graph) call(ctx context.Context, op *registeredOp, s *gqlSel, path []an
|
||||
return e.project(node, op.OutType, s.sel, path)
|
||||
}
|
||||
|
||||
// callForeign resolves one field this app does not own.
|
||||
//
|
||||
// The resolver receives the operation kind as well as the field, because there is
|
||||
// no registry entry here to check a write against. Everything after the answer is
|
||||
// identical to a local field: the value is normalised through JSON and projected
|
||||
// through the selection set, so a caller cannot tell which side answered.
|
||||
func (e *graph) callForeign(ctx context.Context, r Resolver, kind string, s *gqlSel, path []any) any {
|
||||
args := make(map[string]any, len(s.args))
|
||||
for k, v := range s.args {
|
||||
rv, err := e.resolve(v)
|
||||
if err != nil {
|
||||
e.fail(path, err.Error())
|
||||
return nil
|
||||
}
|
||||
args[k] = rv
|
||||
}
|
||||
out, err := r(ctx, kind, s.name, args)
|
||||
if err != nil {
|
||||
e.fail(path, err.Error())
|
||||
return nil
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := jsonenc.Marshal(out)
|
||||
if err != nil {
|
||||
e.fail(path, err.Error())
|
||||
return nil
|
||||
}
|
||||
var node any
|
||||
if err := jsonenc.Unmarshal(raw, &node); err != nil {
|
||||
e.fail(path, err.Error())
|
||||
return nil
|
||||
}
|
||||
// No Go type: the resolver answered with a shape only it knows, so the decoded
|
||||
// JSON is the whole description and projection reads it directly.
|
||||
return e.project(node, nil, s.sel, path)
|
||||
}
|
||||
|
||||
// input builds the op's In from the field's arguments.
|
||||
//
|
||||
// Through JSON and the same decoder the REST seam uses, so a value binds here
|
||||
@@ -343,7 +417,7 @@ func (e *graph) project(node any, t reflect.Type, sel []*gqlSel, path []any) any
|
||||
}
|
||||
return node
|
||||
}
|
||||
if leaf && node != nil {
|
||||
if t != nil && leaf && node != nil {
|
||||
if _, obj := node.(map[string]any); !obj {
|
||||
e.fail(path, "field of scalar type cannot have a selection of subfields")
|
||||
return nil
|
||||
@@ -384,6 +458,19 @@ func (e *graph) object(n map[string]any, t reflect.Type, sel []*gqlSel, path []a
|
||||
}
|
||||
continue
|
||||
}
|
||||
if t == nil {
|
||||
// Nothing declares this value's shape, so a field that is absent is
|
||||
// absent — not a mistake. With a Go type the two are distinguishable
|
||||
// and a missing field IS named; here there is nothing to name it
|
||||
// against, and inventing an error would refuse valid answers.
|
||||
jk, found := keyOf(n, s.name)
|
||||
if !found {
|
||||
out[key] = nil
|
||||
continue
|
||||
}
|
||||
out[key] = e.project(n[jk], nil, s.sel, sub(path, key))
|
||||
continue
|
||||
}
|
||||
f, ok := fieldOf(t, s.name)
|
||||
if !ok {
|
||||
e.fail(sub(path, key), fmt.Sprintf("no field %q on this type", s.name))
|
||||
@@ -395,6 +482,20 @@ func (e *graph) object(n map[string]any, t reflect.Type, sel []*gqlSel, path []a
|
||||
return out
|
||||
}
|
||||
|
||||
// keyOf finds the decoded key a selected name refers to. The schema spells a
|
||||
// field with gqlName, so the key it came from is the one that spells the same.
|
||||
func keyOf(n map[string]any, name string) (string, bool) {
|
||||
if _, ok := n[name]; ok {
|
||||
return name, true
|
||||
}
|
||||
for k := range n {
|
||||
if gqlName(k) == name {
|
||||
return k, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// fieldOf finds the struct field a selected name refers to, by the same name the
|
||||
// schema published it under.
|
||||
func fieldOf(t reflect.Type, name string) (reflect.StructField, bool) {
|
||||
|
||||
+176
@@ -528,3 +528,179 @@ func TestAHostCanMountTheGraphAtItsOwnAddress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A deployment that answers with many processes has ONE surface. These pin the
|
||||
// seam that lets a host describe and serve that surface without a second GraphQL
|
||||
// implementation: the schema and the answer come from elsewhere, everything else
|
||||
// — parsing, variables, fragments, directives, projection — is the same code a
|
||||
// local graph runs.
|
||||
|
||||
func foreignApp(t *testing.T, r Resolver) *App {
|
||||
t.Helper()
|
||||
app := New(Config{AppName: "host", DisableStartupMessage: true})
|
||||
// One local op, so the app is a real one; the foreign fields are not in it.
|
||||
Get(app, "/v1/local", func(ctx context.Context, _ *resGet) (*resUser, error) {
|
||||
return &resUser{ID: "local"}, nil
|
||||
}, WithOperationID("local"))
|
||||
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
|
||||
}
|
||||
|
||||
func askAt(t *testing.T, app *App, path, query string, vars ...map[string]any) resReply {
|
||||
t.Helper()
|
||||
var v map[string]any
|
||||
if len(vars) > 0 {
|
||||
v = vars[0]
|
||||
}
|
||||
body, _ := json.Marshal(GraphRequest{Query: query, Variables: v})
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
var out resReply
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("reply is not JSON (%s): %v", raw, err)
|
||||
}
|
||||
out.code = resp.StatusCode
|
||||
return out
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
r := askAt(t, app, "/v1/fleet", `{ farUser(id: "u1") { id email } }`)
|
||||
if len(r.Errors) != 0 {
|
||||
t.Fatalf("unexpected errors: %s", r.messages())
|
||||
}
|
||||
got, _ := r.Data["farUser"].(map[string]any)
|
||||
if got["id"] != "u1" || got["email"] != "[email protected]" {
|
||||
t.Fatalf("the resolver's answer did not survive: %#v", got)
|
||||
}
|
||||
// A selection is still a FILTER, even with nothing declaring the shape.
|
||||
if _, leaked := got["secret"]; leaked {
|
||||
t.Fatalf("a field nobody asked for was returned: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ The resolver is told which operation selected the field, because this app has
|
||||
// no registry entry to check a write against — the rule that a write is not
|
||||
// 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) {
|
||||
kinds = append(kinds, kind)
|
||||
if kind != "mutation" {
|
||||
return nil, errors.New("that field is a write and this is a query")
|
||||
}
|
||||
return map[string]any{"id": "ok"}, nil
|
||||
})
|
||||
|
||||
r := askAt(t, app, "/v1/fleet", `query { farUser(id: "u1") { id } }`)
|
||||
if len(r.Errors) == 0 {
|
||||
t.Fatalf("the resolver could not refuse a write selected as a read: %#v", r.Data)
|
||||
}
|
||||
if len(kinds) == 0 || kinds[0] != "query" {
|
||||
t.Fatalf("the resolver was not told the kind: %#v", kinds)
|
||||
}
|
||||
m := askAt(t, app, "/v1/fleet", `mutation { farUser(id: "u1") { id } }`)
|
||||
if len(m.Errors) != 0 {
|
||||
t.Fatalf("the same field refused under mutation too: %s", m.messages())
|
||||
}
|
||||
}
|
||||
|
||||
// Variables, aliases and nesting are the executor's, not the resolver's — the
|
||||
// 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
|
||||
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 } } }`,
|
||||
map[string]any{"who": "u9"})
|
||||
if seen["id"] != "u9" {
|
||||
t.Fatalf("the variable did not reach the resolver: %#v", seen)
|
||||
}
|
||||
boss, _ := r.Data["a"].(map[string]any)["boss"].(map[string]any)
|
||||
if boss["id"] != "y" {
|
||||
t.Fatalf("a nested selection was not projected: %#v", r.Data)
|
||||
}
|
||||
if _, leaked := boss["email"]; leaked {
|
||||
t.Fatalf("an unselected nested field came back: %#v", boss)
|
||||
}
|
||||
}
|
||||
|
||||
// With no Go type there is nothing to tell a typo from an empty field, so an
|
||||
// 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) {
|
||||
return map[string]any{"id": "x"}, nil
|
||||
})
|
||||
r := askAt(t, app, "/v1/fleet", `{ farUser(id: "u1") { id email } }`)
|
||||
if len(r.Errors) != 0 {
|
||||
t.Fatalf("an absent field was reported as an error: %s", r.messages())
|
||||
}
|
||||
got, _ := r.Data["farUser"].(map[string]any)
|
||||
if got["email"] != nil {
|
||||
t.Fatalf("want null for the absent field, got %#v", got["email"])
|
||||
}
|
||||
}
|
||||
|
||||
// A composed schema changes as the deployment does, so it is rendered per request
|
||||
// rather than captured at mount.
|
||||
func TestTheForeignSchemaIsRenderedPerRequest(t *testing.T) {
|
||||
n := 0
|
||||
app := New(Config{AppName: "host", DisableStartupMessage: true})
|
||||
Get(app, "/v1/local", func(ctx context.Context, _ *resGet) (*resUser, error) { return nil, nil },
|
||||
WithOperationID("local"))
|
||||
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"))
|
||||
if string(first) == string(second) {
|
||||
t.Fatalf("the schema was captured at mount, not rendered per request:\n%s", first)
|
||||
}
|
||||
}
|
||||
|
||||
func mustGet(t *testing.T, app *App, path string) io.Reader {
|
||||
t.Helper()
|
||||
resp, err := app.Fiber().Test(httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return bytes.NewReader(b)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return nil, errors.New("should not be asked")
|
||||
})
|
||||
app.MountGraph("/v1/own")
|
||||
r := askAt(t, app, "/v1/own", `{ local(id: "x") { id } }`)
|
||||
if len(r.Errors) != 0 {
|
||||
t.Fatalf("the local graph broke: %s", r.messages())
|
||||
}
|
||||
if r.Data["local"].(map[string]any)["id"] != "local" {
|
||||
t.Fatalf("local op did not run: %#v", r.Data)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user