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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +40 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +474 -106
  70. 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 client per subgraph, one `generate!` each.
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
- `GraphWeaver.unmatched_registrations` is that same list as data, for a Rakefile
32
- or a spec that would rather gate on it than read it.
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
- Register everything once and read those lines, or scope each generation to what
35
- it needs and get a silent build:
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
- ```ruby
38
- GraphWeaver.register_scalar("Money", Money)
39
- GraphWeaver.generate!(schema: "billing.graphql",
40
- queries: "app/graphql/billing", output: "app/graphql/generated/billing")
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
- GraphWeaver.reset_registrations!
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 `register_scalar("Person.birthday", Date)` as
52
- a field it doesn't own — a warning, not a failure.
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. (Apollo contracts also pair `@tag` + `@inaccessible`
86
- to build filtered API variants.)
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 the *server* has drifted from your dump,
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 SUPERGRAPH=supergraph.graphql
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** — so it belongs in the normal PR run, and it exits non-zero on
169
- drift so CI can gate on it:
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 — nothing here defines what the supergraph says these declare (running elsewhere, or the type is gone):
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
- Both directions, because they mean opposite things: **stale** is "recompose",
188
- **not composed in** is "publish the subgraph". The stale side names the
189
- subgraph the supergraph blames whose code to look at, whose team to talk to.
190
- "Defines" is deliberately looser than field-set equality, since a subgraph
191
- carries plumbing (`_entities`, `_service`) no supergraph has and a field can
192
- legitimately sit in more than one subgraph (`@external` copies, `@shareable`).
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 type is
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. (Under Rails it won't come up:
201
- the `federation:*` tasks eager-load the app, because `config.rake_eager_load`
202
- defaults to false and detection only sees loaded classes.)
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
- Detection is what drift breaks a schema is recognized by what it defines, and
205
- a subgraph whose *types* are gone stops being recognizable so the same
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" => …, "uncomposed" => …, "skipped" => …,
217
- "faked" => …}`, and `#drift?` is what the task exits on.
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 SUPERGRAPH=supergraph.graphql
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. Root fields that resolve
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.** A subgraph reports `_entities.2.shippingEstimate`; you
422
- get `topProducts.2.shippingEstimate`. `locations` are dropped rather than
423
- pointing into a query you never wrote.
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 subtree one subgraph answers whole sets its own context and plans normally |
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 SUPERGRAPH=supergraph.graphql
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 (default `GraphWeaver.queries_paths`). Planning
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`, including under a namespace as
524
- `@federation__key`), so the missing definitions are supplied on load; anything
525
- the file declares itself wins. The federation directives themselves generate no
526
- code codegen is query-driven.
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