graph_weaver 0.4.4 → 0.5.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1357 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -136
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -251
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -98
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +617 -264
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +12 -6
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +21 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +15 -1
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +820 -57
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +59 -7
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +186 -62
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +194 -28
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +31 -6
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +74 -18
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +398 -170
  62. metadata +20 -3
data/docs/federation.md CHANGED
@@ -1,35 +1,34 @@
1
1
  # Federation
2
2
 
3
- GraphWeaver generates a client for the **whole** graph, so with Apollo
4
- Federation you point it at the *composed* schema the supergraph or the API
5
- schema not at individual subgraph files.
6
-
7
- ## The schema you feed it
8
-
9
- Three artifacts, easy to mix up:
10
-
11
- | Artifact | What it is | Feed to weaver? |
12
- |----------|------------|-----------------|
13
- | **Subgraph SDL** | one service's `.graphql`, with `@key`/`@shareable`/`extend schema @link` | No — it's a fragment of the graph, and its federation directives are declared elsewhere (imported via `@link`), so plain SDL loading rejects them |
14
- | **Supergraph** | the composed graph, annotated with `@join__*`/`@link` machinery | Yes — self-contained (see below) |
15
- | **API schema** | the supergraph with federation internals stripped — exactly what the router serves and what an introspection query returns | Yes — the exact client contract |
16
-
17
- `SchemaLoader.load` (and `Client.new(path_or_sdl)`) take the supergraph or the
18
- API schema as an SDL file or introspection dump; or point weaver at the router
19
- URL to introspect the API schema live.
3
+ Feed weaver any federation artifact: a supergraph, an API schema, a subgraph
4
+ SDL, or the live router. It recognizes which it got `SchemaLoader.load` (and
5
+ `Client.new(path_or_sdl)`) take each as an SDL file or an introspection dump.
20
6
 
21
7
  ## Pointing weaver at a supergraph
22
8
 
23
- A supergraph SDL works as-is. When `SchemaLoader` sees the `@join__*` markers it
9
+ A supergraph SDL works as-is. When `SchemaLoader` recognizes a composed graph it
24
10
  strips the composition machinery before building the schema — the synthetic
25
11
  `join__*`/`link__*` types and directive definitions, and every `@join__*`/`@link`
26
12
  application on the real types — so what codegen sees is the merged graph's
27
13
  ordinary type shapes, with no federation plumbing leaking into `schema.types`.
