graph_weaver 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +4 -4
- data/README.md +40 -88
- data/docs/alternatives.md +1 -7
- data/docs/cassettes.md +54 -59
- data/docs/editors.md +32 -47
- data/docs/errors.md +261 -369
- data/docs/federation.md +650 -837
- data/docs/generated_modules.md +380 -463
- data/docs/getting_started.md +211 -428
- data/docs/i18n.md +114 -177
- data/docs/logging.md +127 -116
- data/docs/real_world.md +26 -39
- data/docs/scalars.md +277 -310
- data/docs/testing.md +343 -486
- data/docs/transports.md +203 -268
- data/docs/upgrading.md +211 -560
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +12 -3
- data/lib/graph_weaver/client.rb +30 -1
- data/lib/graph_weaver/codegen/emit.rb +5 -11
- data/lib/graph_weaver/codegen.rb +23 -55
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +23 -15
- data/lib/graph_weaver/federation.rb +11 -2
- data/lib/graph_weaver/graph.rb +39 -29
- data/lib/graph_weaver/in_process.rb +15 -9
- data/lib/graph_weaver/internal/endpoint.rb +7 -5
- data/lib/graph_weaver/internal/headers.rb +19 -0
- data/lib/graph_weaver/internal/test_clients.rb +7 -11
- data/lib/graph_weaver/internal.rb +81 -13
- data/lib/graph_weaver/log_subscriber.rb +10 -2
- data/lib/graph_weaver/logging.rb +33 -13
- data/lib/graph_weaver/query_module.rb +44 -23
- data/lib/graph_weaver/retry.rb +12 -8
- data/lib/graph_weaver/rspec.rb +13 -24
- data/lib/graph_weaver/schema_loader.rb +52 -14
- data/lib/graph_weaver/tasks.rb +10 -2
- data/lib/graph_weaver/testing/cassette.rb +28 -5
- data/lib/graph_weaver/testing/endpoint.rb +14 -13
- data/lib/graph_weaver/testing/fake_client.rb +33 -3
- data/lib/graph_weaver/testing/router.rb +7 -3
- data/lib/graph_weaver/testing.rb +12 -4
- data/lib/graph_weaver/transport/http.rb +2 -2
- data/lib/graph_weaver/transport.rb +47 -23
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +32 -10
- metadata +16 -3
- data/CHANGELOG.md +0 -3801
data/docs/federation.md
CHANGED
|
@@ -1,36 +1,31 @@
|
|
|
1
1
|
# Federation
|
|
2
2
|
|
|
3
|
-
For an app that is a client of a federated graph, a subgraph in one, or both.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
-
|
|
17
|
-
supergraph](#generating-against-a-supergraph) and [the local
|
|
18
|
-
router](#the-local-router) are the whole document for you. Skip
|
|
19
|
-
[producing a supergraph](#producing-a-supergraph) and [has the supergraph
|
|
20
|
-
been recomposed?](#has-the-supergraph-been-recomposed) — those are the
|
|
21
|
-
composer's.
|
|
3
|
+
For an app that is a client of a federated graph, a subgraph in one, or both. Two
|
|
4
|
+
halves: **generating** against a composed supergraph, which mostly means "point it
|
|
5
|
+
at the file and forget", and the **local router**, which runs a stitched query
|
|
6
|
+
against your own resolvers in-process so specs need no gateway. Any federation
|
|
7
|
+
artifact works — a supergraph, an API schema, a subgraph SDL, or a live router;
|
|
8
|
+
`SchemaLoader.load` (and `Client.new(path_or_sdl)`) recognize which they got, as an
|
|
9
|
+
SDL file or an introspection dump.
|
|
10
|
+
|
|
11
|
+
**Which half is yours** depends on what your app does with the graph, and most apps
|
|
12
|
+
are only one:
|
|
13
|
+
|
|
14
|
+
- **You call the gateway and compose nothing.** [Generating for a federated
|
|
15
|
+
graph](#generating-for-a-federated-graph) and [the local
|
|
16
|
+
router](#the-local-router) are the whole document for you.
|
|
22
17
|
- **You publish a subgraph.** Add [generating against a
|
|
23
|
-
subgraph](#generating-against-a-subgraph) and
|
|
24
|
-
|
|
25
|
-
- **You own the supergraph.** All of it, plus [
|
|
26
|
-
|
|
27
|
-
|
|
18
|
+
subgraph](#generating-against-a-subgraph) and [a subgraph that calls its own
|
|
19
|
+
graph](#a-subgraph-that-calls-its-own-graph).
|
|
20
|
+
- **You own the supergraph.** All of it, plus [producing
|
|
21
|
+
one](#producing-a-supergraph), [`federation:diff`](#has-the-supergraph-been-recomposed)
|
|
22
|
+
and [in CI](#in-ci).
|
|
28
23
|
|
|
29
24
|
## Generating for a federated graph
|
|
30
25
|
|
|
31
26
|
**Queries go through the gateway?** Generate against the supergraph. It is the
|
|
32
|
-
whole graph in one schema, so every registration matches and there is nothing
|
|
33
|
-
|
|
27
|
+
whole graph in one schema, so every registration matches and there is nothing else
|
|
28
|
+
to decide.
|
|
34
29
|
|
|
35
30
|
**Calling subgraphs directly?** One graph per subgraph, declared once:
|
|
36
31
|
|
|
@@ -42,289 +37,467 @@ GraphWeaver.graph :billing do
|
|
|
42
37
|
namespace "Billing"
|
|
43
38
|
register_scalar "Money", Money
|
|
44
39
|
end
|
|
45
|
-
|
|
46
|
-
GraphWeaver.graph :directory do
|
|
47
|
-
schema "directory.graphql"
|
|
48
|
-
queries "app/graphql/directory"
|
|
49
|
-
output "app/graphql/generated/directory"
|
|
50
|
-
namespace "Directory"
|
|
51
|
-
register_scalar "Person.birthday", Date
|
|
52
|
-
end
|
|
53
40
|
```
|
|
54
41
|
|
|
55
|
-
One `rake graph_weaver:generate` generates
|
|
56
|
-
to the registrations declared for it. (Naming a live subgraph *class* from a
|
|
42
|
+
One `rake graph_weaver:generate` generates every graph, and each subgraph is held
|
|
43
|
+
only to the registrations declared for it. (Naming a live subgraph *class* from a
|
|
57
44
|
Rails initializer takes a lambda — `schema -> { Billing::Schema }` — see
|
|
58
45
|
[getting started](getting_started.md#more-than-one-schema).)
|
|
59
46
|
|
|
60
47
|
Registrations made at the *top* level still reach every graph, because names
|
|
61
|
-
compose by identity across a graph — `Money` is one Ruby type wherever it
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
the list once per run, after the files:
|
|
48
|
+
compose by identity across a graph — `Money` is one Ruby type wherever it appears,
|
|
49
|
+
and `Person` is one entity even though a single subgraph owns `birthday`. So a
|
|
50
|
+
registration a given subgraph doesn't declare is not an error; generation warns
|
|
51
|
+
and carries on, printing the list once per run:
|
|
66
52
|
|
|
67
53
|
```
|
|
68
54
|
register_scalar("Money") matches no scalar in Billing::Schema — a typo, or a registration for another schema
|
|
69
55
|
```
|
|
70
56
|
|
|
71
|
-
`GraphWeaver.unmatched_registrations` is that same list as data,
|
|
72
|
-
|
|
73
|
-
|
|
57
|
+
`GraphWeaver.unmatched_registrations` is that same list as data, and moving a
|
|
58
|
+
registration into the graph block that needs it is what makes the lines go away.
|
|
59
|
+
Entity fields work the same way: a subgraph carrying `Person` for its `@key` alone
|
|
60
|
+
sees a top-level `register_scalar("Person.birthday", Date)` as a field it doesn't
|
|
61
|
+
own — a warning, not a failure. What a subgraph *can* disprove still fails
|
|
62
|
+
generation: a name it declares as something else, or a coordinate whose field it
|
|
63
|
+
declares as a composite. Neither is redeemable by any schema in the graph.
|
|
74
64
|
|
|
75
|
-
|
|
76
|
-
every subgraph referencing an entity declares it, so a subgraph carrying
|
|
77
|
-
`Person` for its `@key` alone sees a top-level
|
|
78
|
-
`register_scalar("Person.birthday", Date)` as a field it doesn't own — a
|
|
79
|
-
warning, not a failure.
|
|
65
|
+
### Generating against a supergraph
|
|
80
66
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
67
|
+
A supergraph SDL works as-is. On load, GraphWeaver strips the composition
|
|
68
|
+
machinery — the synthetic `join__*`/`link__*` types and directive definitions, and
|
|
69
|
+
every `@join__*`/`@link` application on the real types — so codegen sees the
|
|
70
|
+
merged graph's ordinary type shapes. Field shapes (nullability, args, enums,
|
|
71
|
+
inputs) are identical to the API schema, so your generated structs are correct.
|
|
85
72
|
|
|
86
|
-
|
|
73
|
+
**Which names count as machinery is read off the schema**, not a fixed list.
|
|
74
|
+
Federation namespaces itself through [`@link`](https://specs.apollo.dev/link/v1.0/)
|
|
75
|
+
(v2) or [`@core`](https://specs.apollo.dev/core/v0.2/) (v1), and those declarations
|
|
76
|
+
are applied as written: the spec URL's name segment gives the namespace
|
|
77
|
+
(`https://specs.apollo.dev/join/v0.3` → `join__`), `as:` renames it, `import:`
|
|
78
|
+
binds names into the root namespace. So a fed 2.5+ graph's `@requiresScopes` /
|
|
79
|
+
`@policy` / `@context` machinery strips the same way `join__` does, a renamed
|
|
80
|
+
`@inaccessible` still hides what it marks, and v1 supergraphs load identically. A
|
|
81
|
+
schema that declares nothing still gets the `join__`/`link__`/`core__` floor.
|
|
82
|
+
|
|
83
|
+
A supergraph is also a **superset** of the API schema: it carries elements the
|
|
84
|
+
public API hides, marked `@inaccessible`. Loading strips every `@inaccessible`
|
|
85
|
+
element and cascades — a field, argument, union member or interface referencing a
|
|
86
|
+
removed type goes too, and a type left empty is removed in turn — so codegen
|
|
87
|
+
validates against what clients can actually query, with no need for Apollo's JS
|
|
88
|
+
tooling to subtract the API schema first. (The derivation is diffed against
|
|
89
|
+
Apollo's own `composeServices` + `toAPISchema()` in
|
|
90
|
+
[`spec/integration/api_schema_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/integration/api_schema_spec.rb).)
|
|
91
|
+
Two bounds: the directive is matched by the **local name it was linked under**, so
|
|
92
|
+
an `import:` alias subtracts what that alias marks; and the subtraction runs **only
|
|
93
|
+
on the supergraph path** — plain and subgraph SDL are taken at face value.
|
|
94
|
+
Directives that hide nothing keep their field: `@requiresScopes` / `@policy` /
|
|
95
|
+
`@authenticated` enforce at runtime, `@tag` / `@requires` / `@provides` /
|
|
96
|
+
`@external` are metadata.
|
|
87
97
|
|
|
88
|
-
|
|
89
|
-
takes one SDL file per subgraph, so the question is where each file comes from.
|
|
98
|
+
### Contracts and variants
|
|
90
99
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
100
|
+
A contract variant is a supergraph built from the same subgraphs with some
|
|
101
|
+
coordinates filtered out: a subgraph marks them `@tag(name: "internal")`, and
|
|
102
|
+
GraphOS builds a second supergraph where everything carrying that tag is
|
|
103
|
+
`@inaccessible`. Nothing special is needed to generate against one — the
|
|
104
|
+
subtraction above is exactly what makes it the narrower schema its clients see. A
|
|
105
|
+
query selecting an internal-only field generates against **internal** and, against
|
|
106
|
+
**public**, fails codegen with a `QueryValidationError` naming the field, because
|
|
107
|
+
the load subtracted it before validation saw it. That is the guarantee: a client
|
|
108
|
+
generated against the variant it calls cannot select something the router will
|
|
109
|
+
reject.
|
|
110
|
+
|
|
111
|
+
One app calling two variants is
|
|
112
|
+
[two graphs](getting_started.md#more-than-one-schema), each with its own `schema`,
|
|
113
|
+
`queries`, `output` and a `namespace` if any module name would collide; a variant
|
|
114
|
+
is mechanically just another graph. But **nothing cross-checks the variant you
|
|
115
|
+
generated against with the endpoint you call** — generate against internal, deploy
|
|
116
|
+
against public, and every check stays green until a live 400 — and
|
|
117
|
+
**`federation:diff` can't tell the variants apart** either, since it reads the
|
|
118
|
+
routing table and `@inaccessible` is a directive the table doesn't carry. Point
|
|
119
|
+
each graph at the variant it actually calls.
|
|
120
|
+
|
|
121
|
+
## The local router
|
|
122
|
+
|
|
123
|
+
Specs for a federated app have a bad choice: fake the whole graph, or boot a
|
|
124
|
+
gateway. `Testing::Router` is the third one. It takes the composed supergraph and
|
|
125
|
+
the Ruby schema classes serving its subgraphs, plans the query, and satisfies the
|
|
126
|
+
[client contract](transports.md) — so a generated module runs against your **real
|
|
127
|
+
resolvers**, in-process, with no gateway, no node and no sockets. It is not a mock:
|
|
128
|
+
your resolvers run, which is the whole point.
|
|
129
|
+
|
|
130
|
+
In rspec that's the [`graphql: :router`](testing.md#a-federated-graph--graphql-router)
|
|
131
|
+
tag and there is nothing to pass — the tag builds it, once for the suite. It finds
|
|
132
|
+
the supergraph where you have already said it is: the schema a
|
|
133
|
+
[graph](getting_started.md#more-than-one-schema) declares when that schema is
|
|
134
|
+
composed, else `Testing.config.router = { supergraph: … }` if you named one
|
|
135
|
+
there, else the committed dump when *that* is, else the dump your own client was
|
|
136
|
+
built from (`GraphWeaver.new("supergraph.graphql")` keeps its path). Two graphs
|
|
137
|
+
may name one supergraph; two naming different ones is refused rather than picked
|
|
138
|
+
between.
|
|
139
|
+
Outside rspec, build it yourself:
|
|
95
140
|
|
|
96
141
|
```ruby
|
|
97
|
-
|
|
142
|
+
GraphWeaver.client = GraphWeaver::Testing::Router.new(
|
|
143
|
+
supergraph: Rails.root.join("supergraph.graphql"),
|
|
144
|
+
context: { current_user: user },
|
|
145
|
+
)
|
|
98
146
|
```
|
|
99
147
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
148
|
+
`context:` reaches every subgraph, because every subgraph is a Ruby call here. A
|
|
149
|
+
request's **headers** don't: the gateway and the Apollo Router both start a subgraph
|
|
150
|
+
call with none of the client's unless you configure forwarding, so don't let a spec
|
|
151
|
+
conclude an auth header arrived somewhere it wouldn't.
|
|
104
152
|
|
|
105
|
-
`
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
153
|
+
`router.trace` records the fetches made since the last `reset_trace`, in order
|
|
154
|
+
(subgraph, query, variables); the same lines go to `GraphWeaver.logger` at
|
|
155
|
+
`:debug`, and the rspec tag resets it before each example. It **accumulates across
|
|
156
|
+
executes**, because the question worth asking is which subgraphs a code path
|
|
157
|
+
touched. But the count is the **local router's plan, not the gateway's**: the data
|
|
158
|
+
is faithful — a real gateway answers byte-identically, or this refuses — while a
|
|
159
|
+
[`@requires` prefetch](#what-it-plans) is still its own call where a gateway merges
|
|
160
|
+
it into the read beside it. So assert on a **bound**
|
|
161
|
+
(`expect(router.trace.size).to be <= 8`) or on the **subgraph set**
|
|
162
|
+
(`router.trace.map { _1[:subgraph] }.uniq`): both move when an N+1 appears, and
|
|
163
|
+
neither pins a number production doesn't have.
|
|
112
164
|
|
|
113
|
-
|
|
114
|
-
|
|
165
|
+
The router hands back a result hash *above* the wire, so the transport your app
|
|
166
|
+
ships never runs. When that transport is the thing under test — a caller tag, an
|
|
167
|
+
APM header, mTLS — [`graphql: :wire`](testing.md#over-the-wire--graphql-wire) serves
|
|
168
|
+
this same router at the endpoint your client posts to and leaves your client in
|
|
169
|
+
place: real serialization, the same plan over the same resolvers, `from_h` over the
|
|
170
|
+
server's own bytes, and a `context:` proc reading the headers that arrived. It
|
|
171
|
+
refuses exactly what the router refuses — a hop, not a capability — and the hop is
|
|
172
|
+
served through webmock, so that tag needs `require "webmock/rspec"`.
|
|
173
|
+
|
|
174
|
+
**[`examples/federation.rb`](https://github.com/dpep/graph_weaver/blob/main/examples/federation.rb)**
|
|
175
|
+
is the whole shape in one runnable file, and
|
|
176
|
+
[`spec/router_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/router_spec.rb)
|
|
177
|
+
is the exhaustive reference — every plan shape and every refusal, each as a named
|
|
178
|
+
example.
|
|
115
179
|
|
|
116
|
-
|
|
117
|
-
reads a config naming each subgraph's routing url and SDL file, and prints the
|
|
118
|
-
supergraph to stdout:
|
|
180
|
+
### What it refuses
|
|
119
181
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
schema: { file: ./accounts.graphql }
|
|
127
|
-
```
|
|
182
|
+
Everything it can't plan **faithfully** raises `GraphWeaver::Testing::Unplannable`
|
|
183
|
+
(a `GraphWeaver::Error`), at plan time, before any subgraph runs — so a refusal is
|
|
184
|
+
never a half-executed query. A double that approximated the rest of Apollo's
|
|
185
|
+
planner would let a test pass on an answer production disagrees with, which is the
|
|
186
|
+
most expensive thing this library can produce. Each refusal names the coordinate
|
|
187
|
+
that stopped it and what to do.
|
|
128
188
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
--elv2-license accept > supergraph.graphql
|
|
132
|
-
```
|
|
189
|
+
Every category, in the words `Unplannable#label` uses (`#category` is the matching
|
|
190
|
+
symbol):
|
|
133
191
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
192
|
+
| Refusal | Why |
|
|
193
|
+
|---|---|
|
|
194
|
+
| no `@key` to cross the boundary on | an entity fetch sends a representation built from a `@key`; with none there is nothing to send |
|
|
195
|
+
| an abstract type the supergraph doesn't break down | bucketing needs the concrete types a subgraph answers a union or interface with, and `@join__unionMember`/`@join__implements` is where a supergraph records that. A composition old enough to carry neither leaves nothing but a guess |
|
|
196
|
+
| an `@interfaceObject` the routing table can't attribute | one subgraph resolves a whole interface's implementations, so the supergraph never says which subgraph answers each of its fields. Per query, not per graph: a query that doesn't reach the type plans as if the directive weren't there |
|
|
197
|
+
| a `@fromContext` argument no fetch here can supply | federation 2.8's `@context`/`@fromContext` fills a field's argument from a selection on an ancestor, and only the gateway that planned the fetch knows what to put there — a subgraph's own resolver never fills one, so this is refused on any path, including the one where a single subgraph answers the whole query. Per query, like `@interfaceObject` |
|
|
198
|
+
| a response delivered in more than one payload | `@defer`/`@stream` stream the rest of the answer over a multipart body after the first payload, and this router answers in one. Refused by name rather than left to validation, so the guarantee doesn't rest on whether the composed schema happens to declare the directive |
|
|
199
|
+
| a progressive `@override` still rolling out | federation 2.7's `@override(label:)` leaves *both* subgraphs resolving the field — the gateway splits traffic per request by the label's rule, and a local router can't evaluate a rollout percentage. Finish the rollout (drop the label) and composition drops the losing copy, which plans normally |
|
|
200
|
+
| a `@requires` whose field set names another `@requires` field | the router satisfies a `@requires` with one fetch, so it can't first satisfy that field's own requirement |
|
|
201
|
+
| a nested field set no one fetch can build | a nested field set crosses as one object, so one fetch has to answer the whole of it. Nesting itself is fine — this is the set whose fields are split across subgraphs, so the object would arrive half-built from each |
|
|
202
|
+
| `@skip`/`@include` on both a fragment and its field | one selection can't carry two conditions of the same name. Spell the condition once |
|
|
203
|
+
| an alias shadowing an injected `@key` | a fetch carries the `@key` it crosses on under a response key — Apollo under the field's own name, the local router under a reserved one — and an alias spelling either claims a key the fetch needs |
|
|
204
|
+
| a mutation's root fields span subgraphs | root mutation fields run in series, and splitting them across subgraphs would run them in whatever order the plan happened to. Sharing one subgraph they're fine, stitching below them and all. Query roots are independent, so those are always fine |
|
|
205
|
+
| the routing table names no subgraph | nothing can route a field the supergraph doesn't place |
|
|
206
|
+
| a subgraph nothing here serves | it's served by another process, so there is nothing here to ask — unless you [fake it](#a-supergraph-only-partly-local) |
|
|
207
|
+
| introspection mixed with data | introspection is answered from the composed API schema and data from the subgraphs, and the two can't be merged. Split them into two operations |
|
|
208
|
+
| the document isn't one operation | pass `operation_name:` naming one of them |
|
|
209
|
+
| not a query or a mutation | the router plans against the composed schema's query and mutation roots; a subscription has neither |
|
|
210
|
+
| a fragment the document never defines | define it, or point the query at the file that does — validation rejects it first, so what you actually get back is an `errors` response |
|
|
211
|
+
| a federation construct the routing table doesn't read | an incomplete table makes every answer about this supergraph a guess. The one refusal raised **at construction**, before a single query |
|
|
212
|
+
| nested deeper than the router walks | past the walk's depth limit, which validation would have rejected first |
|
|
139
213
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
214
|
+
A document that fails ordinary GraphQL validation never reaches any of this: it
|
|
215
|
+
gets the same `errors` response a plain client gets. The construction-time refusal
|
|
216
|
+
is worth planning around, though — a `@join__` directive the table doesn't read
|
|
217
|
+
refuses `Router.new` for the **whole graph**, so one team adopting a newer
|
|
218
|
+
federation feature is an upgrade-timing event for every team that tests with
|
|
219
|
+
`:router`. A subgraph two loaded schemas both fit raises a `ConfigurationError`
|
|
220
|
+
instead, being a wiring mistake rather than a query the router declines, but it
|
|
221
|
+
raises where every other one does: on the query that reaches the subgraph.
|
|
145
222
|
|
|
146
|
-
|
|
223
|
+
### Which schema serves which subgraph
|
|
147
224
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
225
|
+
`subgraphs:` is optional. Left out, each one is **derived from what the loaded
|
|
226
|
+
schemas define**: a schema serves subgraph `s` when it defines every type and field
|
|
227
|
+
the routing table says `s` resolves, plus at least one coordinate attributed to
|
|
228
|
+
that subgraph **alone** (what two subgraphs share can't tell them apart). That's
|
|
229
|
+
evidence rather than a guess, and a wrong guess would point a suite at the wrong
|
|
230
|
+
resolvers and still pass — so exactly one match is used. Neither other outcome
|
|
231
|
+
refuses at construction, since which classes are loaded is not a fact about the
|
|
232
|
+
query you're running: **no** match means the subgraph is served somewhere else
|
|
233
|
+
(below), and **two** means detection can't say which class serves it. Both are
|
|
234
|
+
refused by the query that reaches the subgraph's fields, each naming its own fix —
|
|
235
|
+
for two, `subgraphs: { "reviews" => App::Reviews::Schema }` pins it, and
|
|
236
|
+
`router.ambiguous` lists them.
|
|
154
237
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
namespace (`https://specs.apollo.dev/join/v0.3` → `join__`), `as:` renames it,
|
|
160
|
-
`import:` binds names into the root namespace. So a fed 2.5+ graph's
|
|
161
|
-
`@requiresScopes` / `@policy` / `@context` machinery strips the same way
|
|
162
|
-
`join__` does, a renamed `@inaccessible` still hides what it marks, and v1
|
|
163
|
-
supergraphs (`@core` + `@join__owner`) load identically. A schema that declares
|
|
164
|
-
nothing still gets the `join__`/`link__`/`core__` floor.
|
|
165
|
-
|
|
166
|
-
### `@inaccessible`
|
|
167
|
-
|
|
168
|
-
A supergraph is a **superset** of the API schema: it carries elements the public
|
|
169
|
-
API hides, marked `@inaccessible`. You'll meet the directive rolling out a change
|
|
170
|
-
to a **shared type** — add the field to one subgraph marked `@inaccessible` so
|
|
171
|
-
composition doesn't require every subgraph to have it yet, roll it out, then drop
|
|
172
|
-
the directive to publish it. Apollo **contracts** use the same directive the
|
|
173
|
-
other way round, and get a section of their own [below](#contracts-and-variants).
|
|
174
|
-
|
|
175
|
-
Loading strips every `@inaccessible` element and cascades: a
|
|
176
|
-
field/argument/union-member/interface referencing a removed type goes too, and a
|
|
177
|
-
type left empty is removed in turn. So codegen validates against what clients can
|
|
178
|
-
actually query, with no need for Apollo's JS tooling to subtract the API schema
|
|
179
|
-
first — feed it the raw supergraph and you get the router's contract. The
|
|
180
|
-
derivation is diffed against Apollo's own `composeServices` + `toAPISchema()` in
|
|
181
|
-
[`spec/integration/api_schema_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/integration/api_schema_spec.rb).
|
|
182
|
-
|
|
183
|
-
Two bounds. The directive is matched by the **local name it was linked under**, so
|
|
184
|
-
an `import:` alias subtracts what that alias marks. And the subtraction runs
|
|
185
|
-
**only on the supergraph path** — plain and subgraph SDL are taken at face value,
|
|
186
|
-
where `@inaccessible` stays a directive and its fields stay queryable. Directives
|
|
187
|
-
that hide nothing keep their field and are ignored: `@requiresScopes` / `@policy`
|
|
188
|
-
/ `@authenticated` enforce at runtime, `@tag` / `@requires` / `@provides` /
|
|
189
|
-
`@external` are metadata.
|
|
238
|
+
Name them yourself when you'd rather have the wiring committed, or when detection
|
|
239
|
+
can't settle it — including partially, with the rest derived. Either way the map is
|
|
240
|
+
**checked** at construction, so a swapped pair fails naming what's missing rather
|
|
241
|
+
than surfacing as a mystery three fetches later:
|
|
190
242
|
|
|
191
|
-
|
|
243
|
+
```
|
|
244
|
+
subgraphs["accounts"] is Products::Schema, which doesn't define Query.me,
|
|
245
|
+
Query.user, Query.users, User, User.email and 1 more — the supergraph says
|
|
246
|
+
accounts resolves them. Did two entries get swapped?
|
|
247
|
+
```
|
|
192
248
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
supportTier: String! @tag(name: "internal")
|
|
249
|
+
Detection only sees what's **loaded**, and in Rails an autoloaded schema isn't until
|
|
250
|
+
something references it — which is why an unmatched subgraph reads as absent. The
|
|
251
|
+
`federation:*` rake tasks eager-load the app for you; a spec suite is your own
|
|
252
|
+
`config.eager_load`, which Rails leaves off outside CI. To see what detection sees,
|
|
253
|
+
and get a map to paste:
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
$ rake graph_weaver:federation:subgraphs
|
|
257
|
+
subgraphs: {
|
|
258
|
+
"accounts" => Accounts::Schema, # matched: defines Query.me, Query.user, Query.users
|
|
259
|
+
"products" => Products::Schema, # matched: defines Product.name, Product.price, Product.weight
|
|
205
260
|
}
|
|
206
261
|
```
|
|
207
262
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
263
|
+
A row nothing matched comes back `nil`, naming the coordinates it looked for —
|
|
264
|
+
that's the map to fill in, or the subgraph that lives elsewhere.
|
|
265
|
+
|
|
266
|
+
### A supergraph only partly local
|
|
267
|
+
|
|
268
|
+
The usual migration shape: the supergraph is composed from several services and
|
|
269
|
+
only **some** of them run in your process. Requiring a Ruby schema for the rest
|
|
270
|
+
would refuse the whole suite over fields most of your queries never touch, so a
|
|
271
|
+
subgraph nothing here defines is **absent**, and the router builds and runs anyway.
|
|
272
|
+
Absence costs you exactly the queries that reach into it:
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
router.absent # => ["shipping"]
|
|
276
|
+
router.execute("{ me { username reviews { body } } }") # real data, as always
|
|
277
|
+
router.execute("{ shipments { carrier } }") # GraphWeaver::Testing::Unplannable
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
That refusal is a plan-time one like every other, so nothing has executed when it
|
|
281
|
+
raises, and it names the subgraph, the field that reached for it, and both ways out
|
|
282
|
+
— name a schema for it, or fake it:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
subgraphs: { "shipping" => :fake } # any other absent subgraph still refuses
|
|
213
286
|
```
|
|
214
287
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
`
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
One app calling two variants is [two graphs](getting_started.md#more-than-one-schema)
|
|
235
|
-
— each with its own `schema`, `queries`, `output` and a `namespace` if any
|
|
236
|
-
module name would collide. There is no contract-specific spelling; a variant is
|
|
237
|
-
mechanically just another graph.
|
|
288
|
+
If the subgraph *is* here and detection just couldn't see it — a Rails schema class
|
|
289
|
+
nothing has referenced yet — loading it is the fix, which in a spec suite means
|
|
290
|
+
`config.eager_load = true`. `=> :fake` is the other one: mid-migration, letting an
|
|
291
|
+
absent subgraph answer with schema-correct fabricated data exercises the rest of
|
|
292
|
+
the query. It speaks the whole subgraph contract, `_entities(representations:)`
|
|
293
|
+
included, so it works under a stitched fetch as well as at a root field.
|
|
294
|
+
|
|
295
|
+
Refusing stays the default, and the opt-in is **per subgraph** on purpose: silently
|
|
296
|
+
substituting invented data is the failure mode this library keeps designing
|
|
297
|
+
against. For the same reason faking is **loud** — every faked fetch is marked
|
|
298
|
+
`faked: true` in `router.trace` and logged at `:warn`, `router.faked` lists them,
|
|
299
|
+
and `router.inspect` shows what's served, faked and absent. Values come from the
|
|
300
|
+
same engine as [`graphql: :fake`](testing.md#fabricated-data--graphql-fake), and
|
|
301
|
+
`fake:` says how they fabricate: the [pins](testing.md#pins) and options a fake
|
|
302
|
+
takes, in one hash, on `Router.new` outside rspec, on `Testing.config.router` for
|
|
303
|
+
the suite, or on `graphql_router(fake: { "Shipment.carrier" => "UPS" })` for the one
|
|
304
|
+
example that cares. One `fake:` covers every faked subgraph, since a pin's key
|
|
305
|
+
already says which type it means, and the router is still built once for the suite —
|
|
306
|
+
only the options last one example.
|
|
238
307
|
|
|
239
|
-
|
|
308
|
+
## Generating against a subgraph
|
|
240
309
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
310
|
+
A raw subgraph SDL — `rover subgraph fetch`, `_service { sdl }`, or the `.graphql`
|
|
311
|
+
in a service repo — loads too. It applies `@key`/`@external`/`@shareable`/… without
|
|
312
|
+
declaring them (federation v1 leaves them implicit, v2 imports them via `@link`),
|
|
313
|
+
so the missing definitions are supplied on load; anything the file declares itself
|
|
314
|
+
wins. Whatever the `@link` header says the directives are called is what's supplied
|
|
315
|
+
— the bare `@key`, the namespaced `@federation__key`, or `@primaryKey` from
|
|
316
|
+
`import: [{name: "@key", as: "@primaryKey"}]`. The header itself is read and then
|
|
317
|
+
dropped: it describes the file, not the graph. The federation directives generate
|
|
318
|
+
no code either way — codegen is query-driven.
|
|
319
|
+
|
|
320
|
+
Reach for this when the subgraph is what you have, or to type an `_entities` query.
|
|
321
|
+
But a subgraph is one service's slice of the graph, and its field shapes are not
|
|
322
|
+
always the composed ones (an `@external` field is a reference, not something that
|
|
323
|
+
subgraph serves) — for a client of the whole graph, feed the composed artifact.
|
|
324
|
+
|
|
325
|
+
### `_entities`
|
|
326
|
+
|
|
327
|
+
Every subgraph serves the entity resolver
|
|
328
|
+
`_entities(representations: [_Any!]!): [_Entity]!`, and **no subgraph SDL contains
|
|
329
|
+
it**: `_service { sdl }` and `rover subgraph fetch` print the *published* schema,
|
|
330
|
+
where the plumbing is implicit. So it's supplied on the subgraph path — `_Any`,
|
|
331
|
+
`_Service`, and an `_Entity` union over the file's own `@key`'d types — the same
|
|
332
|
+
way the `@key`/`@external` definitions are. A file that declares its own keeps it.
|
|
333
|
+
|
|
334
|
+
The read side is a normal union selection; `alias:` turns the single-entity case
|
|
335
|
+
into a clean accessor (see
|
|
336
|
+
[flat accessors](generated_modules.md#flat-accessors-with-alias)):
|
|
244
337
|
|
|
245
338
|
```ruby
|
|
246
|
-
|
|
339
|
+
GraphWeaver.extend_type("Query", alias: { entity: "_entities.first" }, optional: true)
|
|
340
|
+
```
|
|
247
341
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
342
|
+
The **input** side is generated. A representation must carry `__typename` and
|
|
343
|
+
satisfy one of the entity's `@key` field sets — both hard requirements of the
|
|
344
|
+
subgraph spec, and neither expressible in a bare `[_Any!]!` — so a query selecting
|
|
345
|
+
entities gets a `Representations` builder per entity it reaches, typed from the
|
|
346
|
+
`@key` directives:
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
UserQuery::Representations.user(id: "1")
|
|
350
|
+
# => {"__typename" => "User", "id" => "1"}
|
|
351
|
+
|
|
352
|
+
UserQuery.execute(reps: [UserQuery::Representations.user(id: "1")])
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Key field sets are selection sets, so they're parsed as such:
|
|
356
|
+
|
|
357
|
+
| `@key(fields:)` | Builder |
|
|
358
|
+
|---|---|
|
|
359
|
+
| `"id"` | `Representations.user(id: "1")` |
|
|
360
|
+
| `"upc sku"` (compound) | `Representations.product(upc: "u", sku: 42)` |
|
|
361
|
+
| `"id organization { id }"` (nested) | `Representations.listing(id: "1", organization: { id: "o" })` |
|
|
362
|
+
| `"id lineItems { sku }"` over a `[LineItem!]!` | `Representations.order(id: "1", line_items: [{ sku: "a" }, { sku: "b" }])` |
|
|
363
|
+
| `"id"` **and** `"serial"` (alternatives) | `Representations.variant(id: "1")` *or* `(serial: "s")` |
|
|
364
|
+
|
|
365
|
+
A key field the schema declares as a **list** takes a list, and stays one on the
|
|
366
|
+
wire — a single object there would describe an entity that doesn't exist. A type
|
|
367
|
+
with one `@key` types its fields as **required kwargs**, so an incomplete
|
|
368
|
+
representation is an `srb tc` error rather than a round trip; what a sig can't say
|
|
369
|
+
is checked at runtime and raises `GraphWeaver::InputError` naming the type and the
|
|
370
|
+
field:
|
|
371
|
+
|
|
372
|
+
```
|
|
373
|
+
Variant representation satisfies none of its @keys — supply "id", or "serial"
|
|
374
|
+
Listing representation is missing @key "organization.id"
|
|
375
|
+
Product representation sku: expected an Int, got "forty-two"
|
|
254
376
|
```
|
|
255
377
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
A `@join__` directive the table hasn't been taught lands in `#unsupported`
|
|
267
|
-
rather than being skipped, and callers refuse on a non-empty list: a table that
|
|
268
|
-
silently ignores half a spec version answers confidently and wrongly.
|
|
269
|
-
`#interface_objects` is the one construct kept out of that list
|
|
270
|
-
(`{"Media" => ["catalog"]}`), because it's a fact about one *type* rather than
|
|
271
|
-
about the table — the router refuses the queries that reach it and plans the rest.
|
|
272
|
-
|
|
273
|
-
The table is what [`Testing::Router`](#the-local-router) plans against, and it's
|
|
274
|
-
a reasonable read on its own — "which subgraph owns this field" is the sentence a
|
|
275
|
-
good error message wants. So when the schema dump is a composed supergraph,
|
|
276
|
-
`rake graph_weaver:queries:check` brands each validation error with the subgraphs
|
|
277
|
-
behind the type it names:
|
|
378
|
+
Key fields take the same loose input an `execute` kwarg does, so a `params[:sku]`
|
|
379
|
+
String converts to the `Int` the `@key` declares. A `@key` field whose name a
|
|
380
|
+
generated method can't take as a kwarg — `class`, `hash`, or a Ruby keyword — takes
|
|
381
|
+
a trailing underscore, the same one its prop took, and still sends the schema's
|
|
382
|
+
spelling on the wire: a subgraph's `@key` field is not yours to rename, so weaver
|
|
383
|
+
renames its own side rather than refusing. Only the declared key fields reach the
|
|
384
|
+
wire, builders are emitted only for the entities a query actually selects, and a
|
|
385
|
+
`@key(..., resolvable: false)` builds nothing, since it declares a key this subgraph
|
|
386
|
+
does *not* answer for. Every shape above is a named example in
|
|
387
|
+
[`spec/federation_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/federation_spec.rb).
|
|
278
388
|
|
|
389
|
+
**Off-label**, a subgraph that generates against its *own* SDL gets a typed gate for
|
|
390
|
+
a raw incoming representation, before `resolve_reference` sees one — with two
|
|
391
|
+
bounds: a single-`@key` builder takes required kwargs, so splatting a hash that is
|
|
392
|
+
short a field raises Ruby's `ArgumentError` rather than an `InputError`, and where
|
|
393
|
+
two key sets overlap the first fully supplied one wins silently.
|
|
394
|
+
|
|
395
|
+
## A subgraph that calls its own graph
|
|
396
|
+
|
|
397
|
+
A subgraph resolver that reaches for the composed graph — a cross-cutting report, a
|
|
398
|
+
field easier to answer through the gateway than by hand — is an ordinary thing to
|
|
399
|
+
write and the one shape **every** test tier is blind to, because each of them
|
|
400
|
+
intercepts exactly that call. Under `:in_process` the client points back at the
|
|
401
|
+
subgraph under test, so the loopback runs against a schema with none of those root
|
|
402
|
+
fields. Under `:router` it re-enters the router it is already inside. Under `:wire`
|
|
403
|
+
it is a second POST to the endpoint WebMock has stubbed. All three answer; none of
|
|
404
|
+
them is the address production will use.
|
|
405
|
+
|
|
406
|
+
So give the call its own graph, beside the one for the subgraph itself, with a
|
|
407
|
+
client distinct from the one the app uses to reach the gateway from outside:
|
|
408
|
+
|
|
409
|
+
```ruby
|
|
410
|
+
# config/initializers/graph_weaver.rb — beside GraphWeaver.graph :reviews,
|
|
411
|
+
# whose client is Reviews::Schema
|
|
412
|
+
GraphWeaver.graph :platform do
|
|
413
|
+
schema "app/graphql/supergraph.graphql"
|
|
414
|
+
queries "app/graphql/platform/queries"
|
|
415
|
+
output "app/graphql/platform/generated"
|
|
416
|
+
client GraphWeaver.new(ENV.fetch("PLATFORM_GRAPHQL_URL"))
|
|
417
|
+
namespace "Platform"
|
|
418
|
+
end
|
|
279
419
|
```
|
|
280
|
-
|
|
281
|
-
|
|
420
|
+
|
|
421
|
+
The resolver then calls `Platform::ProductsQuery.execute!` instead of
|
|
422
|
+
`GraphWeaver.client`, which buys three things. The target is named where someone
|
|
423
|
+
deploying can see it (`rake graph_weaver:graphs` prints it, and `ENV.fetch` fails at
|
|
424
|
+
boot rather than at the first request). A helper stands in for one graph at a time,
|
|
425
|
+
so a spec has to say `graphql_router(graph: :reviews)`, and can't cover the
|
|
426
|
+
loopback by accident. And with two graphs `GraphWeaver.client` under a mode refuses
|
|
427
|
+
by name rather than reaching a real endpoint, so a stray call is loud.
|
|
428
|
+
|
|
429
|
+
What none of that gives you is proof the url resolves. That is a smoke request
|
|
430
|
+
against a running gateway, and it is the only thing that will.
|
|
431
|
+
|
|
432
|
+
## Producing a supergraph
|
|
433
|
+
|
|
434
|
+
GraphWeaver consumes a supergraph; composing one is Apollo's job. Composition takes
|
|
435
|
+
one SDL file per subgraph, so the question is where each file comes from.
|
|
436
|
+
|
|
437
|
+
**Yours.** [`apollo-federation`](https://github.com/Gusto/apollo-federation-ruby)
|
|
438
|
+
is the gem that makes a graphql-ruby schema a subgraph — `@key`,
|
|
439
|
+
`resolve_reference`, the directives — and it adds `federation_sdl`, which prints
|
|
440
|
+
exactly what a composer wants:
|
|
441
|
+
|
|
442
|
+
```ruby
|
|
443
|
+
File.write("supergraph/accounts.graphql", Accounts::Schema.federation_sdl)
|
|
282
444
|
```
|
|
283
445
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
446
|
+
Two traps in that gem. Declare `orphan_types` **before** `query`: it computes the
|
|
447
|
+
`_Entity` union when `query` is called, so an `orphan_types` after it drops those
|
|
448
|
+
types from the printed SDL and from `_entities` with no error at all. That is also
|
|
449
|
+
what an **extend-only type** needs — one this subgraph adds fields to but never
|
|
450
|
+
returns from its own `Query` — since nothing reaches it from a root field, so
|
|
451
|
+
`federation_sdl` never prints it and the fields don't compose. Second, it writes its
|
|
452
|
+
directives **un-imported** (`@federation__key`), while `@apollo/composition`
|
|
453
|
+
(2.14.4) asserts on the short spelling: get a `fields:` argument wrong and instead
|
|
454
|
+
of "Cannot query field `nosuchfield` on type `User`" you get `Error: Unexpected
|
|
455
|
+
element: federation__key` and a JS stack. Only the diagnosis is lost — adding
|
|
456
|
+
`@link(import: ["@key", "@provides"])` gets the real message back.
|
|
457
|
+
|
|
458
|
+
**Everyone else's.** From the team that runs it: a file they publish, `rover
|
|
459
|
+
subgraph fetch` against their endpoint, or your schema registry.
|
|
287
460
|
|
|
288
|
-
|
|
461
|
+
**Composed.** [`rover supergraph compose`](https://www.apollographql.com/docs/rover/commands/supergraphs)
|
|
462
|
+
reads a config naming each subgraph's routing url and SDL file, and prints the
|
|
463
|
+
supergraph to stdout:
|
|
289
464
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
465
|
+
```yaml
|
|
466
|
+
# supergraph-config.yaml
|
|
467
|
+
federation_version: =2.14.4 # rover wants an exact one
|
|
468
|
+
subgraphs:
|
|
469
|
+
accounts:
|
|
470
|
+
routing_url: https://accounts.internal/graphql
|
|
471
|
+
schema: { file: ./accounts.graphql }
|
|
472
|
+
```
|
|
297
473
|
|
|
298
474
|
```sh
|
|
299
|
-
|
|
475
|
+
rover supergraph compose --config supergraph-config.yaml \
|
|
476
|
+
--elv2-license accept > supergraph.graphql
|
|
300
477
|
```
|
|
301
478
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
overrides all of that for one run.
|
|
313
|
-
|
|
314
|
-
`SUPERGRAPH=` reaches the `federation:*` tasks and **nothing else**: every other
|
|
315
|
-
task reads the schema its graph declares, and one pointed at an ad-hoc
|
|
316
|
-
supergraph would collapse a multi-graph app into a single unnamed graph — which
|
|
317
|
-
for `generate` means pruning the generated files of every graph that graph
|
|
318
|
-
didn't cover. So they refuse it rather than ignore it, which is what
|
|
319
|
-
`SUPERGRAPH=… rake graph_weaver:queries:check` used to do while reporting every
|
|
320
|
-
query valid. To check queries against a supergraph, declare it:
|
|
321
|
-
`GraphWeaver.graph(:api) { schema "supergraph.graphql" }`.
|
|
479
|
+
`--elv2-license accept` accepts the Elastic license on the composition binary rover
|
|
480
|
+
downloads; without it rover asks, and a CI job has nobody to answer. Commit the
|
|
481
|
+
result — from here it is an ordinary schema dump, and
|
|
482
|
+
[`federation:diff`](#has-the-supergraph-been-recomposed) is what catches it going
|
|
483
|
+
stale. Already on a node toolchain? `@apollo/composition` composes in-process with
|
|
484
|
+
no rover install; the suite does it that way, in
|
|
485
|
+
[`recompose.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/support/federation/recompose.rb)
|
|
486
|
+
driving [`compose.mjs`](https://github.com/dpep/graph_weaver/blob/main/spec/support/federation/compose.mjs).
|
|
487
|
+
|
|
488
|
+
## Has the supergraph been recomposed?
|
|
322
489
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
490
|
+
A committed supergraph is a snapshot of a composition. Change a subgraph and skip
|
|
491
|
+
the recompose and it quietly describes a graph that no longer exists — the failure
|
|
492
|
+
that bites a federated app mid-migration, and the one the other checks don't ask
|
|
493
|
+
about. `verify` asks whether the generated Ruby is fresh, `schema:diff` whether
|
|
494
|
+
whatever your dump came from has drifted, `queries:check` whether drift broke a
|
|
495
|
+
query. This asks whether the supergraph still describes your subgraphs, reading the
|
|
496
|
+
routing table and the subgraph schemas loaded in this process — **no network** —
|
|
497
|
+
and exiting non-zero on drift, so CI can gate on it:
|
|
326
498
|
|
|
327
499
|
```
|
|
500
|
+
$ rake graph_weaver:federation:diff
|
|
328
501
|
supergraph.graphql: 1 stale, 1 shape, 1 not composed in (checked 1 of 3 subgraphs)
|
|
329
502
|
|
|
330
503
|
stale — the supergraph carries these, no schema here defines them (recompose):
|
|
@@ -344,160 +517,124 @@ not checked — answered with fabricated data:
|
|
|
344
517
|
```
|
|
345
518
|
|
|
346
519
|
Three kinds, because they mean different things: **stale** is "recompose",
|
|
347
|
-
**shape** is "recompose" for a field neither side dropped, and **not composed
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
so
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
still reads — so deleting the *new* side's `@override` copy first doesn't remove
|
|
374
|
-
the field from the graph, it hands ownership back to the subgraph you were
|
|
375
|
-
migrating away from, and every check here reports clean because the coordinate
|
|
376
|
-
and its type never moved. Delete the old owner's copy first; the recompose after
|
|
377
|
-
that is what makes the field's removal visible at all.
|
|
378
|
-
|
|
379
|
-
### Two changes every gate calls clean
|
|
380
|
-
|
|
381
|
-
`federation:diff` reads coordinates and types; `queries:check` and `generate`
|
|
382
|
-
read what a query *says*. Two ordinary schema changes fall between them, and on
|
|
383
|
-
both, `schema:diff`'s `breaking: true` line is the only warning anyone gets —
|
|
384
|
-
once, in the run that first sees it:
|
|
385
|
-
|
|
386
|
-
- **A scalar swapped for one that serializes the same way.** `Widget.price`
|
|
387
|
-
going `String!` → `Currency!` is a breaking row in `schema:diff`. But a client
|
|
388
|
-
that hasn't regenerated has `const :price, String`, and a `Currency` that
|
|
389
|
-
still arrives as a JSON string — `"$19.99"` where `"19.99"` used to be —
|
|
390
|
-
satisfies that prop exactly. Sorbet asks whether it is *a* String, which it
|
|
391
|
-
is; nothing downstream of that knows the format changed, and `"$19.99".to_f`
|
|
392
|
-
is `0.0`. Regenerating is what surfaces it, and only if the client
|
|
393
|
-
[registers the scalar](scalars.md#registering-a-class-of-your-own) rather than
|
|
394
|
-
leaving it a String.
|
|
395
|
-
- **An enum value removed.** `Status.ACTIVE` disappearing is a breaking row too,
|
|
396
|
-
and `queries:check` and `generate` are both clean for every query that selects
|
|
397
|
-
a `status` field without naming `ACTIVE` in the document — validation has
|
|
398
|
-
nothing to check a value against unless the value is written down. The
|
|
399
|
-
generated `T::Enum` keeps the constant and keeps deserializing it; the server
|
|
400
|
-
simply never sends it again. Harmless in itself, and a live signal that the
|
|
401
|
-
branch handling it is dead.
|
|
402
|
-
|
|
403
|
-
So a supergraph owner announcing either of these should not expect a client's CI
|
|
404
|
-
to notice. Deprecate first — `schema:diff` reports a deprecation's arrival, and
|
|
405
|
-
that is the one place it shows up, since generated code carries no trace of it.
|
|
406
|
-
|
|
407
|
-
**A supergraph is routinely only partly local**, so the report names three
|
|
408
|
-
states rather than two: checked, not here (running elsewhere — or the subgraph
|
|
409
|
-
is gone), and [faked](#the-local-router). A clean report that quietly checked one
|
|
410
|
-
subgraph of three would be actively misleading, so the headline counts them and
|
|
411
|
-
the sections name them. Only drift fails the task; absence is a supported
|
|
412
|
-
setup. Checking **none** of them fails too — "checked 0 of 4" attached to exit 0
|
|
413
|
-
is a gate that passes whatever the subgraphs say. (It won't come up in a Rails
|
|
414
|
-
app whose subgraphs are here: the `federation:*` tasks eager-load for you — see
|
|
415
|
-
[which schema serves which subgraph](#which-schema-serves-which-subgraph).)
|
|
416
|
-
|
|
417
|
-
The mirror of all that is a subgraph **retired** from the composition whose Ruby
|
|
418
|
-
class is still loaded. Every check here walks the supergraph's subgraph list, so
|
|
419
|
-
that one sat on the only side nothing looked at, and the report read "matches
|
|
420
|
-
the schemas here (checked 3 of 3 subgraphs)". It is now named, on stderr:
|
|
421
|
-
|
|
422
|
-
```
|
|
423
|
-
not placed — no subgraph of any supergraph read here is:
|
|
424
|
-
Reviews::Schema
|
|
425
|
-
```
|
|
520
|
+
**shape** is "recompose" for a field neither side dropped, and **not composed in**
|
|
521
|
+
is "publish the subgraph". Stale and shape name the subgraph the supergraph blames.
|
|
522
|
+
Field-set comparison is deliberately looser than equality, since a subgraph carries
|
|
523
|
+
plumbing no supergraph has and a field can legitimately sit in more than one
|
|
524
|
+
(`@external` copies, `@shareable`), so one candidate schema agreeing settles it.
|
|
525
|
+
|
|
526
|
+
**A supergraph is routinely only partly local**, so the report counts three states:
|
|
527
|
+
checked, not here, and [faked](#a-supergraph-only-partly-local). Only drift fails
|
|
528
|
+
the task — absence is a supported setup — but checking **none** of them fails too,
|
|
529
|
+
since "checked 0 of 4" attached to exit 0 is a gate that passes whatever the
|
|
530
|
+
subgraphs say. The mirror of that is a subgraph **retired** from the composition
|
|
531
|
+
whose Ruby class is still loaded: every check walks the supergraph's subgraph list,
|
|
532
|
+
so that one sits on the side nothing looks at, and it is named on stderr as `not
|
|
533
|
+
placed`. A warning rather than drift, deliberately — a process that loads a
|
|
534
|
+
subgraph of a supergraph this run never reads is the same picture. (A schema is a
|
|
535
|
+
subgraph here if it serves `Query._service`.)
|
|
536
|
+
|
|
537
|
+
**Every `federation:*` task finds the supergraph the same way**: once per declared
|
|
538
|
+
graph whose schema is a composed one, each report headed with that graph's name. A
|
|
539
|
+
graph that is in none is simply not a subject for these tasks.
|
|
540
|
+
`SUPERGRAPH=supergraph.graphql` overrides that for one run and reaches the
|
|
541
|
+
`federation:*` tasks and **nothing else** — every other task reads the schema its
|
|
542
|
+
graph declares, and one pointed at an ad-hoc supergraph would collapse a
|
|
543
|
+
multi-graph app into a single unnamed graph, so they refuse it rather than ignore
|
|
544
|
+
it. To check queries against a supergraph, declare it:
|
|
545
|
+
`GraphWeaver.graph(:api) { schema "supergraph.graphql" }`.
|
|
426
546
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
A schema is recognized by the types the supergraph says its subgraph declares,
|
|
436
|
-
plus at least one coordinate attributed to that subgraph **alone**. What two
|
|
437
|
-
subgraphs share can't tell them apart — every subgraph has a `Query`, and the
|
|
438
|
-
entity `accounts` and `prefs` both extend is declared by both — so a subgraph
|
|
439
|
-
whose own fields are nowhere in this process is "not here", not stale.
|
|
440
|
-
Detection is therefore what drift breaks, so the same
|
|
441
|
-
`subgraphs:` map [`Testing::Router`](#the-local-router) takes is accepted here,
|
|
442
|
-
and a named schema skips detection:
|
|
547
|
+
`Drift` is the API under the task, and it takes the same `subgraphs:` map
|
|
548
|
+
[`Testing::Router`](#the-local-router) does — which matters twice. **Name the schema
|
|
549
|
+
whenever you are diffing a *proposal***, since detection unions every loaded schema
|
|
550
|
+
that fits a subgraph, so a console session holding both the changed SDL and the
|
|
551
|
+
unmodified class reports clean. And because `Drift` **never calls a resolver**, it
|
|
552
|
+
takes what the router can't — a **subgraph SDL** from a non-Ruby team loads into a
|
|
553
|
+
resolver-less schema it compares like any other:
|
|
443
554
|
|
|
444
555
|
```ruby
|
|
445
556
|
require "graph_weaver/federation" # the rake tasks do this for you
|
|
446
557
|
|
|
447
558
|
GraphWeaver::Federation::Drift.new(
|
|
448
559
|
supergraph: "supergraph.graphql",
|
|
449
|
-
subgraphs: { "products" => Products::Schema, "inventory" => :fake
|
|
560
|
+
subgraphs: { "products" => Products::Schema, "inventory" => :fake,
|
|
561
|
+
"accounts" => GraphWeaver::SchemaLoader.load(File.read("accounts.graphql")) },
|
|
450
562
|
).report
|
|
451
563
|
```
|
|
452
564
|
|
|
453
565
|
`#to_h` is the JSON-ready `{"stale" => …, "shape" => …, "uncomposed" => …,
|
|
454
566
|
"skipped" => …, "faked" => …}` — a `shape` entry is `{"subgraphs" => […],
|
|
455
|
-
"supergraph" => "String!", "here" => ["ID!"]}` —
|
|
456
|
-
|
|
457
|
-
outside both, being the warning above rather than drift.
|
|
567
|
+
"supergraph" => "String!", "here" => ["ID!"]}` — `#drift?` is what the task exits
|
|
568
|
+
on, and `#unplaced` is the warning above rather than drift.
|
|
458
569
|
|
|
459
|
-
|
|
460
|
-
what `Testing::Router` needs. A **subgraph SDL** — `rover subgraph fetch`,
|
|
461
|
-
`_service { sdl }`, whatever a non-Ruby team publishes — loads into a
|
|
462
|
-
resolver-less schema that `Drift` compares like any other:
|
|
570
|
+
### What federation:diff can't see
|
|
463
571
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
572
|
+
**What is compared is a coordinate's presence and its type, and nothing else.** The
|
|
573
|
+
`@key` it is part of, a field's arguments, its directives, and everything a type
|
|
574
|
+
says about itself beyond its fields are not read, and a change to any of them
|
|
575
|
+
reports clean. Two ordinary subgraph edits land in that gap, and both break the
|
|
576
|
+
*next* composition while the report stays green: a **`@key` added, removed or
|
|
577
|
+
turned `resolvable: false`** is a directive, so nothing here reads it (the last of
|
|
578
|
+
those makes recomposition impossible and still reports clean); and a **field one
|
|
579
|
+
subgraph adds that another already owns, neither marked `@shareable`**, is
|
|
580
|
+
invisible because the coordinate is already in the supergraph, so "not composed in"
|
|
581
|
+
doesn't fire, while its original owner still declares it, so neither does "stale".
|
|
582
|
+
|
|
583
|
+
So **`federation:diff` answers "did you forget to recompose the schema you have",
|
|
584
|
+
never "would the next recompose succeed"** — the second question needs the JS
|
|
585
|
+
composer, a network and npm dependency this deliberately doesn't take. A clean
|
|
586
|
+
report says so on its own line rather than leaving you to infer it:
|
|
587
|
+
|
|
588
|
+
```
|
|
589
|
+
supergraph.graphql: matches the schemas here, field for field and type for type (checked 3 of 3 subgraphs)
|
|
590
|
+
not compared: @key (added, removed, or made unresolvable), and one field two subgraphs define without @shareable — recompose to catch those
|
|
469
591
|
```
|
|
470
592
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
593
|
+
An `@override` migration is asymmetric for the same reason: a supergraph published
|
|
594
|
+
*ahead* of the code is caught (the old side's field is `stale`), but code ahead of
|
|
595
|
+
the supergraph is not, because both sides carry the field and its type and only the
|
|
596
|
+
`@override` marker says ownership is moving. **Finish an `@override` migration from
|
|
597
|
+
the old side** — deleting the *new* side's copy first hands ownership back to the
|
|
598
|
+
subgraph you were migrating away from, and every check reports clean because the
|
|
599
|
+
coordinate never moved.
|
|
474
600
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
the
|
|
601
|
+
### Two changes every gate calls clean
|
|
602
|
+
|
|
603
|
+
`federation:diff` reads coordinates and types; `queries:check` and `generate` read
|
|
604
|
+
what a query *says*. Two ordinary schema changes fall between them, and on both,
|
|
605
|
+
`schema:diff`'s `breaking: true` line is the only warning anyone gets — once, in
|
|
606
|
+
the run that first sees it:
|
|
607
|
+
|
|
608
|
+
- **A scalar swapped for one that serializes the same way.** `Widget.price` going
|
|
609
|
+
`String!` → `Currency!` leaves a client that hasn't regenerated with
|
|
610
|
+
`const :price, String`, which a `Currency` arriving as a JSON string — `"$19.99"`
|
|
611
|
+
where `"19.99"` used to be — satisfies exactly. Sorbet asks whether it is *a*
|
|
612
|
+
String, which it is, and `"$19.99".to_f` is `0.0`. Regenerating surfaces it, and
|
|
613
|
+
only if the client [registers the scalar](scalars.md#registering-a-class-of-your-own).
|
|
614
|
+
- **An enum value removed.** `queries:check` and `generate` are clean for every
|
|
615
|
+
query that selects a `status` field without naming `ACTIVE` in the document —
|
|
616
|
+
validation has nothing to check a value against unless the value is written down.
|
|
617
|
+
The generated `T::Enum` keeps the constant and keeps deserializing it; the server
|
|
618
|
+
simply never sends it again.
|
|
619
|
+
|
|
620
|
+
So a supergraph owner announcing either of these should not expect a client's CI to
|
|
621
|
+
notice. Deprecate first: `schema:diff` reports a deprecation's arrival, and that is
|
|
622
|
+
the one place it shows up, since generated code carries no trace of it.
|
|
481
623
|
|
|
482
624
|
## In CI
|
|
483
625
|
|
|
484
626
|
`federation:diff` needs no network, so it belongs beside the other checks in the
|
|
485
|
-
normal PR run — the [GitHub Actions job](getting_started.md#5-verify-in-ci) has
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
endpoint serves one, and a production router refuses introspection by default
|
|
497
|
-
(`{"message": "introspection has been disabled", "extensions": {"code":
|
|
498
|
-
"INTROSPECTION_DISABLED"}}`). Hot-reload a router onto a supergraph that dropped
|
|
499
|
-
a field your queries select and all of it still exits 0 while every one of those
|
|
500
|
-
requests fails.
|
|
627
|
+
normal PR run — the [GitHub Actions job](getting_started.md#5-verify-in-ci) has the
|
|
628
|
+
step. Add it where the subgraph classes live: an app that only *calls* the gateway
|
|
629
|
+
loads none of them, and the task aborts rather than pass having checked nothing.
|
|
630
|
+
|
|
631
|
+
**What that job does not do is look at the schema production is serving**, and on a
|
|
632
|
+
federated graph nothing here can: every check in it compares the app to artifacts
|
|
633
|
+
checked in beside it. `schema:diff` is the one that reads a live source, and it
|
|
634
|
+
can't be pointed at a supergraph — a composed supergraph records no source url
|
|
635
|
+
because no endpoint serves one, and a production router refuses introspection by
|
|
636
|
+
default. Hot-reload a router onto a supergraph that dropped a field your queries
|
|
637
|
+
select and all of it still exits 0 while every one of those requests fails.
|
|
501
638
|
|
|
502
639
|
That gap is Apollo's to close, and it has two commands for it:
|
|
503
640
|
|
|
@@ -506,209 +643,73 @@ rover subgraph check my-graph@prod --name products --schema products.graphql
|
|
|
506
643
|
rover supergraph fetch my-graph@prod # then recompose and diff what you get back
|
|
507
644
|
```
|
|
508
645
|
|
|
509
|
-
`rover subgraph check` asks GraphOS whether publishing this subgraph would break
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
than "is my checked-in supergraph consistent with my checked-in subgraphs".
|
|
646
|
+
`rover subgraph check` asks GraphOS whether publishing this subgraph would break the
|
|
647
|
+
composition or a client operation registered against the variant — the pre-merge
|
|
648
|
+
half. `rover supergraph fetch` hands you the supergraph the router is running, which
|
|
649
|
+
is what `federation:diff` should be pointed at when the question is "does the
|
|
650
|
+
deployed graph still answer my queries".
|
|
515
651
|
|
|
516
|
-
Nothing in this gem talks to GraphOS,
|
|
517
|
-
app finds out: a query the served supergraph rejects comes back with
|
|
652
|
+
Nothing in this gem talks to GraphOS, so without them the runtime is where a
|
|
653
|
+
federated app finds out: a query the served supergraph rejects comes back with
|
|
518
654
|
`schema_stale?` true and a message naming the repair
|
|
519
|
-
([errors → stale schemas](errors.md#stale-schemas)). That is detection at the
|
|
520
|
-
|
|
521
|
-
same job as the five tasks.
|
|
522
|
-
|
|
523
|
-
## The local router
|
|
524
|
-
|
|
525
|
-
Specs for a federated app have a bad choice: fake the whole graph, or boot a
|
|
526
|
-
gateway. `Testing::Router` is the third one. It takes the composed supergraph
|
|
527
|
-
and the Ruby schema classes serving its subgraphs, plans the query, and
|
|
528
|
-
satisfies the [client contract](transports.md) — so a generated module runs
|
|
529
|
-
against your **real resolvers**, in-process, with no gateway, no node and no
|
|
530
|
-
sockets. It is not a mock: your resolvers run, which is the whole point.
|
|
655
|
+
([errors → stale schemas](errors.md#stale-schemas)). That is detection at the point
|
|
656
|
+
of damage, which is why those two commands belong in the same job as the five tasks.
|
|
531
657
|
|
|
532
|
-
|
|
533
|
-
GraphWeaver.client = GraphWeaver::Testing::Router.new(
|
|
534
|
-
supergraph: Rails.root.join("supergraph.graphql"),
|
|
535
|
-
context: { current_user: user },
|
|
536
|
-
)
|
|
537
|
-
```
|
|
658
|
+
## Details
|
|
538
659
|
|
|
539
|
-
|
|
540
|
-
Neither real transport does that with a request's **headers** — the gateway and
|
|
541
|
-
the Apollo Router both start a subgraph call with none of the client's, unless
|
|
542
|
-
you configure the forwarding — so don't let a spec conclude an auth header
|
|
543
|
-
arrived somewhere it wouldn't.
|
|
544
|
-
|
|
545
|
-
In rspec that's the [`graphql: :router`](testing.md#a-federated-graph--graphql-router)
|
|
546
|
-
tag and there is nothing to pass — the tag builds it, once for the suite. It
|
|
547
|
-
finds the supergraph where you have already said it is: `Testing.config.router
|
|
548
|
-
= { supergraph: … }` if you named one there, else the schema a
|
|
549
|
-
[graph](getting_started.md#more-than-one-schema) declares when that schema is
|
|
550
|
-
composed, else the committed dump when *that* is. Two graphs may name one
|
|
551
|
-
supergraph; two naming different ones is refused rather than picked between.
|
|
552
|
-
`router.trace` records the fetches made since the last `reset_trace`, in order
|
|
553
|
-
(subgraph, query, variables); the same lines go to `GraphWeaver.logger` at
|
|
554
|
-
`:debug`. It **accumulates across executes**, because the question worth asking
|
|
555
|
-
is which subgraphs a code path touched and a service object rarely runs one
|
|
556
|
-
query. The rspec tag resets it before each example; outside rspec call
|
|
557
|
-
`router.reset_trace` around the code path you're measuring.
|
|
558
|
-
|
|
559
|
-
The count is the **local router's plan, not the gateway's**. The data is
|
|
560
|
-
faithful — a real gateway answers byte-identically, or this refuses — but the
|
|
561
|
-
cost isn't quite: everything crossing into one subgraph from one level rides one
|
|
562
|
-
call, and a [`@requires` prefetch](#what-it-plans) is still its own call even
|
|
563
|
-
when it goes to the same subgraph as the plain read beside it, where a gateway
|
|
564
|
-
merges the two. A dashboard query a gateway does in 4 fetches takes 5 here. So
|
|
565
|
-
assert on a **bound** (`expect(router.trace.size).to be <= 8`) or on the
|
|
566
|
-
**subgraph set** (`router.trace.map { _1[:subgraph] }.uniq`): both move when an
|
|
567
|
-
N+1 appears, and neither pins a number production doesn't have.
|
|
568
|
-
|
|
569
|
-
The router hands back a result hash *above* the wire, so the transport your app
|
|
570
|
-
ships never runs. When that transport is the thing under test — a caller tag, an
|
|
571
|
-
APM header, mTLS — [`graphql: :wire`](testing.md#over-the-wire--graphql-wire)
|
|
572
|
-
serves this same router at the endpoint your client posts to and leaves your
|
|
573
|
-
client in place: real serialization, the same plan over the same resolvers,
|
|
574
|
-
`from_h` over the server's own bytes, and a `context:` proc reading the headers
|
|
575
|
-
that arrived. It refuses exactly what the router refuses — a hop, not a
|
|
576
|
-
capability. The hop is served through webmock, so that tag needs
|
|
577
|
-
`require "webmock/rspec"` in the spec helper —
|
|
578
|
-
[testing](testing.md#over-the-wire--graphql-wire) has the rest.
|
|
579
|
-
|
|
580
|
-
**[`examples/federation.rb`](https://github.com/dpep/graph_weaver/blob/main/examples/federation.rb)** is the whole shape
|
|
581
|
-
in one runnable file, and the only example that needs no network: three real
|
|
582
|
-
subgraphs, a boundary-crossing query through a generated module, the trace,
|
|
583
|
-
and a refusal. [`spec/router_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/router_spec.rb) is the
|
|
584
|
-
exhaustive reference — every plan shape, every refusal, the partly-local
|
|
585
|
-
graph and the `:fake` opt-in, each as a named example.
|
|
586
|
-
|
|
587
|
-
### Which schema serves which subgraph
|
|
588
|
-
|
|
589
|
-
`subgraphs:` is optional. Left out, each one is **derived from what the loaded
|
|
590
|
-
schemas define**: a schema serves subgraph `s` when it defines every type and
|
|
591
|
-
field the routing table says `s` resolves. That's evidence rather than a guess,
|
|
592
|
-
and a wrong guess would point a suite at the wrong resolvers and still pass — so
|
|
593
|
-
exactly one match is used. Neither other outcome refuses at construction, since
|
|
594
|
-
which classes are loaded is not a fact about the query you're running: **no**
|
|
595
|
-
match means the subgraph is served somewhere else (next section), and **two**
|
|
596
|
-
means detection can't say which loaded schema class serves it. Both are refused
|
|
597
|
-
by the query that reaches the subgraph's fields, each naming its own fix — for
|
|
598
|
-
two, `subgraphs: { "reviews" => App::Reviews::Schema }` pins it, and
|
|
599
|
-
`router.ambiguous` lists them.
|
|
660
|
+
### The routing table
|
|
600
661
|
|
|
601
|
-
|
|
602
|
-
|
|
662
|
+
Stripping the machinery answers "what does this graph look like". The other question
|
|
663
|
+
a supergraph answers is "who resolves what", and `SchemaLoader.routing_table` keeps
|
|
664
|
+
that side rather than discarding it:
|
|
603
665
|
|
|
604
666
|
```ruby
|
|
605
|
-
|
|
606
|
-
```
|
|
607
|
-
|
|
608
|
-
Either way the map is **checked** at construction, so a swapped pair fails
|
|
609
|
-
naming what's missing rather than surfacing as a mystery three fetches later:
|
|
610
|
-
|
|
611
|
-
```
|
|
612
|
-
subgraphs["accounts"] is Products::Schema, which doesn't define Query.me,
|
|
613
|
-
Query.user, Query.users, User, User.email and 1 more — the supergraph says
|
|
614
|
-
accounts resolves them. Did two entries get swapped?
|
|
615
|
-
```
|
|
616
|
-
|
|
617
|
-
Detection only sees what's **loaded**, and in Rails an autoloaded schema isn't
|
|
618
|
-
until something references it — which is why an unmatched subgraph reads as
|
|
619
|
-
absent. The `federation:*` rake tasks eager-load the app for you; a spec suite
|
|
620
|
-
is your own `config.eager_load`, which Rails leaves off outside CI. To see what
|
|
621
|
-
detection sees, and get a map to paste:
|
|
667
|
+
table = GraphWeaver::SchemaLoader.routing_table("supergraph.graphql")
|
|
622
668
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
}
|
|
669
|
+
table.subgraphs # => ["accounts", "products", "reviews"]
|
|
670
|
+
table.owners("Product", "shippingEstimate") # => ["reviews"]
|
|
671
|
+
table.owners("User", "username") # => ["accounts"] — the @external copy isn't an owner
|
|
672
|
+
table.keys("User", "accounts") # => [["id"]]
|
|
673
|
+
table.field("Product", "shippingEstimate").requires # => "price weight"
|
|
674
|
+
table.possible_types("Purchasable", "products") # => ["Bundle", "Product"]
|
|
630
675
|
```
|
|
631
676
|
|
|
632
|
-
|
|
633
|
-
|
|
677
|
+
Subgraphs are named the way `@join__graph(name:)` names them — the strings a router
|
|
678
|
+
config and `rover` use, not the SDL's uppercase enum spelling. A `@key` field set
|
|
679
|
+
comes back as dotted paths (`"id organization { id }"` → `["id",
|
|
680
|
+
"organization.id"]`). A field with no `@join__field` at all lives wherever its type
|
|
681
|
+
does; that omission is how the composer says "everywhere". `possible_types` answers
|
|
682
|
+
the abstract side, from `@join__unionMember`/`@join__implements`, and is `nil` where
|
|
683
|
+
the supergraph doesn't say — a different fact from "none". A `@join__` directive the
|
|
684
|
+
table hasn't been taught lands in `#unsupported` rather than being skipped, and
|
|
685
|
+
callers refuse on a non-empty list: a table that silently ignores half a spec
|
|
686
|
+
version answers confidently and wrongly. `#interface_objects` is the one construct
|
|
687
|
+
kept out of that list (`{"Media" => ["catalog"]}`), being a fact about one *type*
|
|
688
|
+
rather than about the table — the router refuses the queries that reach it and plans
|
|
689
|
+
the rest.
|
|
634
690
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
so there is no Ruby schema here to serve them — and requiring one would refuse
|
|
640
|
-
the whole suite over fields most of your queries never touch.
|
|
691
|
+
The table is also what a good error message wants. When the schema dump is a
|
|
692
|
+
composed supergraph, `rake graph_weaver:queries:check` brands each validation error
|
|
693
|
+
with the subgraphs behind the type it names, and `check_queries` carries the same
|
|
694
|
+
list as a `"subgraphs"` key:
|
|
641
695
|
|
|
642
|
-
So a subgraph nothing here defines is **absent**, and the router builds and runs
|
|
643
|
-
anyway. Absence costs you exactly the queries that reach into it:
|
|
644
|
-
|
|
645
|
-
```ruby
|
|
646
|
-
router.absent # => ["shipping"]
|
|
647
|
-
router.execute("{ me { username reviews { body } } }") # real data, as always
|
|
648
|
-
router.execute("{ shipments { carrier } }") # GraphWeaver::Testing::Unplannable
|
|
649
696
|
```
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
it raises, and it names the subgraph, the field that reached for it, and both
|
|
653
|
-
ways out — name a schema for it, or fake it:
|
|
654
|
-
|
|
655
|
-
```ruby
|
|
656
|
-
subgraphs: { "shipping" => :fake } # any other absent subgraph still refuses
|
|
657
|
-
```
|
|
658
|
-
|
|
659
|
-
If the subgraph *is* here and detection just couldn't see it — a Rails schema
|
|
660
|
-
class nothing has referenced yet — loading it is the fix, and in a spec suite
|
|
661
|
-
that means `config.eager_load = true` (naming it in
|
|
662
|
-
`Testing.config.router = { subgraphs: … }` works too). `=> :fake` is the
|
|
663
|
-
other one: mid-migration, letting an absent subgraph answer with
|
|
664
|
-
schema-correct fabricated data exercises the rest of the query. It speaks the
|
|
665
|
-
whole subgraph contract, `_entities(representations:)` included, so it works
|
|
666
|
-
under a stitched fetch as well as at a root field.
|
|
667
|
-
|
|
668
|
-
Refusing stays the default, and the opt-in is **per subgraph** on purpose:
|
|
669
|
-
silently substituting invented data is the failure mode this library keeps
|
|
670
|
-
designing against. For the same reason faking is **loud** — every faked fetch
|
|
671
|
-
is marked `faked: true` in `router.trace` and logged at `:warn`.
|
|
672
|
-
`router.faked` lists them, and `router.inspect` shows what's served, faked and
|
|
673
|
-
absent. Values come from the same engine as
|
|
674
|
-
[`graphql: :fake`](testing.md#fabricated-data--graphql-fake), so `config.seed`,
|
|
675
|
-
`config.overrides` and the rest apply.
|
|
676
|
-
|
|
677
|
-
`fake:` says how they fabricate — the [pins](testing.md#pins) and options a
|
|
678
|
-
fake takes, in one hash. It goes on `Router.new` outside rspec, on
|
|
679
|
-
`Testing.config.router` for the suite, and on `graphql_router` for the one
|
|
680
|
-
example that cares:
|
|
681
|
-
|
|
682
|
-
```ruby
|
|
683
|
-
Testing.config.router = { subgraphs: { "shipping" => :fake },
|
|
684
|
-
fake: { list_size: 2 } }
|
|
685
|
-
|
|
686
|
-
it "shows the carrier" do
|
|
687
|
-
graphql_router(fake: { "Shipment.carrier" => "UPS" })
|
|
688
|
-
...
|
|
689
|
-
end
|
|
697
|
+
app/graphql/queries/product.graphql
|
|
698
|
+
4:5 Field 'dimensions' doesn't exist on type 'Product' (products, reviews)
|
|
690
699
|
```
|
|
691
700
|
|
|
692
|
-
One `fake:` covers every faked subgraph, because a pin's key
|
|
693
|
-
(`"Shipment.carrier"`) already says which type it means. And `graphql_router` is
|
|
694
|
-
`graphql: :router` with somewhere to put arguments — the router itself is still
|
|
695
|
-
built once for the suite, and the options last one example.
|
|
696
|
-
|
|
697
701
|
### What it plans
|
|
698
702
|
|
|
699
703
|
An operation that resolves in **one subgraph** goes over verbatim. One that
|
|
700
704
|
**crosses a boundary** is split at the crossing: the plan injects the entity's
|
|
701
705
|
`@key` under a reserved alias, refetches it from the owning subgraph through
|
|
702
|
-
`_entities(representations:)`, and stitches the answer back. Every node at one
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
plain one included. Root fields that resolve
|
|
707
|
-
in different subgraphs get one fetch each. A `@provides` copy is read in place,
|
|
708
|
-
so nothing leaves the subgraph for a field the copy already holds.
|
|
706
|
+
`_entities(representations:)`, and stitches the answer back. Every node at one level
|
|
707
|
+
goes in **one** `_entities` call, so a list of users and all their reviews' products
|
|
708
|
+
is three fetches, not one per row. Root fields that resolve in different subgraphs
|
|
709
|
+
get one fetch each, and a `@provides` copy is read in place.
|
|
709
710
|
|
|
710
|
-
A **`@requires` field set** is supplied by the router rather than by the
|
|
711
|
-
|
|
711
|
+
A **`@requires` field set** is supplied by the router rather than by the subgraph
|
|
712
|
+
that declares the field, so it's a fetch before the fetch:
|
|
712
713
|
|
|
713
714
|
```ruby
|
|
714
715
|
router.reset_trace
|
|
@@ -717,24 +718,21 @@ router.trace.map { _1[:subgraph] } # => ["reviews", "products", "reviews"]
|
|
|
717
718
|
```
|
|
718
719
|
|
|
719
720
|
`shippingEstimate` resolves in `reviews` and `@requires "price weight"`, which
|
|
720
|
-
`products` owns — so the plan fetches those into hidden keys, hands them back
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
chain
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
A **union or interface at a boundary** — a feed, a search page, any
|
|
736
|
-
polymorphic list — is planned per concrete type, because a representation names
|
|
737
|
-
one concrete `__typename` and which one an object has isn't in the query:
|
|
721
|
+
`products` owns — so the plan fetches those into hidden keys, hands them back in the
|
|
722
|
+
representation, and only then asks for the estimate. One hop only: the key for the
|
|
723
|
+
first fetch has to come from the subgraph already in hand, so a chain can't grow a
|
|
724
|
+
chain. Two `@requires` sets crossing into the same subgraph on the same `@key` ride
|
|
725
|
+
one prefetch, as Apollo's do.
|
|
726
|
+
|
|
727
|
+
A **nested field set** — `@key(fields: "id organization { id }")` — crosses as one
|
|
728
|
+
object: the fetch asks for `organization { id }` under a reserved alias, and the
|
|
729
|
+
representation carries it back in the shape the SDL spells, to any depth, nulls and
|
|
730
|
+
all. Where a type has more than one `@key`, the plan takes the first the fetching
|
|
731
|
+
subgraph can supply.
|
|
732
|
+
|
|
733
|
+
A **union or interface at a boundary** is planned per concrete type, because a
|
|
734
|
+
representation names one `__typename` and which one an object has isn't in the
|
|
735
|
+
query:
|
|
738
736
|
|
|
739
737
|
```ruby
|
|
740
738
|
router.reset_trace
|
|
@@ -742,136 +740,61 @@ router.execute("{ purchasables { name ... on Product { reviews { body } } } }")
|
|
|
742
740
|
router.trace.map { _1[:subgraph] } # => ["products", "reviews"]
|
|
743
741
|
```
|
|
744
742
|
|
|
745
|
-
The plan holds a branch per type the supergraph says that subgraph can answer
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
which would reply with its own slice — the one split a real router also makes.
|
|
776
|
-
|
|
777
|
-
**A wire fault at one subgraph has no representation here.** A subgraph is a
|
|
778
|
-
Ruby call, not a socket: `:router` fetches in-process, and `:wire` stubs one
|
|
779
|
-
endpoint in front of the whole router. So a timeout, an HTTP 500, malformed
|
|
780
|
-
JSON, or a bare `errors` with no `data` — anything that is a property of the
|
|
781
|
-
*transport* to one subgraph — is out of reach under `:router`; `:wire` plus
|
|
782
|
-
`Failure` covers those faults for the one stubbed endpoint, which is the whole
|
|
783
|
-
graph rather than any subgraph in it. A subgraph that *fails* is expressible
|
|
784
|
-
— raise from its resolver, or fake it — but partial availability is a
|
|
785
|
-
gateway's property, not this double's.
|
|
786
|
-
|
|
787
|
-
### What it refuses
|
|
788
|
-
|
|
789
|
-
Everything it can't plan **faithfully** raises
|
|
790
|
-
`GraphWeaver::Testing::Unplannable` (a `GraphWeaver::Error`), at plan time,
|
|
791
|
-
before any subgraph runs — so a refusal is never a half-executed query. A double
|
|
792
|
-
that approximated the rest of Apollo's planner would let a test pass on an
|
|
793
|
-
answer production disagrees with, which is the most expensive thing this library
|
|
794
|
-
can produce. Each refusal names the coordinate that stopped it and what to do —
|
|
795
|
-
`examples/federation.rb` prints one.
|
|
796
|
-
|
|
797
|
-
Every category, in the words `Unplannable#label` uses (`#category` is the
|
|
798
|
-
matching symbol):
|
|
799
|
-
|
|
800
|
-
| Refusal | Why |
|
|
801
|
-
|---|---|
|
|
802
|
-
| no `@key` to cross the boundary on | an entity fetch sends a representation built from a `@key`; with none there is nothing to send |
|
|
803
|
-
| an abstract type the supergraph doesn't break down | bucketing needs the concrete types a subgraph answers a union or interface with, and `@join__unionMember`/`@join__implements` is where a supergraph records that. A composition old enough to carry neither leaves nothing but a guess |
|
|
804
|
-
| an `@interfaceObject` the routing table can't attribute | one subgraph resolves a whole interface's implementations, so the supergraph never says which subgraph answers each of its fields. Per query, not per graph: a query that doesn't reach the type plans as if the directive weren't there |
|
|
805
|
-
| a `@fromContext` argument no fetch here can supply | federation 2.8's `@context`/`@fromContext` fills a field's argument from a selection on an ancestor, and only the gateway that planned the fetch knows what to put there — a subgraph's own resolver never fills one, so this is refused on any path, including the one where a single subgraph answers the whole query. Per query, like `@interfaceObject`: a query that doesn't reach the field plans as if the directive weren't there |
|
|
806
|
-
| a response delivered in more than one payload | `@defer`/`@stream` stream the rest of the answer over a multipart body after the first payload, and this router answers in one. Refused by name rather than left to validation, so the guarantee doesn't rest on whether the composed schema happens to declare the directive |
|
|
807
|
-
| a progressive `@override` still rolling out | federation 2.7's `@override(label:)` leaves *both* subgraphs resolving the field — composition decides nothing, the gateway splits traffic per request by the label's rule. A local router can't evaluate a rollout percentage, so it would answer from one side every time. Finish the rollout (drop the label) and composition drops the losing copy, which plans normally |
|
|
808
|
-
| a `@requires` whose field set names another `@requires` field | the router satisfies a `@requires` with one fetch, so it can't first satisfy that field's own requirement |
|
|
809
|
-
| a nested field set no one fetch can build | a nested field set crosses as one object, so one fetch has to answer the whole of it. Nesting itself is fine — this is the set whose fields are split across subgraphs, so the object would arrive half-built from each |
|
|
810
|
-
| `@skip`/`@include` on both a fragment and its field | one selection can't carry two conditions of the same name. Spell the condition once |
|
|
811
|
-
| an alias shadowing an injected `@key` | a fetch carries the `@key` it crosses on under a response key — Apollo under the field's own name, the local router under a reserved one — and an alias spelling either claims a key the fetch needs |
|
|
812
|
-
| a mutation's root fields span subgraphs | root mutation fields run in series, and splitting them across subgraphs would run them in whatever order the plan happened to. Sharing one subgraph they're fine, stitching below them and all — that's an ordinary read afterwards. Query roots are independent, so those are always fine |
|
|
813
|
-
| the routing table names no subgraph | nothing can route a field the supergraph doesn't place |
|
|
814
|
-
| a subgraph nothing here serves | it's served by another process, so there is nothing here to ask — unless you fake it (above) |
|
|
815
|
-
| introspection mixed with data | introspection is answered from the composed API schema and data from the subgraphs, and the two can't be merged. Split them into two operations |
|
|
816
|
-
| the document isn't one operation | pass `operation_name:` naming one of them |
|
|
817
|
-
| not a query or a mutation | the router plans against the composed schema's query and mutation roots; a subscription has neither |
|
|
818
|
-
| a fragment the document never defines | define it, or point the query at the file that does — validation rejects it first, so what you actually get back is an `errors` response |
|
|
819
|
-
| a federation construct the routing table doesn't read | an incomplete table makes every answer about this supergraph a guess. The one refusal raised **at construction**, before a single query |
|
|
820
|
-
| nested deeper than the router walks | past the walk's depth limit, which validation would have rejected first |
|
|
821
|
-
|
|
822
|
-
A document that fails ordinary GraphQL validation — an undefined fragment, a
|
|
823
|
-
subscription against a schema with no `Subscription` root — never reaches any of
|
|
824
|
-
this: it gets the same `errors` response a plain client gets, not an
|
|
825
|
-
`Unplannable`.
|
|
826
|
-
|
|
827
|
-
The construction-time one is worth planning around. A `@join__` directive the
|
|
828
|
-
table doesn't read refuses `Router.new` for the **whole graph**, so one team
|
|
829
|
-
adopting a newer federation feature in their own subgraph is an upgrade-timing
|
|
830
|
-
event for every team that tests with `:router` — including the ones whose
|
|
831
|
-
queries never go near it.
|
|
832
|
-
|
|
833
|
-
A subgraph two loaded schemas both fit raises a `ConfigurationError` rather than
|
|
834
|
-
an `Unplannable` — it's a wiring mistake, not a query the router declines — but
|
|
835
|
-
it raises where every other one does, on the query that reaches the subgraph.
|
|
836
|
-
Which classes happen to be loaded is not a fact about the query under test.
|
|
837
|
-
Naming a class in `subgraphs:` *is* a claim, so a wrong one still fails at
|
|
838
|
-
construction.
|
|
839
|
-
|
|
840
|
-
A double that quietly answered *differently* from the router would be worse than
|
|
841
|
-
no double at all, so
|
|
743
|
+
The plan holds a branch per type the supergraph says that subgraph can answer with;
|
|
744
|
+
the fetch asks for `__typename` under a reserved alias and buckets what comes back
|
|
745
|
+
by it — one `_entities` fetch per concrete type, none for an empty bucket. A
|
|
746
|
+
fragment whose condition can't hold there is dropped, as a real router drops it.
|
|
747
|
+
|
|
748
|
+
Three things it does that a naive merge doesn't, and that being wrong about would be
|
|
749
|
+
worse than refusing: it **re-applies GraphQL's null propagation** to the merged tree,
|
|
750
|
+
where a stitched fetch can put a null the composed schema says can't be there and no
|
|
751
|
+
subgraph is in a position to notice; it **re-paths errors and stamps the subgraph**,
|
|
752
|
+
so a subgraph's `_entities.2.shippingEstimate` reaches you as
|
|
753
|
+
`topProducts.2.shippingEstimate` with `extensions.service` naming it (whatever the
|
|
754
|
+
resolver put in `extensions` is left alone, and `locations` are dropped rather than
|
|
755
|
+
pointing into a query you never wrote — that is for a `GraphQL::ExecutionError`, and
|
|
756
|
+
a resolver raising anything else propagates out of `execute` as a Ruby exception);
|
|
757
|
+
and a field **`@skip`/`@include` removes comes back absent, not null**. Introspection
|
|
758
|
+
is answered from the composed API schema, never from a subgraph, which is the one
|
|
759
|
+
split a real router also makes.
|
|
760
|
+
|
|
761
|
+
**A wire fault at one subgraph has no representation here.** A subgraph is a Ruby
|
|
762
|
+
call, not a socket: `:router` fetches in-process, and `:wire` stubs one endpoint in
|
|
763
|
+
front of the whole router. So a timeout, an HTTP 500, malformed JSON, or a bare
|
|
764
|
+
`errors` with no `data` — anything that is a property of the *transport* to one
|
|
765
|
+
subgraph — is out of reach. A subgraph that *fails* is expressible (raise from its
|
|
766
|
+
resolver, or fake it), but partial availability is a gateway's property, not this
|
|
767
|
+
double's.
|
|
768
|
+
|
|
769
|
+
### How faithful is it, really
|
|
770
|
+
|
|
771
|
+
A double that quietly answered *differently* from the router would be worse than no
|
|
772
|
+
double at all, so
|
|
842
773
|
[`spec/integration/router_parity_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/integration/router_parity_spec.rb)
|
|
843
|
-
serves the demo subgraphs over HTTP, boots a
|
|
844
|
-
supergraph, and runs the whole corpus through
|
|
845
|
-
queries where a subgraph deliberately **fails**,
|
|
846
|
-
merge that doesn't re-propagate hands back a populated tree while
|
|
847
|
-
answers `data: null`.
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
**And a production router shows you less than this one does.** A subgraph error
|
|
862
|
-
arrives here with its message and an `extensions: {"service" => …}` stamp; an
|
|
863
|
-
Apollo Router with `include_subgraph_errors` omitted — the default — answers
|
|
864
|
-
`{"message" => "Subgraph errors redacted", "path" => […]}` with the extensions
|
|
865
|
-
emptied. A spec asserting on the message or the stamp therefore passes here and
|
|
866
|
-
against a dev router and fails in staging. `path` is what survives, so that is
|
|
867
|
-
what to assert on: [testing → production redacts what this router hands
|
|
868
|
-
you](testing.md#production-redacts-what-this-router-hands-you).
|
|
774
|
+
(`make integration`, node required) serves the demo subgraphs over HTTP, boots a
|
|
775
|
+
real `@apollo/gateway` on the same supergraph, and runs the whole corpus through
|
|
776
|
+
both — plus boundary probes and queries where a subgraph deliberately **fails**,
|
|
777
|
+
the cases where a merge that doesn't re-propagate hands back a populated tree while
|
|
778
|
+
the real router answers `data: null`. Match, refuse, or answer differently: the
|
|
779
|
+
spec fails on the third.
|
|
780
|
+
|
|
781
|
+
**But "a real router" is two things, and they disagree** — and a production one
|
|
782
|
+
shows you less than either. Everything above is measured against `@apollo/gateway`,
|
|
783
|
+
the deprecated JS gateway; the Rust Apollo Router differs on a subgraph 500's error
|
|
784
|
+
shape, on malformed JSON from a subgraph, on `@defer` (it supports it, behind an
|
|
785
|
+
`Accept: multipart/mixed` header) and on introspection, which it disables by
|
|
786
|
+
default — and with `include_subgraph_errors` omitted it answers `{"message" =>
|
|
787
|
+
"Subgraph errors redacted", "path" => […]}` where this router hands you the
|
|
788
|
+
subgraph's own message and its `service` stamp. So run a refusal you're unsure
|
|
789
|
+
about against the router you deploy, and assert on `path` rather than a message
|
|
790
|
+
that only survives locally:
|
|
791
|
+
[testing → production redacts what this router hands you](testing.md#production-redacts-what-this-router-hands-you).
|
|
869
792
|
|
|
870
793
|
### Is it worth wiring up? Measure.
|
|
871
794
|
|
|
872
|
-
The router's value is one number — the fraction of *your* queries it can plan —
|
|
873
|
-
|
|
874
|
-
|
|
795
|
+
The router's value is one number — the fraction of *your* queries it can plan — and
|
|
796
|
+
that depends on the shape of your graph and of your queries, so measure it rather
|
|
797
|
+
than guess:
|
|
875
798
|
|
|
876
799
|
```
|
|
877
800
|
$ rake graph_weaver:federation:coverage
|
|
@@ -879,129 +802,19 @@ $ rake graph_weaver:federation:coverage
|
|
|
879
802
|
accounts 4, reviews 4, products+reviews 3, accounts+reviews 2, products 2, accounts+products 1, accounts+products+reviews 1
|
|
880
803
|
```
|
|
881
804
|
|
|
882
|
-
**Two numbers, because they answer different questions.** *Plannable* is about
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
`QUERIES=` picks the directory; by default each graph's report measures that
|
|
900
|
-
graph's own `queries`, since a query written against one supergraph says
|
|
901
|
-
nothing about the next one along. Planning
|
|
902
|
-
needs the supergraph and nothing else, so this runs in CI with the SDL alone —
|
|
903
|
-
with no subgraph loaded the report drops the second number and says it counted
|
|
904
|
-
planning only. The subgraph line says which subgraphs each query touches, and
|
|
905
|
-
anything refused is listed after it grouped by category, so one glance says
|
|
906
|
-
whether the gap is one construct or many. (The first run above is the demo graph
|
|
907
|
-
in `spec/support/federation`, not a real app's mix.)
|
|
908
|
-
|
|
909
|
-
## Generating against a subgraph
|
|
910
|
-
|
|
911
|
-
A raw subgraph SDL — `rover subgraph fetch`, `_service { sdl }`, or the
|
|
912
|
-
`.graphql` in a service repo — loads too. It applies `@key`/`@external`/
|
|
913
|
-
`@shareable`/… without declaring them (federation v1 leaves them implicit, v2
|
|
914
|
-
imports them via `@link`), so the missing definitions are supplied on load;
|
|
915
|
-
anything the file declares itself wins. Whatever the `@link` header says the
|
|
916
|
-
directives are called is what's supplied — the bare `@key`, the namespaced
|
|
917
|
-
`@federation__key`, or `@primaryKey` from
|
|
918
|
-
`import: [{name: "@key", as: "@primaryKey"}]`. The header itself is read and
|
|
919
|
-
then dropped: it describes the file, not the graph. The federation directives
|
|
920
|
-
generate no code either way — codegen is query-driven.
|
|
921
|
-
|
|
922
|
-
Reach for this when the subgraph is what you have, or to type an `_entities`
|
|
923
|
-
query (below). But a subgraph is one service's slice of the graph, and its
|
|
924
|
-
field shapes are not always the composed ones (an `@external` field is a
|
|
925
|
-
reference, not something that subgraph serves) — for a client of the whole
|
|
926
|
-
graph, feed the composed artifact.
|
|
927
|
-
|
|
928
|
-
### `_entities`
|
|
929
|
-
|
|
930
|
-
Every subgraph serves the entity resolver
|
|
931
|
-
`_entities(representations: [_Any!]!): [_Entity]!`, and **no subgraph SDL
|
|
932
|
-
contains it**: `_service { sdl }` and `rover subgraph fetch` print the
|
|
933
|
-
*published* schema, where the plumbing is implicit. So it's supplied on the
|
|
934
|
-
subgraph path — `_Any`, `_Service`, and an `_Entity` union over the file's own
|
|
935
|
-
`@key`'d types — the same way the `@key`/`@external` definitions are. A file
|
|
936
|
-
that declares its own keeps it.
|
|
937
|
-
|
|
938
|
-
The read side is a normal union selection; `alias:` turns the
|
|
939
|
-
single-entity case into a clean accessor (see
|
|
940
|
-
[flat accessors](generated_modules.md#flat-accessors-with-alias)):
|
|
941
|
-
|
|
942
|
-
```ruby
|
|
943
|
-
GraphWeaver.extend_type("Query", alias: { entity: "_entities.first" }, optional: true)
|
|
944
|
-
```
|
|
945
|
-
|
|
946
|
-
The **input** side is generated. A representation must carry `__typename` and
|
|
947
|
-
satisfy one of the entity's `@key` field sets — both hard requirements of the
|
|
948
|
-
subgraph spec, and neither expressible in a bare `[_Any!]!`. So a query
|
|
949
|
-
selecting entities gets a `Representations` builder per entity it can resolve,
|
|
950
|
-
typed from the `@key` directives:
|
|
951
|
-
|
|
952
|
-
```ruby
|
|
953
|
-
UserQuery::Representations.user(id: "1")
|
|
954
|
-
# => {"__typename" => "User", "id" => "1"}
|
|
955
|
-
|
|
956
|
-
UserQuery.execute(reps: [UserQuery::Representations.user(id: "1")])
|
|
957
|
-
```
|
|
958
|
-
|
|
959
|
-
Key field sets are selection sets, so they're parsed as such:
|
|
960
|
-
|
|
961
|
-
| `@key(fields:)` | Builder |
|
|
962
|
-
|---|---|
|
|
963
|
-
| `"id"` | `Representations.user(id: "1")` |
|
|
964
|
-
| `"upc sku"` (compound) | `Representations.product(upc: "u", sku: 42)` |
|
|
965
|
-
| `"id organization { id }"` (nested) | `Representations.listing(id: "1", organization: { id: "o" })` |
|
|
966
|
-
| `"id lineItems { sku }"` over a `[LineItem!]!` | `Representations.order(id: "1", line_items: [{ sku: "a" }, { sku: "b" }])` |
|
|
967
|
-
| `"id"` **and** `"serial"` (alternatives) | `Representations.variant(id: "1")` *or* `(serial: "s")` |
|
|
968
|
-
|
|
969
|
-
A key field the schema declares as a **list** takes a list, and stays one on
|
|
970
|
-
the wire — a single object there would describe an entity that doesn't exist,
|
|
971
|
-
so it's refused rather than sent. The error spells the list hop `lineItems[]`,
|
|
972
|
-
which is also how the generated key set records it.
|
|
973
|
-
|
|
974
|
-
A type with one `@key` types its fields as **required kwargs**, so an
|
|
975
|
-
incomplete representation is an `srb tc` error rather than a round trip. What a
|
|
976
|
-
sig can't say is checked at runtime and raises `GraphWeaver::InputError` naming
|
|
977
|
-
the type and the field:
|
|
978
|
-
|
|
979
|
-
```
|
|
980
|
-
Variant representation satisfies none of its @keys — supply "id", or "serial"
|
|
981
|
-
Listing representation is missing @key "organization.id"
|
|
982
|
-
```
|
|
983
|
-
|
|
984
|
-
Key fields take the same loose input an `execute` kwarg does — a `params[:sku]`
|
|
985
|
-
String converts to the `Int` the `@key` declares — and a value that converts to
|
|
986
|
-
nothing raises `GraphWeaver::InputError` naming the representation and the
|
|
987
|
-
field:
|
|
988
|
-
|
|
989
|
-
```
|
|
990
|
-
Product representation sku: expected an Int, got "forty-two"
|
|
991
|
-
```
|
|
992
|
-
|
|
993
|
-
A `@key` field whose name a generated method can't take as a kwarg — `class`,
|
|
994
|
-
`hash`, or a Ruby keyword — takes a trailing underscore, the same one its prop
|
|
995
|
-
took: `@key(fields: "class")` builds `Representations.room(class_: "suite")`
|
|
996
|
-
and still sends `"class"` on the wire. A subgraph's `@key` field is not yours
|
|
997
|
-
to rename, so weaver renames its own side rather than refusing.
|
|
998
|
-
|
|
999
|
-
Only the declared key fields reach the wire — an extra key in a nested hash is
|
|
1000
|
-
dropped. Builders are emitted **only for the entities a query's `_entities`
|
|
1001
|
-
selection reaches** (codegen is query-driven, so a subgraph with fifty entities
|
|
1002
|
-
emits nothing for the forty-nine you didn't name), and a
|
|
1003
|
-
`@key(..., resolvable: false)` declares a key this subgraph does *not* answer
|
|
1004
|
-
for, so it builds nothing. Key fields typed as scalars get their registered Ruby
|
|
1005
|
-
type; anything else (a nested selection) is an open `Hash` the runtime narrows.
|
|
1006
|
-
Every shape above is a named example in
|
|
1007
|
-
[`spec/federation_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/federation_spec.rb).
|
|
805
|
+
**Two numbers, because they answer different questions.** *Plannable* is about the
|
|
806
|
+
graph — could the router split this query faithfully at all. *Servable here* is what
|
|
807
|
+
your suite actually gets: every subgraph that plan reaches is one this process
|
|
808
|
+
serves. In a [partly-local supergraph](#a-supergraph-only-partly-local) they differ,
|
|
809
|
+
and the plannable number alone reads optimistically — so the report lists the
|
|
810
|
+
queries that plan but reach a subgraph nothing here serves, each with the subgraph
|
|
811
|
+
that stopped it and the three ways out (name a schema, fake it, or run it against a
|
|
812
|
+
real router).
|
|
813
|
+
|
|
814
|
+
`QUERIES=` picks the directory; by default each graph's report measures that graph's
|
|
815
|
+
own `queries`, since a query written against one supergraph says nothing about the
|
|
816
|
+
next one along. Planning needs the supergraph and nothing else, so this runs in CI
|
|
817
|
+
with the SDL alone — with no subgraph loaded the report drops the second number and
|
|
818
|
+
says it counted planning only. Anything refused is listed grouped by category, so
|
|
819
|
+
one glance says whether the gap is one construct or many. (The run above is the demo
|
|
820
|
+
graph in `spec/support/federation`, not a real app's mix.)
|