graph_weaver 0.4.6 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1314 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -154
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -258
- data/lib/graph_weaver/codegen/enum_type.rb +27 -124
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +593 -334
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +9 -1
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +14 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +7 -0
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +794 -59
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +43 -8
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +160 -61
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +181 -21
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +28 -10
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +67 -14
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +389 -170
- metadata +20 -3
data/docs/federation.md
CHANGED
|
@@ -1,35 +1,34 @@
|
|
|
1
1
|
# Federation
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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`
|
|
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
|
-
(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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*.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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).
|