graph_weaver 0.6.1 → 0.7.0
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/CHANGELOG.md +1447 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +20 -6
- data/docs/alternatives.md +201 -0
- data/docs/cassettes.md +17 -1
- data/docs/errors.md +382 -17
- data/docs/federation.md +469 -63
- data/docs/generated_modules.md +231 -15
- data/docs/getting_started.md +497 -104
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +28 -0
- data/docs/scalars.md +190 -26
- data/docs/testing.md +457 -58
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +328 -3
- data/graph_weaver.gemspec +7 -0
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +47 -10
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +404 -197
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/errors.rb +264 -34
- data/lib/graph_weaver/federation.rb +119 -26
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +17 -11
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +78 -0
- data/lib/graph_weaver/internal/headers.rb +51 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +40 -4
- data/lib/graph_weaver/internal.rb +183 -1
- data/lib/graph_weaver/log_subscriber.rb +66 -0
- data/lib/graph_weaver/logging.rb +136 -12
- data/lib/graph_weaver/query_module.rb +36 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +33 -5
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +221 -49
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +6 -5
- data/lib/graph_weaver/testing/endpoint.rb +106 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +133 -44
- data/lib/graph_weaver/testing/router.rb +58 -11
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +46 -4
- data/lib/graph_weaver/transport.rb +109 -26
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +474 -106
- metadata +56 -1
data/docs/federation.md
CHANGED
|
@@ -10,52 +10,139 @@ subgraph SDL, or a live router — and recognizes which it got.
|
|
|
10
10
|
`SchemaLoader.load` (and `Client.new(path_or_sdl)`) accept each as an SDL file
|
|
11
11
|
or an introspection dump.
|
|
12
12
|
|
|
13
|
+
**Which half is yours** depends on what your app does with the graph, and most
|
|
14
|
+
of them are only one:
|
|
15
|
+
|
|
16
|
+
- **You call the gateway and compose nothing.** [Generating against a
|
|
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.
|
|
22
|
+
- **You publish a subgraph.** Add [generating against a
|
|
23
|
+
subgraph](#generating-against-a-subgraph) and `federation:diff`, which is
|
|
24
|
+
what tells you your change needs a recompose.
|
|
25
|
+
- **You own the supergraph.** All of it, plus [in CI](#in-ci): the checks here
|
|
26
|
+
compare an app to its own artifacts, and the pre-deploy check against the
|
|
27
|
+
live graph is `rover`'s.
|
|
28
|
+
|
|
13
29
|
## Generating for a federated graph
|
|
14
30
|
|
|
15
31
|
**Queries go through the gateway?** Generate against the supergraph. It is the
|
|
16
32
|
whole graph in one schema, so every registration matches and there is nothing
|
|
17
33
|
else to decide.
|
|
18
34
|
|
|
19
|
-
**Calling subgraphs directly?** One
|
|
20
|
-
Registrations stay in one global registry, because names compose by identity
|
|
21
|
-
across a graph — `Money` is one Ruby type wherever it appears, and `Person` is
|
|
22
|
-
one entity even though a single subgraph owns `birthday`. So a registration a
|
|
23
|
-
given subgraph doesn't declare is not an error; generation warns and carries on.
|
|
24
|
-
`rake graph_weaver:generate` and `verify` print the list once per run, after the
|
|
25
|
-
files:
|
|
26
|
-
|
|
27
|
-
```
|
|
28
|
-
register_scalar("Money") matches no scalar in Billing::Schema — a typo, or a registration for another schema
|
|
29
|
-
```
|
|
35
|
+
**Calling subgraphs directly?** One graph per subgraph, declared once:
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
```ruby
|
|
38
|
+
GraphWeaver.graph :billing do
|
|
39
|
+
schema "billing.graphql"
|
|
40
|
+
queries "app/graphql/billing"
|
|
41
|
+
output "app/graphql/generated/billing"
|
|
42
|
+
namespace "Billing"
|
|
43
|
+
register_scalar "Money", Money
|
|
44
|
+
end
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
```
|
|
36
54
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
One `rake graph_weaver:generate` generates both, and each subgraph is held only
|
|
56
|
+
to the registrations declared for it. (Naming a live subgraph *class* from a
|
|
57
|
+
Rails initializer takes a lambda — `schema -> { Billing::Schema }` — see
|
|
58
|
+
[getting started](getting_started.md#more-than-one-schema).)
|
|
41
59
|
|
|
42
|
-
|
|
60
|
+
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
|
+
appears, and `Person` is one entity even though a single subgraph owns
|
|
63
|
+
`birthday`. So a registration a given subgraph doesn't declare is not an error;
|
|
64
|
+
generation warns and carries on. `rake graph_weaver:generate` and `verify` print
|
|
65
|
+
the list once per run, after the files:
|
|
43
66
|
|
|
44
|
-
GraphWeaver.register_scalar("Person.birthday", Date)
|
|
45
|
-
GraphWeaver.generate!(schema: "directory.graphql",
|
|
46
|
-
queries: "app/graphql/directory", output: "app/graphql/generated/directory")
|
|
47
67
|
```
|
|
68
|
+
register_scalar("Money") matches no scalar in Billing::Schema — a typo, or a registration for another schema
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`GraphWeaver.unmatched_registrations` is that same list as data, for a Rakefile
|
|
72
|
+
or a spec that would rather gate on it than read it. Moving a registration into
|
|
73
|
+
the graph block that needs it is what makes those lines go away.
|
|
48
74
|
|
|
49
75
|
This holds for entity fields too, which is the case that would otherwise bite:
|
|
50
76
|
every subgraph referencing an entity declares it, so a subgraph carrying
|
|
51
|
-
`Person` for its `@key` alone sees
|
|
52
|
-
a field it doesn't own — a
|
|
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.
|
|
53
80
|
|
|
54
81
|
What a subgraph *can* disprove still fails generation: a name it declares as
|
|
55
82
|
something else (`register_scalar("Species")` where `Species` is an enum), and a
|
|
56
83
|
coordinate whose field it declares as a composite. Neither is redeemable by any
|
|
57
84
|
schema in the graph.
|
|
58
85
|
|
|
86
|
+
## Producing a supergraph
|
|
87
|
+
|
|
88
|
+
GraphWeaver consumes a supergraph; composing one is Apollo's job. Composition
|
|
89
|
+
takes one SDL file per subgraph, so the question is where each file comes from.
|
|
90
|
+
|
|
91
|
+
**Yours.** [`apollo-federation`](https://github.com/Gusto/apollo-federation-ruby)
|
|
92
|
+
is the gem that makes a graphql-ruby schema a subgraph — `@key`,
|
|
93
|
+
`resolve_reference`, the directives — and it adds `federation_sdl` to the schema
|
|
94
|
+
class, which prints exactly what a composer wants:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
File.write("supergraph/accounts.graphql", Accounts::Schema.federation_sdl)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
One ordering trap in that gem: declare `orphan_types` **before** `query` in the
|
|
101
|
+
schema class. It computes the `_Entity` union when `query` is called, so an
|
|
102
|
+
`orphan_types` after it drops those types from the printed SDL and from
|
|
103
|
+
`_entities` with no error at all, while `Schema.types` still lists them.
|
|
104
|
+
|
|
105
|
+
`orphan_types` is also what an **extend-only type** needs — one this subgraph
|
|
106
|
+
contributes fields to but never returns from its own `Query`, the shape a
|
|
107
|
+
warehouse/inventory split produces. Nothing reaches it from a root field, so
|
|
108
|
+
graphql-ruby never visits it, `federation_sdl` never prints it, and the fields
|
|
109
|
+
simply don't compose: no error from graphql-ruby, none from apollo-federation,
|
|
110
|
+
and the first sign is a supergraph missing fields you wrote. Name every such
|
|
111
|
+
type in `orphan_types`, before `query`.
|
|
112
|
+
|
|
113
|
+
**Everyone else's.** From the team that runs it: a file they publish, `rover
|
|
114
|
+
subgraph fetch` against their endpoint, or your schema registry.
|
|
115
|
+
|
|
116
|
+
**Composed.** [`rover supergraph compose`](https://www.apollographql.com/docs/rover/commands/supergraphs)
|
|
117
|
+
reads a config naming each subgraph's routing url and SDL file, and prints the
|
|
118
|
+
supergraph to stdout:
|
|
119
|
+
|
|
120
|
+
```yaml
|
|
121
|
+
# supergraph-config.yaml
|
|
122
|
+
federation_version: =2.14.4 # rover wants an exact one
|
|
123
|
+
subgraphs:
|
|
124
|
+
accounts:
|
|
125
|
+
routing_url: https://accounts.internal/graphql
|
|
126
|
+
schema: { file: ./accounts.graphql }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
rover supergraph compose --config supergraph-config.yaml \
|
|
131
|
+
--elv2-license accept > supergraph.graphql
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`--elv2-license accept` accepts the Elastic license on the composition binary
|
|
135
|
+
rover downloads; without it rover asks, and a CI job has nobody to answer.
|
|
136
|
+
Commit the result — from here it is an ordinary schema dump, and
|
|
137
|
+
[`federation:diff`](#has-the-supergraph-been-recomposed) is what catches it
|
|
138
|
+
going stale.
|
|
139
|
+
|
|
140
|
+
Already on a node toolchain? `@apollo/composition` composes in-process with no
|
|
141
|
+
rover install. The suite does it that way, and its two files are the whole loop
|
|
142
|
+
end to end — schema classes, `federation_sdl`, composed supergraph:
|
|
143
|
+
[`recompose.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/support/federation/recompose.rb)
|
|
144
|
+
driving [`compose.mjs`](https://github.com/dpep/graph_weaver/blob/main/spec/support/federation/compose.mjs).
|
|
145
|
+
|
|
59
146
|
## Generating against a supergraph
|
|
60
147
|
|
|
61
148
|
A supergraph SDL works as-is. On load, GraphWeaver strips the composition
|
|
@@ -82,8 +169,8 @@ A supergraph is a **superset** of the API schema: it carries elements the public
|
|
|
82
169
|
API hides, marked `@inaccessible`. You'll meet the directive rolling out a change
|
|
83
170
|
to a **shared type** — add the field to one subgraph marked `@inaccessible` so
|
|
84
171
|
composition doesn't require every subgraph to have it yet, roll it out, then drop
|
|
85
|
-
the directive to publish it.
|
|
86
|
-
|
|
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).
|
|
87
174
|
|
|
88
175
|
Loading strips every `@inaccessible` element and cascades: a
|
|
89
176
|
field/argument/union-member/interface referencing a removed type goes too, and a
|
|
@@ -101,6 +188,54 @@ that hide nothing keep their field and are ignored: `@requiresScopes` / `@policy
|
|
|
101
188
|
/ `@authenticated` enforce at runtime, `@tag` / `@requires` / `@provides` /
|
|
102
189
|
`@external` are metadata.
|
|
103
190
|
|
|
191
|
+
### Contracts and variants
|
|
192
|
+
|
|
193
|
+
A contract variant is a supergraph built from the same subgraphs with some
|
|
194
|
+
coordinates filtered out: a subgraph marks them `@tag(name: "internal")`, and
|
|
195
|
+
GraphOS builds a second supergraph where everything carrying that tag is
|
|
196
|
+
`@inaccessible`. Nothing special is needed to generate against one — it is a
|
|
197
|
+
supergraph, and the `@inaccessible` subtraction above is exactly what makes it
|
|
198
|
+
the narrower schema its clients see:
|
|
199
|
+
|
|
200
|
+
```graphql
|
|
201
|
+
# reviews, the subgraph
|
|
202
|
+
type User @key(fields: "id") {
|
|
203
|
+
id: ID!
|
|
204
|
+
supportTier: String! @tag(name: "internal")
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
```graphql
|
|
209
|
+
# the internal variant's supergraph — the tag rides along, nothing is hidden
|
|
210
|
+
supportTier: String! @join__field(graph: REVIEWS)
|
|
211
|
+
# the public variant's supergraph — what the contract build adds
|
|
212
|
+
supportTier: String! @inaccessible @join__field(graph: REVIEWS)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Generate a query selecting `supportTier` against **internal** and it generates;
|
|
216
|
+
against **public**, codegen refuses at the boundary —
|
|
217
|
+
`QueryValidationError` naming `supportTier`, because the load subtracted the
|
|
218
|
+
field before validation saw it. That is the guarantee: a client generated
|
|
219
|
+
against the variant it calls cannot select something the router will reject.
|
|
220
|
+
|
|
221
|
+
Two things to know, both of which bit this document into existence:
|
|
222
|
+
|
|
223
|
+
- **`federation:diff` can't tell the variants apart.** It reads the routing
|
|
224
|
+
table, and `@inaccessible` is a directive the table doesn't carry, so both
|
|
225
|
+
variants report "matches the schemas here" against the same subgraphs. That
|
|
226
|
+
is the same [directive blindness](#has-the-supergraph-been-recomposed) as
|
|
227
|
+
everywhere else, not a contract-specific hole.
|
|
228
|
+
- **Nothing cross-checks the variant you generated against with the endpoint
|
|
229
|
+
you call.** `verify`, `queries:check` and `generate` take the schema as
|
|
230
|
+
given; generate against internal, deploy against public, and every check
|
|
231
|
+
stays green until a live 400. Point each graph at the variant it actually
|
|
232
|
+
calls, and let the graph declaration be the single place that says so.
|
|
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.
|
|
238
|
+
|
|
104
239
|
### The routing table
|
|
105
240
|
|
|
106
241
|
Stripping the machinery answers "what does this graph look like". The other
|
|
@@ -156,65 +291,234 @@ A committed supergraph is a snapshot of a composition. Change a subgraph and
|
|
|
156
291
|
skip the recompose and it quietly describes a graph that no longer exists —
|
|
157
292
|
the failure that bites a federated app mid-migration, and the one the other
|
|
158
293
|
checks don't ask about. `graph_weaver:verify` asks whether the generated Ruby
|
|
159
|
-
is fresh, `schema:diff` whether
|
|
294
|
+
is fresh, `schema:diff` whether whatever your dump came from has drifted,
|
|
160
295
|
`queries:check` whether drift broke a query. This asks whether the supergraph
|
|
161
296
|
still describes your subgraphs:
|
|
162
297
|
|
|
163
298
|
```sh
|
|
164
|
-
rake graph_weaver:federation:diff
|
|
299
|
+
rake graph_weaver:federation:diff
|
|
165
300
|
```
|
|
166
301
|
|
|
302
|
+
**Every `federation:*` task finds the supergraph the same way: it runs once per
|
|
303
|
+
declared graph whose schema is a composed one, and heads each report with that
|
|
304
|
+
graph's name.** An app that wrote `GraphWeaver.graph(:accounts) { schema
|
|
305
|
+
"app/graphql/accounts/supergraph.graphql" }` has already said where its
|
|
306
|
+
supergraph is, and a graph that is in none — a plain API schema, a live schema
|
|
307
|
+
class — is simply not a subject for these tasks. A single-schema app whose
|
|
308
|
+
committed dump is a composed supergraph is that one graph, unnamed, and its
|
|
309
|
+
output says nothing about graphs at all — until it declares a second graph
|
|
310
|
+
beside it, at which point it has to name the first one too
|
|
311
|
+
([why](getting_started.md#more-than-one-schema)). `SUPERGRAPH=supergraph.graphql`
|
|
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" }`.
|
|
322
|
+
|
|
167
323
|
It reads the routing table and the subgraph schemas loaded in this process —
|
|
168
|
-
**no network** —
|
|
169
|
-
|
|
324
|
+
**no network** — and exits non-zero on drift, so CI can gate on it ([in
|
|
325
|
+
CI](#in-ci)):
|
|
170
326
|
|
|
171
327
|
```
|
|
172
|
-
supergraph.graphql: 1 stale, 1 not composed in (checked 1 of 3 subgraphs)
|
|
328
|
+
supergraph.graphql: 1 stale, 1 shape, 1 not composed in (checked 1 of 3 subgraphs)
|
|
173
329
|
|
|
174
330
|
stale — the supergraph carries these, no schema here defines them (recompose):
|
|
175
331
|
Product.weight (products)
|
|
176
332
|
|
|
333
|
+
shape — both carry these, with different types (recompose):
|
|
334
|
+
Warehouse.code (inventory): String! in the supergraph, ID! here
|
|
335
|
+
|
|
177
336
|
not composed in — a schema here defines these, the supergraph doesn't carry them:
|
|
178
337
|
Product.dimensions (Products::Schema)
|
|
179
338
|
|
|
180
|
-
not checked —
|
|
181
|
-
shipping (Shipment)
|
|
339
|
+
not checked — no schema here matches what the supergraph says only these resolve (running elsewhere, or the subgraph is gone):
|
|
340
|
+
shipping (Shipment, Shipment.eta, Order.shipment)
|
|
182
341
|
|
|
183
342
|
not checked — answered with fabricated data:
|
|
184
343
|
reviews
|
|
185
344
|
```
|
|
186
345
|
|
|
187
|
-
|
|
188
|
-
**
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
346
|
+
Three kinds, because they mean different things: **stale** is "recompose",
|
|
347
|
+
**shape** is "recompose" for a field neither side dropped, and **not composed
|
|
348
|
+
in** is "publish the subgraph". Stale and shape name the subgraph the
|
|
349
|
+
supergraph blames — whose code to look at, whose team to talk to. Field-set
|
|
350
|
+
comparison is deliberately looser than equality, since a subgraph carries
|
|
351
|
+
plumbing (`_entities`, `_service`) no supergraph has and a field can
|
|
352
|
+
legitimately sit in more than one subgraph (`@external` copies, `@shareable`) —
|
|
353
|
+
so one candidate schema agreeing settles it.
|
|
354
|
+
|
|
355
|
+
**What is compared is a coordinate's presence and its type, and nothing else.**
|
|
356
|
+
`Warehouse.code` going from `String!` to `ID!` under a committed composition is
|
|
357
|
+
the shape row above; the `@key` it is part of, a field's arguments, its
|
|
358
|
+
directives, and everything a type says about itself beyond its fields are not
|
|
359
|
+
read, and a change to any of them still reports clean. The one asymmetry worth
|
|
360
|
+
knowing is an `@override` migration: a supergraph
|
|
361
|
+
published *ahead* of the code is caught (the old side's field is `stale`), but
|
|
362
|
+
code ahead of the supergraph — the new side declaring the field with
|
|
363
|
+
`@override` before the recompose — is not, because both sides carry the field
|
|
364
|
+
and its type, and only the `@override` marker, which lives in
|
|
365
|
+
apollo-federation's own bookkeeping rather than in the schema this reads, says
|
|
366
|
+
ownership is moving. Recompose and the report catches up; until then it is the
|
|
367
|
+
one drift this check can't see. The check compares **schema objects**, not SDL
|
|
368
|
+
text, so how a subgraph spells its directives — `@key`, `@federation__key`, or a
|
|
369
|
+
name it imported them under — can't affect what it reports.
|
|
370
|
+
|
|
371
|
+
**Finish an `@override` migration from the old side.** Once the new owner
|
|
372
|
+
resolves the field, the original owner's copy is dead code that composition
|
|
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.
|
|
193
406
|
|
|
194
407
|
**A supergraph is routinely only partly local**, so the report names three
|
|
195
|
-
states rather than two: checked, not here (running elsewhere — or the
|
|
196
|
-
gone), and [faked](#the-local-router). A clean report that quietly checked one
|
|
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
|
|
197
410
|
subgraph of three would be actively misleading, so the headline counts them and
|
|
198
411
|
the sections name them. Only drift fails the task; absence is a supported
|
|
199
412
|
setup. Checking **none** of them fails too — "checked 0 of 4" attached to exit 0
|
|
200
|
-
is a gate that passes whatever the subgraphs say. (
|
|
201
|
-
the `federation:*` tasks eager-load
|
|
202
|
-
|
|
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
|
+
```
|
|
203
426
|
|
|
204
|
-
|
|
205
|
-
|
|
427
|
+
A **warning, not drift**, deliberately: a process that loads a subgraph of a
|
|
428
|
+
supergraph this run never reads is the same picture, and nothing on either side
|
|
429
|
+
tells the two apart. A schema is a subgraph here if it serves `Query._service`,
|
|
430
|
+
which is how a gateway reads one to compose it. `Drift#unplaced` is the list for
|
|
431
|
+
one supergraph; the task asks every graph in the run, so a multi-graph app's
|
|
432
|
+
second supergraph places its own schemas rather than having them reported
|
|
433
|
+
against the first.
|
|
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
|
|
206
441
|
`subgraphs:` map [`Testing::Router`](#the-local-router) takes is accepted here,
|
|
207
442
|
and a named schema skips detection:
|
|
208
443
|
|
|
209
444
|
```ruby
|
|
445
|
+
require "graph_weaver/federation" # the rake tasks do this for you
|
|
446
|
+
|
|
210
447
|
GraphWeaver::Federation::Drift.new(
|
|
211
448
|
supergraph: "supergraph.graphql",
|
|
212
449
|
subgraphs: { "products" => Products::Schema, "inventory" => :fake },
|
|
213
450
|
).report
|
|
214
451
|
```
|
|
215
452
|
|
|
216
|
-
`#to_h` is the JSON-ready `{"stale" => …, "
|
|
217
|
-
"faked" => …}
|
|
453
|
+
`#to_h` is the JSON-ready `{"stale" => …, "shape" => …, "uncomposed" => …,
|
|
454
|
+
"skipped" => …, "faked" => …}` — a `shape` entry is `{"subgraphs" => […],
|
|
455
|
+
"supergraph" => "String!", "here" => ["ID!"]}` — and `#drift?` is what the task
|
|
456
|
+
exits on. `#unplaced` sits
|
|
457
|
+
outside both, being the warning above rather than drift.
|
|
458
|
+
|
|
459
|
+
**`Drift` never calls a resolver**, so what it accepts in that map is wider than
|
|
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:
|
|
463
|
+
|
|
464
|
+
```ruby
|
|
465
|
+
GraphWeaver::Federation::Drift.new(
|
|
466
|
+
supergraph: "supergraph.graphql",
|
|
467
|
+
subgraphs: { "accounts" => GraphWeaver::SchemaLoader.load(File.read("accounts.graphql")) },
|
|
468
|
+
).report
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
That is the real answer to "what does a Python subgraph look like to these
|
|
472
|
+
tasks": to the router it is `:fake` or absent, but to `federation:diff` it is a
|
|
473
|
+
fully compared citizen.
|
|
474
|
+
|
|
475
|
+
**Name the schema whenever you are diffing a *proposal*.** Detection unions
|
|
476
|
+
every loaded schema that fits a subgraph, so a console session that builds the
|
|
477
|
+
changed SDL while the unmodified `Reviews::Schema` is still loaded reports
|
|
478
|
+
clean — the real class supplies the field the proposal dropped, and both are
|
|
479
|
+
candidates for "reviews". `subgraphs:` naming the one you mean is what makes
|
|
480
|
+
the answer about your proposal.
|
|
481
|
+
|
|
482
|
+
## In CI
|
|
483
|
+
|
|
484
|
+
`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
|
+
the step. Add it where the subgraph classes live: an app that only *calls* the
|
|
487
|
+
gateway loads none of them, and the task aborts rather than pass having checked
|
|
488
|
+
nothing ([above](#has-the-supergraph-been-recomposed)).
|
|
489
|
+
|
|
490
|
+
**What that job does not do is look at the schema production is serving**, and
|
|
491
|
+
on a federated graph nothing here can. Every check in it compares the app to
|
|
492
|
+
artifacts checked in beside it: `verify` to the generated Ruby, `queries:check`
|
|
493
|
+
and `unused` to the dump, `federation:diff` to the subgraph classes in this
|
|
494
|
+
process. `schema:diff` is the one that reads a live source, and it can't be
|
|
495
|
+
pointed at a supergraph — a composed supergraph records no source url because no
|
|
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.
|
|
501
|
+
|
|
502
|
+
That gap is Apollo's to close, and it has two commands for it:
|
|
503
|
+
|
|
504
|
+
```sh
|
|
505
|
+
rover subgraph check my-graph@prod --name products --schema products.graphql
|
|
506
|
+
rover supergraph fetch my-graph@prod # then recompose and diff what you get back
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
`rover subgraph check` asks GraphOS whether publishing this subgraph would break
|
|
510
|
+
the composition or a client operation registered against the variant — the
|
|
511
|
+
pre-merge half. `rover supergraph fetch` hands you the supergraph the router is
|
|
512
|
+
running, which is the artifact `federation:diff` should be pointed at when what
|
|
513
|
+
you want to know is "does the deployed graph still answer my queries", rather
|
|
514
|
+
than "is my checked-in supergraph consistent with my checked-in subgraphs".
|
|
515
|
+
|
|
516
|
+
Nothing in this gem talks to GraphOS, and the runtime half is where a federated
|
|
517
|
+
app finds out: a query the served supergraph rejects comes back with
|
|
518
|
+
`schema_stale?` true and a message naming the repair
|
|
519
|
+
([errors → stale schemas](errors.md#stale-schemas)). That is detection at the
|
|
520
|
+
point of damage, which is exactly why the two `rover` commands belong in the
|
|
521
|
+
same job as the five tasks.
|
|
218
522
|
|
|
219
523
|
## The local router
|
|
220
524
|
|
|
@@ -232,8 +536,19 @@ GraphWeaver.client = GraphWeaver::Testing::Router.new(
|
|
|
232
536
|
)
|
|
233
537
|
```
|
|
234
538
|
|
|
539
|
+
`context:` reaches every subgraph, because every subgraph is a Ruby call here.
|
|
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
|
+
|
|
235
545
|
In rspec that's the [`graphql: :router`](testing.md#a-federated-graph--graphql-router)
|
|
236
|
-
tag and there is nothing to pass — the tag builds it, once for the suite.
|
|
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.
|
|
237
552
|
`router.trace` records the fetches made since the last `reset_trace`, in order
|
|
238
553
|
(subgraph, query, variables); the same lines go to `GraphWeaver.logger` at
|
|
239
554
|
`:debug`. It **accumulates across executes**, because the question worth asking
|
|
@@ -241,6 +556,27 @@ is which subgraphs a code path touched and a service object rarely runs one
|
|
|
241
556
|
query. The rspec tag resets it before each example; outside rspec call
|
|
242
557
|
`router.reset_trace` around the code path you're measuring.
|
|
243
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
|
+
|
|
244
580
|
**[`examples/federation.rb`](https://github.com/dpep/graph_weaver/blob/main/examples/federation.rb)** is the whole shape
|
|
245
581
|
in one runnable file, and the only example that needs no network: three real
|
|
246
582
|
subgraphs, a boundary-crossing query through a generated module, the trace,
|
|
@@ -285,7 +621,7 @@ is your own `config.eager_load`, which Rails leaves off outside CI. To see what
|
|
|
285
621
|
detection sees, and get a map to paste:
|
|
286
622
|
|
|
287
623
|
```
|
|
288
|
-
$ rake graph_weaver:federation:subgraphs
|
|
624
|
+
$ rake graph_weaver:federation:subgraphs
|
|
289
625
|
subgraphs: {
|
|
290
626
|
"accounts" => Accounts::Schema, # matched: defines Query.me, Query.user, Query.users
|
|
291
627
|
"products" => Products::Schema, # matched: defines Product.name, Product.price, Product.weight
|
|
@@ -365,7 +701,9 @@ An operation that resolves in **one subgraph** goes over verbatim. One that
|
|
|
365
701
|
`@key` under a reserved alias, refetches it from the owning subgraph through
|
|
366
702
|
`_entities(representations:)`, and stitches the answer back. Every node at one
|
|
367
703
|
level goes in **one** `_entities` call, so a list of users and all their
|
|
368
|
-
reviews' products is three fetches, not one per row
|
|
704
|
+
reviews' products is three fetches, not one per row — and everything that level
|
|
705
|
+
crosses into one subgraph shares that call, a `@requires` field alongside a
|
|
706
|
+
plain one included. Root fields that resolve
|
|
369
707
|
in different subgraphs get one fetch each. A `@provides` copy is read in place,
|
|
370
708
|
so nothing leaves the subgraph for a field the copy already holds.
|
|
371
709
|
|
|
@@ -418,15 +756,34 @@ would be worse than refusing:
|
|
|
418
756
|
where the composed schema says non-null, and no subgraph is in a position to
|
|
419
757
|
notice. The router re-applies GraphQL's propagation rules to the merged
|
|
420
758
|
result, so a subtree the real router would have nulled comes back null here.
|
|
421
|
-
- **Error re-pathing
|
|
422
|
-
|
|
423
|
-
|
|
759
|
+
- **Error re-pathing, and a stamp saying which subgraph failed.** A subgraph
|
|
760
|
+
reports `_entities.2.shippingEstimate`; you get
|
|
761
|
+
`topProducts.2.shippingEstimate`, with `extensions.service` naming the
|
|
762
|
+
subgraph — the Apollo Router's spelling, because it is the current product
|
|
763
|
+
(the deprecated `@apollo/gateway` says `extensions.serviceName` and adds
|
|
764
|
+
`code: "DOWNSTREAM_SERVICE_ERROR"`, which isn't a code any subgraph here
|
|
765
|
+
set). Whatever the resolver put in `extensions` is left alone, including its
|
|
766
|
+
own `service`. `locations` are dropped rather than pointing into a query you
|
|
767
|
+
never wrote. That is for a `GraphQL::ExecutionError`, which is what `errors`
|
|
768
|
+
carries; a resolver that raises anything else propagates as a Ruby exception
|
|
769
|
+
out of `execute`, the same way it would from graphql-ruby on its own, and
|
|
770
|
+
nothing here catches it into a response.
|
|
424
771
|
- **`@skip`/`@include` on a stitched field.** A skipped field comes back
|
|
425
772
|
*absent*, not null.
|
|
426
773
|
|
|
427
774
|
Introspection is answered from the composed API schema, never from a subgraph,
|
|
428
775
|
which would reply with its own slice — the one split a real router also makes.
|
|
429
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
|
+
|
|
430
787
|
### What it refuses
|
|
431
788
|
|
|
432
789
|
Everything it can't plan **faithfully** raises
|
|
@@ -445,7 +802,9 @@ matching symbol):
|
|
|
445
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 |
|
|
446
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 |
|
|
447
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 |
|
|
448
|
-
| 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. Per query, like `@interfaceObject`: a
|
|
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 |
|
|
449
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 |
|
|
450
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 |
|
|
451
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 |
|
|
@@ -456,10 +815,21 @@ matching symbol):
|
|
|
456
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 |
|
|
457
816
|
| the document isn't one operation | pass `operation_name:` naming one of them |
|
|
458
817
|
| not a query or a mutation | the router plans against the composed schema's query and mutation roots; a subscription has neither |
|
|
459
|
-
| a fragment the document never defines | define it, or point the query at the file that does |
|
|
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 |
|
|
460
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 |
|
|
461
820
|
| nested deeper than the router walks | past the walk's depth limit, which validation would have rejected first |
|
|
462
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
|
+
|
|
463
833
|
A subgraph two loaded schemas both fit raises a `ConfigurationError` rather than
|
|
464
834
|
an `Unplannable` — it's a wiring mistake, not a query the router declines — but
|
|
465
835
|
it raises where every other one does, on the query that reaches the subgraph.
|
|
@@ -478,6 +848,25 @@ answers `data: null`. Three outcomes, one of them a defect: match, refuse, or
|
|
|
478
848
|
answer differently, and the spec fails on the third. `make integration` runs it
|
|
479
849
|
(node required).
|
|
480
850
|
|
|
851
|
+
**"A real router" is two things, and they disagree.** Everything measured above
|
|
852
|
+
is against `@apollo/gateway` — the deprecated JS gateway, which is what the
|
|
853
|
+
parity spec boots. The Rust Apollo Router, which is the current product,
|
|
854
|
+
differs from it on a subgraph 500's error shape, on malformed JSON from a
|
|
855
|
+
subgraph, on `@defer` (it supports it, behind an `Accept: multipart/mixed`
|
|
856
|
+
header; the gateway doesn't know the directive), and on introspection, which it
|
|
857
|
+
disables by default. So when a refusal says "run this one against a real
|
|
858
|
+
router", run it against the one you deploy — not against the one these numbers
|
|
859
|
+
came from.
|
|
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).
|
|
869
|
+
|
|
481
870
|
### Is it worth wiring up? Measure.
|
|
482
871
|
|
|
483
872
|
The router's value is one number — the fraction of *your* queries it can plan —
|
|
@@ -485,7 +874,7 @@ and that depends on the shape of your graph and of your queries, so measure it
|
|
|
485
874
|
rather than guess:
|
|
486
875
|
|
|
487
876
|
```
|
|
488
|
-
$ rake graph_weaver:federation:coverage
|
|
877
|
+
$ rake graph_weaver:federation:coverage
|
|
489
878
|
17/17 queries plannable locally (100%), 17 servable here
|
|
490
879
|
accounts 4, reviews 4, products+reviews 3, accounts+reviews 2, products 2, accounts+products 1, accounts+products+reviews 1
|
|
491
880
|
```
|
|
@@ -507,7 +896,9 @@ plannable, but nothing here serves what they reach (3) — name a schema for tho
|
|
|
507
896
|
tracking.graphql shipping
|
|
508
897
|
```
|
|
509
898
|
|
|
510
|
-
`QUERIES=` picks the directory
|
|
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
|
|
511
902
|
needs the supergraph and nothing else, so this runs in CI with the SDL alone —
|
|
512
903
|
with no subgraph loaded the report drops the second number and says it counted
|
|
513
904
|
planning only. The subgraph line says which subgraphs each query touches, and
|
|
@@ -520,10 +911,13 @@ in `spec/support/federation`, not a real app's mix.)
|
|
|
520
911
|
A raw subgraph SDL — `rover subgraph fetch`, `_service { sdl }`, or the
|
|
521
912
|
`.graphql` in a service repo — loads too. It applies `@key`/`@external`/
|
|
522
913
|
`@shareable`/… without declaring them (federation v1 leaves them implicit, v2
|
|
523
|
-
imports them via `@link
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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.
|
|
527
921
|
|
|
528
922
|
Reach for this when the subgraph is what you have, or to type an `_entities`
|
|
529
923
|
query (below). But a subgraph is one service's slice of the graph, and its
|
|
@@ -569,8 +963,14 @@ Key field sets are selection sets, so they're parsed as such:
|
|
|
569
963
|
| `"id"` | `Representations.user(id: "1")` |
|
|
570
964
|
| `"upc sku"` (compound) | `Representations.product(upc: "u", sku: 42)` |
|
|
571
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" }])` |
|
|
572
967
|
| `"id"` **and** `"serial"` (alternatives) | `Representations.variant(id: "1")` *or* `(serial: "s")` |
|
|
573
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
|
+
|
|
574
974
|
A type with one `@key` types its fields as **required kwargs**, so an
|
|
575
975
|
incomplete representation is an `srb tc` error rather than a round trip. What a
|
|
576
976
|
sig can't say is checked at runtime and raises `GraphWeaver::InputError` naming
|
|
@@ -590,6 +990,12 @@ field:
|
|
|
590
990
|
Product representation sku: expected an Int, got "forty-two"
|
|
591
991
|
```
|
|
592
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
|
+
|
|
593
999
|
Only the declared key fields reach the wire — an extra key in a nested hash is
|
|
594
1000
|
dropped. Builders are emitted **only for the entities a query's `_entities`
|
|
595
1001
|
selection reaches** (codegen is query-driven, so a subgraph with fifty entities
|