graph_weaver 0.6.1 → 0.7.1

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