28
- (It's a pure AST rewrite of the SDL; no graphql-ruby monkeypatch, and plain
29
- schemas pass through untouched.) Field shapes — nullability, args, enums,
30
- inputs — are identical to the API schema, so your generated structs are correct;
31
- and because codegen is **query-driven**, nothing federation-internal could
32
- generate code anyway.
14
+ (A pure AST rewrite of the SDL; plain schemas pass through untouched.) Field
15
+ shapes — nullability, args, enums, inputs — are identical to the API schema, so
16
+ your generated structs are correct.
17
+
18
+ **Which names count as machinery is read off the schema**, not a fixed list.
19
+ Federation namespaces itself through [`@link`](https://specs.apollo.dev/link/v1.0/)
20
+ (v2) or [`@core`](https://specs.apollo.dev/core/v0.2/) (v1), and weaver applies
21
+ those declarations as written: a spec URL's name segment gives the namespace
22
+ (`https://specs.apollo.dev/join/v0.3` → `join__`), `as:` renames it, and
23
+ `import:` binds names into the root namespace, `{name: "@key", as: "@myKey"}`
24
+ renames included. So a graph on fed 2.5+ auth strips its `@requiresScopes` /
25
+ `@policy` / `@context` machinery (`federation__Scope`, `context__ContextFieldValue`,
26
+ …) the same way `join__` goes, and a renamed `@inaccessible` still hides what it
27
+ marks. A schema that declares nothing still gets the `join__`/`link__`/`core__`
28
+ floor.
29
+
30
+ Federation **v1** supergraphs (`@core` + `@join__owner`/`@join__type`) load the
31
+ same way — the older spelling of the same machinery is stripped too.
33
32
 
34
33
  A supergraph is a **superset** of the API schema — it carries elements the
35
34
  public API hides, marked `@inaccessible`. Weaver removes those on load (below),
@@ -38,41 +37,514 @@ so the schema it generates against is the API schema, not the superset.
38
37
  ### `@inaccessible`
39
38
 
40
39
  A federation-v2 directive marking an element as *present in the federated graph
41
- but removed from the public API schema*. Its common use is safely rolling out a
42
- change to a **shared type**: add the field to one subgraph marked
43
- `@inaccessible` (so composition doesn't require every subgraph to have it yet),
44
- roll it out to the rest, then drop the directive to publish it. (Apollo
45
- contracts also pair `@tag` + `@inaccessible` to build filtered API variants.)
46
- It's a fed-v2 feature — common in mature, multi-team graphs with lots of shared
47
- types, rare in small or young ones, and targeted where present (a handful of
48
- elements, not every field).
40
+ but removed from the public API schema*. You'll meet it rolling out a change to
41
+ a **shared type**: add the field to one subgraph marked `@inaccessible` (so
42
+ composition doesn't require every subgraph to have it yet), roll it out to the
43
+ rest, then drop the directive to publish it. (Apollo contracts also pair `@tag`
44
+ + `@inaccessible` to build filtered API variants.)
49
45
 
50
46
  Weaver derives the API schema from the supergraph for you: loading strips every
51
47
  `@inaccessible` element and cascades — a field/argument/union-member/interface
52
- referencing a removed type goes too, and a type left empty is removed in turn
53
- so codegen validates against **exactly** what clients can query. There's no
54
- over-permit gap, and no need for Apollo's JS tooling (`@apollo/federation-internals`)
55
- to subtract the API schema first; feed weaver the raw supergraph and you get the
56
- router's contract. (This is a pure SDL rewrite at load time — see
48
+ referencing a removed type goes too, and a type left empty is removed in turn.
49
+ So codegen validates against what clients can query, with no need for Apollo's
50
+ JS tooling (`@apollo/federation-internals`) to subtract the API schema first;
51
+ feed weaver the raw supergraph and you get the router's contract. (A pure SDL
52
+ rewrite at load time — see
57
53
  [`SchemaLoader`](../lib/graph_weaver/schema_loader.rb).)
58
54
 
55
+ The directive is matched by the **local name it was linked under**, so
56
+ `@link(url: "…/federation/v2.5", import: [{name: "@inaccessible", as: "@private"}])`
57
+ subtracts what `@private` marks.
58
+
59
+ **Only on the supergraph path.** The subtraction runs when weaver recognizes a
60
+ composed supergraph. Plain SDL and subgraph SDL are taken at face value:
61
+ `@inaccessible` there is left as a directive and its fields stay queryable.
62
+
59
63
  Other federation directives hide nothing from the schema, so weaver keeps the
60
64
  field and ignores the directive: `@requiresScopes` / `@policy` / `@authenticated`
61
65
  enforce access at runtime; `@tag` / `@requires` / `@provides` / `@external` are
62
66
  metadata.
63
67
 
64
- ## Which schema to feed
68
+ The derivation is diffed against Apollo's own `composeServices` +
69
+ `toAPISchema()` in
70
+ [`spec/integration/api_schema_spec.rb`](../spec/integration/api_schema_spec.rb),
71
+ over composed supergraphs carrying `@interfaceObject`, `@join__unionMember`,
72
+ `@join__enumValue` and an aliased `@inaccessible` — identical in each.
73
+
74
+ ### The routing table
75
+
76
+ Stripping the machinery answers "what does this graph look like". The other
77
+ question a supergraph answers is "who resolves what", and
78
+ `SchemaLoader.routing_table` keeps that side rather than discarding it:
79
+
80
+ ```ruby
81
+ table = GraphWeaver::SchemaLoader.routing_table("supergraph.graphql")
82
+
83
+ table.subgraphs # => ["accounts", "products", "reviews"]
84
+ table.owners("Product", "shippingEstimate") # => ["reviews"]
85
+ table.owners("User", "username") # => ["accounts"] — the @external copy isn't an owner
86
+ table.keys("User", "accounts") # => [["id"]]
87
+ table.field("Product", "shippingEstimate").requires # => "price weight"
88
+ table.possible_types("Purchasable", "products") # => ["Bundle", "Product"]
89
+ ```
90
+
91
+ Subgraphs are named the way `@join__graph(name:)` names them — the strings a
92
+ router config and `rover` use, not the SDL's uppercase enum spelling. A `@key`
93
+ field set comes back as dotted paths (`"id organization { id }"` → `["id",
94
+ "organization.id"]`), so a nested one is recognizable by its shape. A field
95
+ with no `@join__field` at all lives wherever its type does; that omission is
96
+ how the composer says "everywhere". `possible_types` answers the abstract
97
+ side — the concrete types one subgraph can answer a union or interface with,
98
+ from `@join__unionMember`/`@join__implements` — and `nil` where the supergraph
99
+ doesn't say, which is a different fact from "none".
100
+
101
+ A `@join__` directive the table hasn't been taught lands in `#unsupported`
102
+ rather than being skipped — a table that silently ignores half a spec version
103
+ answers confidently and wrongly. Callers refuse on a non-empty list; that is
104
+ what bounds the maintenance tail across federation spec versions.
105
+ `#interface_objects` is the one construct kept out of that list
106
+ (`{"Media" => ["catalog"]}`), because it's a fact about one *type* rather than
107
+ about the table: the router refuses the queries that reach it and plans the
108
+ rest.
109
+
110
+ The table is what [`Testing::Router`](#the-local-router)
111
+ plans against, and it's a reasonable read on its own — "which subgraph owns
112
+ this field" is the sentence a good error message wants.
113
+
114
+ Weaver says it where it has the coordinate to say it about. When the schema
115
+ dump is a composed supergraph, `rake graph_weaver:queries:check` brands each
116
+ validation error with the subgraphs behind the type it names:
117
+
118
+ ```
119
+ app/graphql/queries/product.graphql
120
+ 4:5 Field 'dimensions' doesn't exist on type 'Product' (products, reviews)
121
+ ```
122
+
123
+ `Product.dimensions` says what broke; `(products, reviews)` says whose code to
124
+ look at. `check_queries` carries the same list as a `"subgraphs"` key. A plain
125
+ schema has no routing table, so nothing changes for it.
126
+
127
+ ### Has the supergraph been recomposed?
128
+
129
+ A committed supergraph is a snapshot of a composition. Change a subgraph and
130
+ skip the recompose and it quietly describes a graph that no longer exists —
131
+ the failure that bites a federated app mid-migration, and the one the other
132
+ checks don't ask about. `graph_weaver:verify` asks whether the generated Ruby
133
+ is fresh, `schema:diff` whether the *server* has drifted from your dump,
134
+ `queries:check` whether drift broke a query. This asks whether the supergraph
135
+ still describes your subgraphs:
136
+
137
+ ```sh
138
+ rake graph_weaver:federation:diff SUPERGRAPH=supergraph.graphql
139
+ ```
140
+
141
+ It reads the routing table and the subgraph schemas loaded in this process —
142
+ **no network** — so it belongs in the normal PR run, and it exits non-zero on
143
+ drift so CI can gate on it:
144
+
145
+ ```
146
+ supergraph.graphql: 1 stale, 1 not composed in (checked 1 of 3 subgraphs)
147
+
148
+ stale — the supergraph carries these, no schema here defines them (recompose):
149
+ Product.weight (products)
150
+
151
+ not composed in — a schema here defines these, the supergraph doesn't carry them:
152
+ Product.dimensions (Products::Schema)
153
+
154
+ not checked — nothing here defines what the supergraph says these declare (running elsewhere, or the type is gone):
155
+ shipping (Shipment)
156
+
157
+ not checked — answered with fabricated data:
158
+ reviews
159
+ ```
160
+
161
+ Both directions, because they mean opposite things: **stale** is "recompose",
162
+ **not composed in** is "publish the subgraph". The stale side names the
163
+ subgraph the supergraph blames, which is the sentence you want — whose code to
164
+ look at, whose team to talk to.
165
+
166
+ "Defines" is deliberately looser than field-set equality: a subgraph carries
167
+ federation plumbing (`_entities`, `_service`) no supergraph has, and a field can
168
+ legitimately sit in more than one subgraph (`@external` copies, `@shareable`).
169
+ So a coordinate is compared only against the schemas that could *be* the
170
+ subgraph the supergraph attributes it to, the uncomposed side reports only a
171
+ field the supergraph's type doesn't carry **at all**, and underscore-prefixed
172
+ fields never count.
173
+
174
+ **A supergraph is routinely only partly local**, so the report names three
175
+ states rather than two: checked, not here (running elsewhere — or the type is
176
+ gone), and [faked](#the-local-router). A clean report that quietly checked one
177
+ subgraph of three would be actively misleading, so the headline counts them and
178
+ the sections name them. Only drift fails the task; absence is a supported
179
+ setup, not a failure.
180
+
181
+ Checking **none** of them is a failure, though — "checked 0 of 4" attached to
182
+ exit 0 is a gate that passes whatever the subgraphs say, so the task exits
183
+ non-zero and says so. Under Rails it won't come up: the `federation:*` tasks
184
+ eager-load the app, because `config.rake_eager_load` defaults to false and
185
+ detection only sees loaded classes.
186
+
187
+ Detection is what drift breaks — a schema is recognized by what it defines,
188
+ and a subgraph whose *types* are gone stops being recognizable — so the same
189
+ `subgraphs:` map [`Testing::Router`](#the-local-router)
190
+ takes is accepted here, and a named schema skips detection:
191
+
192
+ ```ruby
193
+ GraphWeaver::Federation::Drift.new(
194
+ supergraph: "supergraph.graphql",
195
+ subgraphs: { "products" => Products::Schema, "inventory" => :fake },
196
+ ).report
197
+ ```
198
+
199
+ `#to_h` is the JSON-ready `{"stale" => …, "uncomposed" => …, "skipped" => …,
200
+ "faked" => …}`, and `#drift?` is what the task exits on.
201
+
202
+ ## The local router
203
+
204
+ Specs for a federated app have a bad choice: fake the whole graph, or boot a
205
+ gateway. `Testing::Router` is the third one. It takes the composed supergraph
206
+ and the Ruby schema classes serving its subgraphs, plans the query, and
207
+ satisfies the [client contract](transports.md) — so a generated module runs
208
+ against your **real resolvers**, in-process, with no gateway, no node and no
209
+ sockets. It is not a mock: your resolvers run, which is the whole point.
210
+
211
+ ```ruby
212
+ GraphWeaver.client = GraphWeaver::Testing::Router.new(
213
+ supergraph: Rails.root.join("supergraph.graphql"),
214
+ context: { current_user: user },
215
+ )
216
+ ```
217
+
218
+ In rspec that's the [`graphql: :router`](testing.md#a-federated-graph--graphql-router)
219
+ tag and there is nothing to pass — the tag builds it, once for the suite.
220
+ `router.trace` records the fetches made since the last `reset_trace`, in order
221
+ (subgraph, query, variables); the same lines go to `GraphWeaver.logger` at
222
+ `:debug`. It **accumulates across executes**, because the question worth asking
223
+ is which subgraphs a code path touched and a service object rarely runs one
224
+ query. The rspec tag resets it before each example; outside rspec call
225
+ `router.reset_trace` around the code path you're measuring.
226
+
227
+ **[`examples/federation.rb`](../examples/federation.rb)** is the whole shape
228
+ in one runnable file, and the only example that needs no network: three real
229
+ subgraphs, a boundary-crossing query through a generated module, the trace,
230
+ and a refusal. [`spec/router_spec.rb`](../spec/router_spec.rb) is the
231
+ exhaustive reference — every plan shape, every refusal, the partly-local
232
+ graph and the `:fake` opt-in, each as a named example.
233
+
234
+ ### Which schema serves which subgraph
235
+
236
+ `subgraphs:` is optional. Left out, each one is **derived from what the loaded
237
+ schemas define**: a schema serves subgraph `s` when it defines every type and
238
+ field the routing table says `s` resolves. That's evidence rather than a guess,
239
+ and a wrong guess would point a suite at the wrong resolvers and still pass — so
240
+ exactly one match is used, and **two** matches refuse, naming both. **No** match
241
+ isn't a refusal: that subgraph is simply served somewhere else (next section).
242
+
243
+ Name them yourself when you'd rather have the wiring committed, or when
244
+ detection can't settle it — including partially, with the rest derived:
245
+
246
+ ```ruby
247
+ subgraphs: { "accounts" => Accounts::Schema } # products, reviews derived
248
+ ```
249
+
250
+ Either way the map is **checked** at construction, so a swapped pair fails
251
+ naming what's missing rather than surfacing as a mystery three fetches later:
252
+
253
+ ```
254
+ subgraphs["accounts"] is Products::Schema, which doesn't define Query.me,
255
+ Query.user, Query.users, User, User.email and 1 more — the supergraph says
256
+ accounts resolves them. Did two entries get swapped?
257
+ ```
258
+
259
+ Detection only sees what's **loaded**, and in Rails an autoloaded schema isn't
260
+ until something references it — which is why an unmatched subgraph reads as
261
+ absent. The `federation:*` rake tasks eager-load the app for you; a spec suite
262
+ is your own `config.eager_load`, which Rails leaves off outside CI. To see what
263
+ detection sees, and get a map to paste:
264
+
265
+ ```
266
+ $ rake graph_weaver:federation:subgraphs SUPERGRAPH=supergraph.graphql
267
+ subgraphs: {
268
+ "accounts" => Accounts::Schema, # matched: defines Query.me, Query.user, Query.users
269
+ "products" => Products::Schema, # matched: defines Product.name, Product.price, Product.weight
270
+ "reviews" => Reviews::Schema, # matched: defines Product.reviews, Product.shippingEstimate, Query.feed
271
+ }
272
+ ```
273
+
274
+ A row nothing matched comes back `nil`, naming the coordinates it looked for —
275
+ that's the map to fill in, or the subgraph that lives elsewhere.
276
+
277
+ ### A supergraph only partly local
278
+
279
+ The usual migration shape: the supergraph is composed from several services and
280
+ only **some** of them run in your process. The rest are routed over the network,
281
+ so there is no Ruby schema here to serve them — and requiring one would refuse
282
+ the whole suite over fields most of your queries never touch.
283
+
284
+ So a subgraph nothing here defines is **absent**, and the router builds and runs
285
+ anyway. Absence costs you exactly the queries that reach into it:
286
+
287
+ ```ruby
288
+ router.absent # => ["shipping"]
289
+ router.execute("{ me { username reviews { body } } }") # real data, as always
290
+ router.execute("{ shipments { carrier } }") # GraphWeaver::Testing::Unplannable
291
+ ```
292
+
293
+ That refusal is a plan-time one like every other, so nothing has executed when
294
+ it raises, and it names the subgraph, the field that reached for it, and both
295
+ ways out — name a schema for it, or fake it:
296
+
297
+ ```ruby
298
+ subgraphs: { "shipping" => :fake } # any other absent subgraph still refuses
299
+ ```
300
+
301
+ If the subgraph *is* here and detection just couldn't see it — a Rails schema
302
+ class nothing has referenced yet — loading it is the fix, and in a spec suite
303
+ that means `config.eager_load = true` (naming it in
304
+ `Testing.config.router = { subgraphs: … }` works too). `=> :fake` is the
305
+ other one: mid-migration, letting an absent subgraph answer with
306
+ schema-correct fabricated data exercises the rest of the query. It speaks the
307
+ whole subgraph contract, `_entities(representations:)` included, so it works
308
+ under a stitched fetch as well as at a root field.
309
+
310
+ Refusing stays the default, and the opt-in is **per subgraph** on purpose:
311
+ silently substituting invented data is the failure mode this library keeps
312
+ designing against. For the same reason faking is **loud** — every faked fetch
313
+ is marked `faked: true` in `router.trace` and logged at `:warn`.
314
+ `router.faked` lists them, and `router.inspect` shows what's served, faked and
315
+ absent. Values come from the same engine as
316
+ [`graphql: :fake`](testing.md#fabricated-data--graphql-fake), so `config.seed`,
317
+ `config.overrides` and the rest apply.
318
+
319
+ ### What it plans
320
+
321
+ An operation that resolves in **one subgraph** goes over verbatim. One that
322
+ **crosses a boundary** is split at the crossing: the plan injects the entity's
323
+ `@key` under a reserved alias, refetches it from the owning subgraph through
324
+ `_entities(representations:)`, and stitches the answer back. Every node at one
325
+ level goes in **one** `_entities` call, so a list of users and all their
326
+ reviews' products is three fetches, not one per row. Root fields that resolve
327
+ in different subgraphs get one fetch each. A `@provides` copy is read in place,
328
+ so nothing leaves the subgraph for a field the copy already holds.
329
+
330
+ A **`@requires` field set** is supplied by the router rather than by the
331
+ subgraph that declares the field, so it's a fetch before the fetch:
332
+
333
+ ```ruby
334
+ router.reset_trace
335
+ router.execute("{ reviews { product { shippingEstimate } } }")
336
+ router.trace.map { _1[:subgraph] } # => ["reviews", "products", "reviews"]
337
+ ```
338
+
339
+ `shippingEstimate` resolves in `reviews` and `@requires "price weight"`, which
340
+ `products` owns — so the plan fetches those into hidden keys, hands them back
341
+ in the representation, and only then asks for the estimate. One hop only: the
342
+ key for the first fetch has to come from the subgraph already in hand, so a
343
+ chain can't grow a chain.
344
+
345
+ A **nested field set** — `@key(fields: "id organization { id }")`,
346
+ `@requires(fields: "origin { lat lon }")` — is a selection set like any other,
347
+ so it crosses as one: the fetch asks for `organization { id }` under a
348
+ reserved alias, and the representation carries the object back in the shape
349
+ the SDL spells it, to any depth, nulls and all. Where a type has more than one
350
+ `@key`, the plan takes the first one the fetching subgraph can supply.
351
+
352
+ A **union or interface at a boundary** — a feed, a search page, any
353
+ polymorphic list — is planned per concrete type, because a representation names
354
+ one concrete `__typename` and which one an object has isn't in the query:
355
+
356
+ ```ruby
357
+ router.reset_trace
358
+ router.execute("{ purchasables { name ... on Product { reviews { body } } } }")
359
+ router.trace.map { _1[:subgraph] } # => ["products", "reviews"]
360
+ ```
361
+
362
+ The plan holds a branch per type the supergraph says that subgraph can answer
363
+ with; the fetch asks for `__typename` under a reserved alias, and the objects
364
+ that come back are bucketed by it — one `_entities` fetch per concrete type,
365
+ none for a bucket nothing lands in. A fragment whose condition can't hold there
366
+ (`... on Note` where that subgraph's union has no Note) never matches, so it is
367
+ dropped, which is the answer a real router gives too.
368
+
369
+ Three things it does that a naive merge doesn't, and that being wrong about
370
+ would be worse than refusing:
371
+
372
+ - **Null propagation over the merged tree.** A stitched fetch can put a null
373
+ where the composed schema says non-null, and no subgraph is in a position to
374
+ notice. The router re-applies GraphQL's propagation rules to the merged
375
+ result, so a subtree the real router would have nulled comes back null here.
376
+ - **Error re-pathing.** A subgraph reports `_entities.2.shippingEstimate`; you
377
+ get `topProducts.2.shippingEstimate`. `locations` are dropped rather than
378
+ pointing into a query you never wrote.
379
+ - **`@skip`/`@include` on a stitched field.** A skipped field comes back
380
+ *absent*, not null.
381
+
382
+ Introspection is answered from the composed API schema, never from a subgraph,
383
+ which would reply with its own slice — the one split a real router also makes.
384
+
385
+ ### What it refuses
386
+
387
+ Everything it can't plan **faithfully** raises
388
+ `GraphWeaver::Testing::Unplannable` (a `GraphWeaver::Error`), at plan time,
389
+ before any subgraph runs — so a refusal is never a half-executed query. A double
390
+ that approximated the rest of Apollo's planner would let a test pass on an
391
+ answer production disagrees with, which is the most expensive thing this library
392
+ can produce. Each refusal names the coordinate that stopped it and what to do —
393
+ `examples/federation.rb` prints one.
394
+
395
+ What it refuses, and why:
396
+
397
+ Every category, spelled as `Unplannable#category` reports it:
398
+
399
+ | Refusal | Why |
400
+ |---|---|
401
+ | no `@key` to cross the boundary on | an entity fetch sends a representation built from a `@key`; with none there is nothing to send |
402
+ | 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 |
403
+ | 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 |
404
+ | 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 |
405
+ | 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 |
406
+ | `@skip`/`@include` on both a fragment and its field | one selection can't carry two conditions of the same name. Spell the condition once |
407
+ | an alias shadowing an injected `@key` | Apollo's router lets its injected key win over your alias and a spec-conformant server doesn't — there is no one answer to agree with |
408
+ | a mutation's root fields span subgraphs | root mutation fields run in series, and splitting them across subgraphs would run them in whatever order the plan happened to. Sharing one subgraph they're fine, stitching below them and all — that's an ordinary read afterwards. Query roots are independent, so those are always fine |
409
+ | the routing table names no subgraph | nothing can route a field the supergraph doesn't place |
410
+ | a subgraph nothing here serves | it's served by another process, so there is nothing here to ask — unless you fake it (above) |
411
+ | 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 |
412
+ | the document isn't one operation | pass `operation_name:` naming one of them |
413
+ | not a query or a mutation | the router plans against the composed schema's query and mutation roots; a subscription has neither |
414
+ | a fragment the document never defines | define it, or point the query at the file that does |
415
+ | 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 |
416
+ | nested deeper than the router walks | past the walk's depth limit, which validation would have rejected first |
417
+
418
+ A subgraph two loaded schemas both fit refuses at construction too, as a
419
+ `ConfigurationError` rather than an `Unplannable` (above) — it's a wiring
420
+ mistake, not a query the router declines.
421
+
422
+ ### Is it worth wiring up? Measure.
423
+
424
+ The router's value is one number — the fraction of *your* queries it can plan —
425
+ and that depends on the shape of your graph and of your queries, so measure it
426
+ rather than guess:
427
+
428
+ ```
429
+ $ rake graph_weaver:federation:coverage SUPERGRAPH=supergraph.graphql
430
+ 17/17 queries plannable locally (100%), 17 servable here
431
+ accounts 4, reviews 4, products+reviews 3, accounts+reviews 2, products 2, accounts+products 1, accounts+products+reviews 1
432
+ ```
433
+
434
+ **Two numbers, because they answer different questions.** *Plannable* is about
435
+ the graph — could the router split this query faithfully at all. *Servable
436
+ here* is what your suite actually gets: every subgraph that plan reaches is one
437
+ this process serves. In a [partly-local supergraph](#a-supergraph-only-partly-local)
438
+ they differ, and the plannable number alone reads optimistically:
439
+
440
+ ```
441
+ 5/5 queries plannable locally (100%), 2 servable here
442
+ accounts 1, billing 1, reviews 1, reviews+shipping 1, shipping 1
443
+
444
+ plannable, but nothing here serves what they reach (3) — name a schema for those subgraphs, fake them (subgraphs: { "shipping" => :fake }), or run these against a real router:
445
+ invoices.graphql billing
446
+ shipping_quotes.graphql shipping
447
+ tracking.graphql shipping
448
+ ```
449
+
450
+ `QUERIES=` picks the directory (default `GraphWeaver.queries_paths`). Planning
451
+ needs the supergraph and nothing else, so this still runs in CI with the SDL
452
+ alone — no subgraph has to be loadable, and with none loaded the report drops
453
+ the second number and says it counted planning only. The subgraph line says
454
+ which subgraphs each query touches, so a graph whose queries all sit in one is
455
+ visibly a different situation from one that stitches everywhere. Anything
456
+ refused is listed after it, grouped by category, so one glance says whether the
457
+ gap is one construct or many. The runs above are against the demo graph in
458
+ `spec/support/federation`, not a real app's mix.
459
+
460
+ ### How the refusals are kept honest
461
+
462
+ A double that quietly answered *differently* from the router would be worse
463
+ than no double at all, so
464
+ [`spec/integration/router_parity_spec.rb`](../spec/integration/router_parity_spec.rb)
465
+ serves the demo subgraphs over HTTP, boots a real `@apollo/gateway` on the same
466
+ supergraph, and runs the whole corpus through both. Three outcomes, one of them
467
+ a defect: match, refuse, or answer differently — and the spec fails on the
468
+ third. It also checks that the gateway answers every refusal cleanly, so each
469
+ refusal is a capability gap rather than a broken query. `make integration` runs
470
+ it (node required).
471
+
472
+ Alongside the corpus it runs boundary probes and a handful of queries where a
473
+ subgraph deliberately **fails** — a resolver erroring under a stitched fetch, an
474
+ entity nothing can resolve, a `@requires` fetch that comes back empty. Those are
475
+ the ones that matter most: each is a case where a merge that doesn't
476
+ re-propagate hands back a populated tree while the real router answers
477
+ `data: null`, so it's checked against the real thing rather than against an
478
+ expectation someone wrote down.
479
+
480
+ ## Pointing weaver at a subgraph
481
+
482
+ A raw subgraph SDL — `rover subgraph fetch`, `_service { sdl }`, or the
483
+ `.graphql` in a service repo — loads too. It applies `@key`/`@external`/
484
+ `@shareable`/… without declaring them (federation v1 leaves them implicit, v2
485
+ imports them via `@link`, including under a namespace as
486
+ `@federation__key`), so weaver supplies the missing definitions on load;
487
+ anything the file declares itself wins. The federation directives themselves
488
+ generate no code — codegen is query-driven.
489
+
490
+ Reach for this when the subgraph is what you have, or to type an `_entities`
491
+ query (below). But a subgraph is one service's slice of the graph, and its
492
+ field shapes are not always the composed ones (an `@external` field is a
493
+ reference, not something that subgraph serves) — for a client of the whole
494
+ graph, feed the composed artifact.
495
+
496
+ ### `_entities`
497
+
498
+ Every subgraph serves the entity resolver
499
+ `_entities(representations: [_Any!]!): [_Entity]!`, and **no subgraph SDL
500
+ contains it**: `_service { sdl }` and `rover subgraph fetch` print the
501
+ *published* schema, where the plumbing is implicit. So weaver supplies it on the
502
+ subgraph path — `_Any`, `_Service`, and an `_Entity` union over the file's own
503
+ `@key`'d types — the same way it supplies the `@key`/`@external` definitions. A
504
+ file that declares its own keeps it.
505
+
506
+ The read side is a normal union selection; `alias:` turns the
507
+ single-entity case into a clean accessor (see
508
+ [flat accessors](generated_modules.md#flat-accessors-with-alias)):
509
+
510
+ ```ruby
511
+ GraphWeaver.extend_type("Query", alias: { entity: "_entities.first" }, optional: true)
512
+ ```
513
+
514
+ The **input** side is generated. A representation must carry `__typename` and
515
+ satisfy one of the entity's `@key` field sets — both hard requirements of the
516
+ subgraph spec, and neither expressible in a bare `[_Any!]!`. So a query
517
+ selecting entities gets a `Representations` builder per entity it can resolve,
518
+ typed from the `@key` directives:
519
+
520
+ ```ruby
521
+ UserQuery::Representations.user(id: "1")
522
+ # => {"__typename" => "User", "id" => "1"}
523
+
524
+ UserQuery.execute(reps: [UserQuery::Representations.user(id: "1")])
525
+ ```
526
+
527
+ Key field sets are selection sets, so they're parsed as such:
65
528
 
66
- Any of these they all produce the same generated code:
529
+ | `@key(fields:)` | Builder |
530
+ |---|---|
531
+ | `"id"` | `Representations.user(id: "1")` |
532
+ | `"upc sku"` (compound) | `Representations.product(upc: "u", sku: 42)` |
533
+ | `"id organization { id }"` (nested) | `Representations.listing(id: "1", organization: { id: "o" })` |
534
+ | `"id"` **and** `"serial"` (alternatives) | `Representations.variant(id: "1")` *or* `(serial: "s")` |
67
535
 
68
- - **The supergraph SDL** weaver strips the `@join__*`/`@link` machinery and
69
- derives the API schema (removing `@inaccessible`). The common case.
70
- - **The API schema SDL** already subtracted (e.g. emitted in your CI); loads
71
- as an ordinary schema.
72
- - **The live router** introspect it; it already serves the API schema.
536
+ A type with one `@key` types its fields as **required kwargs**, so an
537
+ incomplete representation is an `srb tc` error rather than a round trip. What a
538
+ sig can't say is checked at runtime and raises `GraphWeaver::InputError` naming
539
+ the type and the field: which of two alternative keys you meant to supply, and
540
+ whether a nested sub-hash carries the fields the key set declares. Only the
541
+ declared key fields reach the wire — an extra key in a nested hash is dropped.
73
542
 
74
- A large real supergraph carries more constructs than a toy one (interface
75
- objects via `@join__type(isInterfaceObject:)`, `@join__unionMember`, enum join
76
- directives). The stripping holds across them, but the honest check is to run
77
- codegen against your actual composed schema plus a couple of representative
78
- queries before relying on it.
543
+ Two bounds worth knowing. Builders are emitted **only for the entities a
544
+ query's `_entities` selection reaches** codegen is query-driven, so a
545
+ subgraph with fifty entities emits nothing for the forty-nine you didn't name.
546
+ And a `@key(..., resolvable: false)` declares a key this subgraph does *not*
547
+ answer for, so it builds nothing. Key fields typed as scalars get their
548
+ registered Ruby type; anything else (a nested selection) is an open `Hash` the
549
+ runtime narrows. Every shape above is a named example in
550
+ [`spec/federation_spec.rb`](../spec/federation_spec.rb).