graph_weaver 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +40 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +474 -106
  70. metadata +56 -1
data/docs/testing.md CHANGED
@@ -1,20 +1,19 @@
1
1
  # Testing
2
2
 
3
3
  How to run a spec that executes a GraphQL query without a server — against
4
- fabricated data, against your own resolvers, or across a federated graph. Read
5
- this once when you set the suite up; after that the one thing to remember is
6
- the `graphql:` tag.
4
+ fabricated data, against your own resolvers, across a federated graph, or
5
+ through your own transport with any of those behind it.
6
+ Setup is one require and one tag (`:wire` alone adds two test gems); the rest of
7
+ this page is what you reach for when an example is *about* the data, the
8
+ resolvers, or the transport.
7
9
 
8
10
  One line in your spec helper:
9
11
 
10
12
  ```ruby
13
+ # spec/support/graph_weaver.rb — or rails_helper.rb itself
11
14
  require "graph_weaver/rspec"
12
15
  ```
13
16
 
14
- (In Rails, put it **above** the `spec/support` glob in `rails_helper.rb` —
15
- rspec-rails requires those partway through, and a support file mentioning
16
- `GraphWeaver::Testing` before this line dies on `NameError`.)
17
-
18
17
  Then **one tag says what an example runs against** — on the example, or on
19
18
  the group it belongs to, since rspec metadata inherits:
20
19
 
@@ -25,6 +24,7 @@ end
25
24
 
26
25
  it "renders the empty state", graphql: :fake do … end
27
26
  it "authorizes drafts", graphql: :in_process do … end
27
+ it "sends the caller tag", graphql: :wire do … end
28
28
  ```
29
29
 
30
30
  | mode | reach for it when | what it costs |
@@ -32,30 +32,54 @@ it "authorizes drafts", graphql: :in_process do … end
32
32
  | [`:fake`](#fabricated-data--graphql-fake) | most unit tests — you need *a* well-shaped response | no resolver code runs |
33
33
  | [`:in_process`](#real-resolvers--graphql-in_process) | the point of the test is that your resolver logic works | slower; needs a live schema class |
34
34
  | [`:router`](#a-federated-graph--graphql-router) | the same, across a federated graph | needs a composed supergraph; [refuses](federation.md#what-it-refuses) shapes it can't plan faithfully |
35
+ | [`:wire`](#over-the-wire--graphql-wire) | the test is about your own transport — headers, middleware, deserialization | needs webmock and rack, and an http client |
36
+ | `:live` | the app's own client is the point, or this one example wants out of `config.default_mode` | whatever your client does — this is the default |
35
37
  | [cassettes](cassettes.md) | pinning a real server's exact response | must be re-recorded when the query changes |
36
38
 
37
- The tag installs its client as `GraphWeaver.client` for that example, so
38
- generated modules run against it with zero per-test setup. (Generate them
39
- *without* a baked `client:` a module that has one never consults
40
- `GraphWeaver.client`.) `rspec --tag graphql:router` runs one mode's
41
- examples; an untagged example is left alone unless you set
42
- `config.default_mode`, and **`graphql: false` opts one back out** of that
43
- default.
39
+ The tag installs a stand-in per graph, and every generated module of that
40
+ graph runs against it with no per-test setup including one generated *with*
41
+ a baked `client:`, since that constant is exactly what the tag means to
42
+ replace. `rspec --tag graphql:router` runs one mode's examples.
43
+
44
+ **A bad variable never gets as far as a mode.** `execute` coerces the variables
45
+ before it asks any client for anything, so a
46
+ [client-side `InputError`](errors.md#what-an-inputerror-says-without-reading-english)
47
+ raises the same way under every tag and under none. "Which mode do I need for
48
+ this" has no answer there — pick `:fake`, the cheapest.
49
+
50
+ **The other half is the one `:fake` can't reach.** It fabricates a
51
+ shape-correct *success*, so your server's `validates:` rules and custom
52
+ validators never run and nothing is ever rejected — a `:fake`-only suite has
53
+ zero coverage of server-side refusal. Cover it with
54
+ [`Failure.graphql(code:, extensions:)`](#simulating-failures) for the rejection
55
+ you expect, or with `:in_process`, where the real validators do run.
56
+
57
+ **Every example has exactly one mode.** An untagged one takes
58
+ `config.default_mode`, which is `:live` — your own client, exactly as it is —
59
+ unless the suite sets another; and **`graphql: :live` is how one example steps
60
+ back out** of a default the suite did set.
44
61
 
45
62
  `GraphWeaver.client` is **snapshotted before every example and restored
46
- after** — tagged, untagged or opted out, and whatever the example did to
47
- it. So building your own client is a plain assignment, cleaned up like a
63
+ after** — tagged or untagged, whatever its mode, and whatever the example did
64
+ to it. So building your own client is a plain assignment, cleaned up like a
48
65
  tagged one:
49
66
 
50
67
  ```ruby
51
68
  before { GraphWeaver.client = GraphWeaver::Testing::Failure.throttled }
52
69
  ```
53
70
 
54
- Both at once and the assignment wins: the tag installs its client from a
55
- suite-level `before`, which rspec runs ahead of any group hook. So a tagged
56
- example with a `before` of its own runs against the client the `before`
57
- built tag the group for the mode, override the one example that needs
58
- something else.
71
+ Both at once and **the tag wins**: a mode's stand-in outranks
72
+ `GraphWeaver.client=`, so assigning one inside a tagged example does *not*
73
+ change what its generated modules run against the assignment reads back,
74
+ and the modules keep using the mode. Two things do step out of a tag, and
75
+ `graphql: :live` steps the whole example out:
76
+
77
+ ```ruby
78
+ DashboardQuery.execute!(client: GraphWeaver::Testing::Failure.throttled) # this call
79
+ DashboardQuery.client = GraphWeaver::Testing::Failure.throttled # this module
80
+ ```
81
+
82
+ [Client resolution](transports.md#client-resolution) has the full order.
59
83
 
60
84
  Everything here is a *client* — the one interface queries run through (the
61
85
  contract is in [transports](transports.md)). Fakes, the router, failures and
@@ -71,29 +95,50 @@ DashboardQuery = router.parse("query Dashboard { me { username } }")
71
95
  DashboardQuery.execute!.me.username
72
96
  ```
73
97
 
74
- All three modes, tagged and running end to end, are
75
- [`spec/rspec_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/rspec_spec.rb) — the reference for anything
76
- this page leaves out.
98
+ Every mode, tagged and running end to end, is
99
+ [`spec/rspec_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/rspec_spec.rb) (and
100
+ [`spec/wire_mode_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/wire_mode_spec.rb) for `:wire`) — the
101
+ reference for anything this page leaves out.
77
102
 
78
103
  ## Nothing to configure
79
104
 
80
- Each mode works out what to run against, and **refusesnaming what it
81
- looked forrather than guessing**:
82
-
83
- - **the schema** is `config.schema` if you set one, else the committed dump
84
- at `GraphWeaver.schema_path`, else the schema `GraphWeaver.client` talks to.
105
+ Each mode works out what to run against **per graph** — with more than one, the
106
+ honest answer varies per module and **refuses, naming what it looked for,
107
+ rather than guessing**:
108
+
109
+ - **the schema** is `config.schema` if you set one, else the one that
110
+ [graph](getting_started.md#more-than-one-schema) names, else the committed
111
+ dump at `GraphWeaver.schema_path`, else the schema `GraphWeaver.client` talks
112
+ to. A fake reads the scalar registrations of the graph it is answering, so it
113
+ invents the wire value that graph's generated cast expects. (Pins and
114
+ `overrides:` stay suite-wide, keyed by scalar name — one `"Money"` override
115
+ for the run.)
85
116
  - **`:in_process`** needs the live schema *class*, since only that has
86
- resolvers: the one your client already runs in-process, else the loaded
87
- class that defines everything the schema declares — the same
88
- derive-verify-refuse rule that
117
+ resolvers: the one that graph names, else the one your client already runs
118
+ in-process, else the loaded class that defines everything the schema
119
+ declares — the same derive-verify-refuse rule that
89
120
  [maps subgraphs](federation.md#which-schema-serves-which-subgraph).
90
- - **`:router`** plans against the composed supergraph. If your committed dump
91
- *is* one (it carries `@join__*` markers), that's it no config at all. A
92
- client can't stand in for it: a client's schema is the API schema the router
93
- serves, with the `@join__*` routing table stripped out, so the supergraph has
94
- to be named. Subgraphs are derived either way.
121
+ - **`:router`** plans against the composed supergraph **that graph** names,
122
+ else `config.router = { supergraph: }`, else the committed dump when
123
+ *that* carries `@join__*` markers — which for a federated app is usually no
124
+ config at all. A graph that is in no supergraph is refused **by name**,
125
+ rather than planned against another graph's. A client can't stand in for
126
+ one: a client's schema is the API schema the router serves, with the
127
+ `@join__*` routing table stripped out. Subgraphs are derived either way.
128
+
129
+ **The helpers say which graph they mean.** `graphql_fake`,
130
+ `graphql_in_process` and `graphql_router` are the stand-in for the modules of
131
+ the graph their schema names — for your only graph when they name none — and
132
+ they refuse, naming your graphs, when there is none they could reach:
95
133
 
96
- So configure only to override a derivation, or to tune fabricated values:
134
+ ```ruby
135
+ graphql_fake("Product.name" => "Ada's Book", schema: Catalog::Schema)
136
+ graphql_in_process(Accounts::Schema)
137
+ ```
138
+
139
+ So configure only to override a derivation, or to tune fabricated values — in
140
+ the same file as the require, since support files load in sorted order and one
141
+ naming `GraphWeaver::Testing` before it dies on `NameError`:
97
142
 
98
143
  ```ruby
99
144
  GraphWeaver::Testing.configure do |config|
@@ -101,14 +146,81 @@ GraphWeaver::Testing.configure do |config|
101
146
  # config.router = { supergraph: Rails.root.join("supergraph.graphql") }
102
147
  # config.router = { subgraphs: { "reviews" => :fake } } # either key alone
103
148
  # config.context = { tenant: } # baseline context every example starts from
104
- # config.default_mode = :fake # what an UNtagged example runs against
105
- # # (graphql: false opts one back out)
149
+ # config.default_mode = :fake # what an UNtagged example runs against;
150
+ # # :live (the default) leaves your client
151
+ # # alone, and graphql: :live opts one out
106
152
  # config.seed = 4242 # defaults to rspec's own --seed
107
153
  # config.overrides = { "Money" => "12.00", "Person.name" => "Daniel" }
108
154
  # config.list_size = 1..3
109
155
  end
110
156
  ```
111
157
 
158
+ `list_size` is how long an **unbounded** list is — an Integer exactly that
159
+ many, a Range randomized within it, or a Hash saying it per list (below). A
160
+ list with a `first:`/`last:`/`limit:` argument is that long instead, whatever
161
+ this says.
162
+
163
+ **Every list the fabricator reaches reads the same setting, so nested lists
164
+ multiply.** A query selecting `rows { owner { … } tags }` with `tags`
165
+ uncapped fabricates `list_size` rows and `list_size` tags *in each of them* —
166
+ at 1600 that is 2.5M tags, and per-row allocations double with every doubling
167
+ of the number. Three nested lists cube it. Say it per list instead, keyed the
168
+ way a pin is (a `"Type.field"` coordinate or a bare field name), with
169
+ `default:` for the rest:
170
+
171
+ ```ruby
172
+ config.list_size = { "Row.tags" => 3, default: 1000 }
173
+ ```
174
+
175
+ which holds the inner list at 3 however large the outer one grows — or cap it
176
+ in the query (`tags(first: 3)`), where the query is yours to change.
177
+
178
+ **Configure at load, or in an `around` — never in a plain `before`.** The tag
179
+ builds this example's clients in a `before` hook of its own, and rspec runs
180
+ that one ahead of yours, so a `before` setting `config.schema`, `config.router`
181
+ or `config.context` arrives after the decision it meant to change. It is
182
+ **refused**, not ignored — a green example running against the wrong stand-in
183
+ is the expensive outcome. `Testing.configure` in the spec helper is the usual
184
+ place; an `around` wraps the tag's setup when one group needs its own:
185
+
186
+ ```ruby
187
+ around do |example|
188
+ GraphWeaver::Testing.configure { |config| config.schema = Catalog::Schema }
189
+ example.run
190
+ end
191
+ ```
192
+
193
+ For a single example the helper says it where it varies instead —
194
+ `graphql_in_process(MySchema)`, `graphql_fake(schema: MySchema)`,
195
+ `graphql_router(fake: …)`, `graphql_context(current_user: …)`.
196
+
197
+ **With more than one graph, `graph:` says which one a helper stands in for** —
198
+ `graphql_fake(graph: :poke, "pokemon_v2_pokemon.name" => "pikachu")`,
199
+ `graphql_in_process(graph: :catalog)`, `graphql_router(graph: :storefront,
200
+ fake: …)`. A helper is the stand-in for one graph's modules, so an app with
201
+ several is refused, naming them, rather than guessing. A schema class names
202
+ its graph and its schema in one word — `graphql_in_process(Reviews::Schema)`
203
+ — but only for a graph that runs that class in-process; a graph whose schema
204
+ is a dump has no such object, and `graph:` is the handle every graph has.
205
+
206
+ **The rule: a helper sets the stand-in for the graph it names; the tag sets
207
+ the mode for every graph no helper named.** So one example can run two graphs
208
+ in two modes — the federated one through its router, the plain one faked —
209
+ and neither helper disturbs the other's graph:
210
+
211
+ ```ruby
212
+ it "renders the dashboard", graphql: :router do
213
+ graphql_fake(graph: :countries, "Country.name" => "Canada")
214
+ # :storefront routes through its supergraph (the tag); :countries is faked
215
+ end
216
+ ```
217
+
218
+ A helper naming one graph of several isn't contradicting the tag, so it isn't
219
+ refused. A helper that speaks for the whole example still is: with one graph,
220
+ or with no `graph:`/schema to narrow it, `graphql: :fake` plus
221
+ `graphql_in_process` is two answers to one question, and the later one winning
222
+ silently would hide which was the mistake.
223
+
112
224
  Anything whose honest answer differs per example belongs on the fake instead
113
225
  — `graphql_fake(null_chance: 1.0)` for the example that's about an empty
114
226
  state, `graphql_fake(values: :literal)` for the one that reads better without
@@ -164,7 +276,11 @@ graphql_fake("Money" => "12.00", # every Money field, however deep
164
276
  Keys are schema vocabulary, so they survive query refactors — a type name, or
165
277
  `"Type.field"` (a bare `"field"` pins it on every type) — and they are checked
166
278
  and spellchecked: `"Person.nmae"` raises rather than quietly pinning nothing
167
- and leaving the example green against random data.
279
+ and leaving the example green against random data. **Schema vocabulary, not
280
+ Ruby:** a `countries` field generates a `Countries` struct, but the pin is
281
+ `"Country"`, the type name the schema uses — spelled the way *that* schema
282
+ spells it, so a Hasura table type is `"pokemon_v2_pokemon"` and not a
283
+ Ruby-cased guess at it.
168
284
 
169
285
  An **object pin** is anything answering the field names — a FactoryBot build, a
170
286
  model, a `Struct`, an `OpenStruct`. For each selected field the fake calls the
@@ -182,16 +298,43 @@ needs — only `Money.parse` knows what wire value it accepts — so without one
182
298
  fabrication refuses at the path it reached (`at reader.orders.0.total`) and
183
299
  names the pin to add, rather than feeding your cast a placeholder that fails
184
300
  deep inside `from_h`. A scalar registered as `BigDecimal`, `Time`, `Date`,
185
- `Integer`, `Float`, `String` or `T::Boolean` needs nothing. Suite-wide, the same hash is
301
+ `Integer`, `Float`, `String` or `T::Boolean` needs nothing. The key is the
302
+ **schema's scalar name**, not the Ruby class it maps to, so two scalars that
303
+ both deserialize into `Money` want a pin each. Suite-wide, the same hash is
186
304
  `config.overrides`, and the [cassette anonymizer](cassettes.md) reads it too.
187
305
 
306
+ A pin is **what the wire carries** — `"12.00"`, not `Money.parse("12.00")` —
307
+ but the object is accepted wherever the registration can serialize one, which
308
+ is the same rule an object pin's fields already follow. A `serialize:` **Proc**
309
+ builds source rather than converting a value, so a registration spelled that
310
+ way has nothing to run: pin the wire value there, and the fake says so if you
311
+ don't.
312
+
188
313
  Pins lead and options follow — `graphql_fake("Money" => "12.00", values:
189
- :literal)`. Options are lowercase words, so a key with a dot or a leading
190
- capital is a pin wherever it is written; `overrides:` takes the same hash by
191
- keyword, and the leading pins win where both name a key. A fake refuses an
192
- option it doesn't take, lists the ones it does, and guesses at what you meant —
193
- at every door: `FakeClient.new`, `graphql_fake`, `Router.new(fake:)` and
194
- `graphql_router(fake:)`.
314
+ :literal)`. Pins and options are the same keywords, told apart by a lookup: a
315
+ key the fake takes is an option, a key **your schema** knows is a pin, and a
316
+ key that is neither is refused naming both. So a lowercase type pins as
317
+ readily as a capitalized one `graphql_fake("pokemon_v2_pokemon" => …)` for a
318
+ Hasura API. `overrides:` takes the same hash by keyword, and the leading pins
319
+ win where both name a key; written as that leading hash a key is only ever a
320
+ pin, which is the spelling for a schema whose own vocabulary collides with an
321
+ option name. The refusal is the same at every door: `FakeClient.new`,
322
+ `graphql_fake`, `Router.new(fake:)` and `graphql_router(fake:)`.
323
+
324
+ **A pin answers every call the same way**, which is how a paging loop fed by a
325
+ fake runs forever — page two is as full as page one.
326
+ `GraphWeaver::Testing::Sequence` chains clients and repeats the last, so an
327
+ empty pin on the second one is what ends the loop:
328
+
329
+ ```ruby
330
+ page = GraphWeaver::Testing::FakeClient.new(schema:, overrides: { "pokemon_v2_pokemon" => [{ "name" => "pikachu" }] })
331
+ empty = GraphWeaver::Testing::FakeClient.new(schema:, overrides: { "pokemon_v2_pokemon" => [] })
332
+
333
+ GraphWeaver.client = GraphWeaver::Testing::Sequence.new(page, empty)
334
+ ```
335
+
336
+ It is the same chain a retry test uses — [simulating
337
+ failures](#simulating-failures) has that one.
195
338
 
196
339
  ### The example that's *about* the data
197
340
 
@@ -268,10 +411,16 @@ block, and is restored after the example.
268
411
  ### The context your resolvers see
269
412
 
270
413
  `graphql_context` is available in every example. It **merges** onto
271
- `config.context` — the baseline survives unless you override a key — and is
272
- **reset before the next example**, so one example running as somebody else
273
- can't leak into the one after it. (Use it rather than the
274
- `graphql_in_process(context:)` baseline, which is per-suite.)
414
+ `config.context` — the baseline survives unless you override a key — reaches
415
+ every stand-in the example runs through (all your graphs', and the ones
416
+ `:wire` serves behind its endpoints), and is **reset before the next
417
+ example**, so one example running as somebody else can't leak into the one
418
+ after it.
419
+
420
+ It is also the *only* way to set a context from inside an example.
421
+ `config.context` is the suite baseline, read when an example's clients are
422
+ built — before any `before` hook runs — so setting it there is refused rather
423
+ than silently dropped.
275
424
 
276
425
  Context is setup, so it usually belongs in a `before` block — a group of
277
426
  examples sharing one identity says who they are once:
@@ -316,9 +465,9 @@ describe "the dashboard", graphql: :router do
316
465
  end
317
466
  ```
318
467
 
319
- The router is built once for the suite (parsing a supergraph per example
320
- would be real time) and installed as `GraphWeaver.client` for each; its
321
- context is reset from `config.context` every time.
468
+ A router is built once per supergraph (parsing one per example would be real
469
+ time) and stands in for that graph's modules; its context is reset from
470
+ `config.context` every time.
322
471
 
323
472
  `graphql_router` is the tag with options, the way `graphql_fake` is — one
324
473
  option, `fake:`, saying how the subgraphs the router
@@ -329,10 +478,224 @@ options `graphql_fake` takes, in one hash:
329
478
  graphql_router(fake: { "Shipment.carrier" => "UPS", list_size: 2 })
330
479
  ```
331
480
 
481
+ It names no schema, so with more than one graph it refuses: the tag alone
482
+ already routes each module through its own graph's supergraph, and
483
+ `config.router = { fake: … }` says how the faked subgraphs fabricate for the
484
+ suite.
485
+
332
486
  What it plans, what it **refuses** and why, how subgraphs are matched to your
333
487
  schema classes, and what to do about a supergraph only partly local:
334
488
  **[federation → the local router](federation.md#the-local-router)**.
335
489
 
490
+ ### Production redacts what this router hands you
491
+
492
+ A subgraph's error reaches you here in full — its message, and
493
+ `extensions: {"service" => "<subgraph>"}` saying which subgraph produced it. So
494
+ does a dev router configured with `include_subgraph_errors.all: true`. A
495
+ production Apollo Router, with that setting **omitted** — the default — replaces
496
+ the message and empties the extensions:
497
+
498
+ ```
499
+ here, and a dev router
500
+ {"message" => "carrier unavailable for this weight",
501
+ "path" => ["product", "shippingEstimate"], "extensions" => {"service" => "reviews"}}
502
+ a production router
503
+ {"message" => "Subgraph errors redacted", "path" => ["product", "shippingEstimate"]}
504
+ ```
505
+
506
+ `path` survives; nothing else about the subgraph does — including the
507
+ `extensions.code` your own subgraph set, since it is a subgraph like any other.
508
+ So assert on `path` and on what your app does with the failure, not on a
509
+ message, a code, or the `service` stamp.
510
+ An example that needs the redacted shape gets it from
511
+ [`Failure`](#simulating-failures), which reproduces it exactly:
512
+
513
+ ```ruby
514
+ it "degrades when shipping is unavailable" do
515
+ response = ProductQuery.execute(
516
+ client: GraphWeaver::Testing::Failure.graphql(
517
+ "Subgraph errors redacted", path: ["product", "shippingEstimate"],
518
+ ),
519
+ upc: "p1",
520
+ )
521
+ expect(response.errors.first.path).to eq ["product", "shippingEstimate"]
522
+ end
523
+ ```
524
+
525
+ ## Over the wire — `graphql: :wire`
526
+
527
+ Your schema, served at the endpoint your own client posts to — with
528
+ **`GraphWeaver.client` left exactly where it is**. So the request really is
529
+ serialized, posted through your middleware, answered at the far end, and read
530
+ back by `from_h` over the server's own bytes. That is the half the other three
531
+ tags skip: they sit *in* the client slot, so the transport your app ships —
532
+ APM tracing, a caller tag, mTLS — never runs.
533
+
534
+ **It needs [webmock](https://github.com/bblimke/webmock) and
535
+ [rack](https://github.com/rack/rack)** in the Gemfile (`group :test`) — webmock
536
+ hooks Net::HTTP, Faraday and HTTPX underneath, and its `to_rack` builds the Rack
537
+ env with rack.
538
+
539
+ ```ruby
540
+ it "sends the caller tag", graphql: :wire do
541
+ DashboardQuery.execute!
542
+
543
+ expect(WebMock).to have_requested(:post, "https://api.example.com/graphql")
544
+ .with(headers: { "X-Caller" => "web" })
545
+ end
546
+ ```
547
+
548
+ What sits behind each endpoint is **what that graph is** — decided the way the
549
+ other tags already decide it, **per graph**, in descending faithfulness: that
550
+ graph's [router](#a-federated-graph--graphql-router) when it is in a composed
551
+ supergraph, its [live schema class](#real-resolvers--graphql-in_process) when it
552
+ has one, else a [fake](#fabricated-data--graphql-fake) of its schema. So one
553
+ federated graph doesn't put its router behind a plain graph's url, and an app
554
+ that is a pure *client* of someone else's API — a committed dump and no
555
+ resolvers to serve — gets a schema-correct server without writing one. A graph
556
+ with no schema at all is refused, **by `:wire`'s own name**: the one thing the
557
+ other tags can fall back to and this one can't is your client's own schema,
558
+ since reading it means introspecting the endpoint `:wire` has just stubbed.
559
+ Commit a dump, or set `config.schema`.
560
+
561
+ **It says which, on the logger** — the choice is the one thing this tag makes
562
+ for you, and it is invisible from inside the example. One line per endpoint, at
563
+ `info` (a Rails app already has a logger; elsewhere set `GraphWeaver.logger`):
564
+
565
+ ```
566
+ graph_weaver: :wire serving Shop::Schema (in-process) at https://api.example.com/graphql
567
+ ```
568
+
569
+ When a **fake** stands in while the process has a `GraphQL::Schema` class that
570
+ nothing named, that line is a `warn` instead and names the class — the case
571
+ worth catching, because an app that owns real resolvers otherwise goes green
572
+ against fabricated data with nothing said:
573
+
574
+ ```
575
+ graph_weaver: :wire serving a fake at https://api.example.com/graphql — Shop::Schema
576
+ is loaded and nothing named it, so your resolvers did not run. To serve them,
577
+ name it: GraphWeaver::Testing.config.schema = Shop::Schema
578
+ ```
579
+
580
+ A warning rather than a refusal, because a loaded class isn't proof you meant
581
+ it *here* — a federated suite loads every subgraph's — and a fake behind the
582
+ wire is a thing to want. Name the class for the suite, and call `graphql_fake`
583
+ in the examples that want fabricated data.
584
+
585
+ **A helper says what goes behind the wire.** Under the other tags a
586
+ `graphql_*` helper takes the client slot; under `:wire` it is served instead —
587
+ the client slot has to keep your own client for the transport to run at all —
588
+ so pins read exactly as they do under `:fake`:
589
+
590
+ ```ruby
591
+ it "renders two orders, through our own transport", graphql: :wire do
592
+ graphql_fake("Reader.orders" => [{ "status" => "PAID" }, {}])
593
+
594
+ expect(DashboardQuery.execute!.reader.orders.size).to eq 2
595
+ end
596
+ ```
597
+
598
+ It returns the client it serves, so `fake.requests` is what the *endpoint* was
599
+ asked. `graphql_router(fake: …)` and `graphql_in_process(Reviews::Schema)` say
600
+ the same thing for the other two.
601
+
602
+ **Every endpoint an example can reach is served**, one per graph: the client
603
+ each [declared graph](getting_started.md#more-than-one-schema) bakes into its
604
+ modules with `client:`, or `GraphWeaver.client` for a graph that bakes none —
605
+ so an app whose graphs all bake one needs no app default at all. Each gets
606
+ that graph's own schema behind it, so a billing module posts to billing's
607
+ url and is answered by billing's schema. A graph whose baked client posts
608
+ nowhere is refused by name, rather than its requests quietly leaving the
609
+ suite.
610
+
611
+ **Identity comes from the request.** A `context:` **proc** is called per
612
+ request with the headers as sent, which is the seam nothing above the wire can
613
+ test:
614
+
615
+ ```ruby
616
+ GraphWeaver::Testing.configure do |config|
617
+ config.context = ->(headers) { { current_user: User.find_by(token: headers["Authorization"]) } }
618
+ end
619
+ ```
620
+
621
+ A hash still works, and is still the baseline `graphql_context` merges onto; a
622
+ proc replaces it, and `graphql_context` then says so rather than merging onto
623
+ something that isn't there. (Rack drops a header's capitalization, so `X-CALLER`
624
+ arrives as `X-Caller`.)
625
+
626
+ Like every suite setting, it goes in `Testing.configure` or an `around` —
627
+ [never a plain `before`](#nothing-to-configure), `:wire` least of all, since the
628
+ tag stubs this example's endpoints in a `before` hook of its own.
629
+
630
+ **And webmock has to be *enabled*** — `require "webmock/rspec"` in the spec
631
+ helper, in either order with `graph_weaver/rspec`. Having it in the Gemfile is
632
+ not enough: `Bundler.require` loads webmock without installing its adapters, so
633
+ `:wire` checks and refuses *before* the first request rather than letting it
634
+ leave the suite. That is what makes this a *transport* test rather than a mock
635
+ of one — every transport [documented here](transports.md) runs unchanged,
636
+ pooling and all. The tag adds one stub per endpoint and takes each back after
637
+ the example — it never disables net connections on your behalf, and never
638
+ resets stubs it didn't make.
639
+
640
+ **The wire adds a hop, not a capability.** Behind a router, everything
641
+ [it refuses](federation.md#what-it-refuses) is still refused, before any
642
+ resolver runs. Behind a fake, what you are testing is your *transport* — the
643
+ request your middleware wrote, the headers it sent, the retry it does on a 500,
644
+ and that `from_h` reads real JSON off a socket rather than a Ruby hash you
645
+ handed it. What it can't tell you is whether your `cast:` agrees with the real
646
+ server: the fabricated bytes are written to match your own
647
+ [scalar registrations](scalars.md), so the round trip agrees with itself. Put
648
+ the live schema class behind the wire for that, or pin a real response with a
649
+ [cassette](cassettes.md).
650
+
651
+ `GraphWeaver::Testing::Endpoint` is an ordinary Rack app wrapping anything that
652
+ satisfies the [client contract](transports.md) — so mount it yourself if you'd
653
+ rather have a real socket:
654
+
655
+ ```ruby
656
+ run GraphWeaver::Testing::Endpoint.new(router) # config.ru, or a Puma in a thread
657
+ ```
658
+
659
+ ### Making the served endpoint fail
660
+
661
+ The tag adds **one stub per endpoint**, and webmock answers with the *last*
662
+ stub declared for a url — so an example that wants the server to misbehave
663
+ declares its own, and it wins for that example:
664
+
665
+ ```ruby
666
+ it "surfaces a 503, after the retries it's allowed", graphql: :wire do
667
+ stub_request(:post, "https://api.example.com/graphql")
668
+ .to_return(status: 503, headers: { "Retry-After" => "0" }, body: "down for maintenance")
669
+
670
+ expect { PlaceOrderMutation.execute!(input:) }.to raise_error(GraphWeaver::ServerError) { |e|
671
+ expect(e.status).to eq 503
672
+ expect(e.retry_after).to eq 0 # the header your backoff read
673
+ }
674
+ end
675
+
676
+ it "surfaces a timeout", graphql: :wire do
677
+ stub_request(:post, "https://api.example.com/graphql").to_timeout
678
+
679
+ expect { PlaceOrderMutation.execute!(input:) }.to raise_error(GraphWeaver::TransportError)
680
+ end
681
+ ```
682
+
683
+ `to_timeout` raises what a timeout raises, instantly — it tests what your app
684
+ *does* with one. It does not test that a `read_timeout:` of yours is short
685
+ enough, and neither does sleeping inside `to_return { |req| … }`: webmock stands
686
+ in for the socket, so there is nothing to time out and the call simply takes
687
+ that long and succeeds. A timeout *value* can only be proven against a
688
+ genuinely slow server — the [`Endpoint`](#over-the-wire--graphql-wire) above on
689
+ a real port, or a `TCPServer` that dawdles before it replies.
690
+
691
+ That is a **served** failure: your transport reads the status and the headers
692
+ off a real response, and your `retries:` budget really spends itself against it
693
+ — including [the one attempt a mutation gets](transports.md#retries). That is
694
+ the half a [`Failure` client](#simulating-failures) can't reach, since those sit
695
+ *in* the client slot and raise above the wire. Use `Failure.server` /
696
+ `Failure.timeout` when the example is about your `rescue`; a stub when it is
697
+ about the transport.
698
+
336
699
  ## Simulating failures
337
700
 
338
701
  Every failure mode is just a client, so error-handling paths are testable
@@ -342,10 +705,11 @@ without a server that misbehaves on cue:
342
705
  Failure = GraphWeaver::Testing::Failure
343
706
 
344
707
  PersonQuery.execute(client: Failure.transport, id: "1") # raises TransportError
708
+ PersonQuery.execute(client: Failure.timeout, id: "1") # raises TransportError, cause Net::ReadTimeout
345
709
  PersonQuery.execute(client: Failure.server(status: 502), id: "1") # raises ServerError
346
710
  PersonQuery.execute(client: Failure.throttled, id: "1") # errors.first.code => "THROTTLED"
347
711
  PersonQuery.execute(client: Failure.stale_schema, id: "1") # schema_stale? => true
348
- PersonQuery.execute(client: Failure.graphql("boom"), id: "1") # partial failure
712
+ PersonQuery.execute(client: Failure.graphql("boom"), id: "1") # errors, and no data
349
713
 
350
714
  # a throttling server, with the header a backoff reads
351
715
  PersonQuery.execute(client: Failure.server(status: 429, headers: { "retry-after" => "2" }), id: "1")
@@ -356,14 +720,49 @@ fake = GraphWeaver::Testing::FakeClient.new(schema:)
356
720
  GraphWeaver::Testing::Sequence.new(Failure.transport, Failure.transport, fake)
357
721
 
358
722
  # type mismatch: corrupt: derives a wrong-typed wire value for the field —
359
- # casting raises GraphWeaver::TypeError (overrides remain the manual escape hatch)
723
+ # casting raises GraphWeaver::CastError (overrides remain the manual escape hatch)
360
724
  GraphWeaver::Testing::FakeClient.new(schema:, corrupt: "Person.birthday")
361
725
 
362
726
  # field-level partial failure with real GraphQL null propagation: the error
363
- # lands with its concrete path and nulls bubble to the nearest nullable spot
727
+ # lands with its concrete path and nulls bubble to the nearest nullable spot.
728
+ # The path is response keys joined by dots, and a list index is a segment of
729
+ # its own — state only the indices you mean, the rest match any position
364
730
  GraphWeaver::Testing::FakeClient.new(schema:, fail_at: { path: "person.email", code: "PRIVATE" })
731
+ GraphWeaver::Testing::FakeClient.new(schema:, fail_at: "people.2.pets.name")
732
+ ```
733
+
734
+ `Failure.graphql` is the **whole response** failing — `data` is null unless you
735
+ pass `data:`, which is what makes it a partial one. Shape the error by naming
736
+ its wire fields beside the message (`code:`, `extensions:`, `path:`,
737
+ `locations:` — anything else is refused rather than swallowed), so a rejection
738
+ that follows the [`extensions.input`
739
+ convention](errors.md#what-your-server-can-send) is one call:
740
+
741
+ ```ruby
742
+ # a plain code, the coarse bucket every server states
743
+ Failure.graphql("that input was bad", code: "BAD_USER_INPUT")
744
+
745
+ # the convention: response.input_errors reads this back as one InputError,
746
+ # kind :out_of_range, details { min: 1 }, on path ["input", "min"]
747
+ Failure.graphql(
748
+ "min must be at least 1",
749
+ code: "BAD_USER_INPUT",
750
+ extensions: { "input" => { "kind" => "out_of_range", "path" => ["input", "min"],
751
+ "coordinate" => "RangeInput.min", "value" => 0, "min" => 1 } },
752
+ )
753
+
754
+ # several errors, and partial data: each carries its own hash
755
+ Failure.graphql({ message: "boom", path: ["person"] }, "and again", data: { "person" => nil })
365
756
  ```
366
757
 
758
+ **A server's own rejection lands in `#errors` unless it marked it.** A
759
+ graphql-ruby `validates:` failure carries no `extensions` at all, so it is an
760
+ ordinary error in `response.errors` — `#input_errors` is empty, because
761
+ "the value was out of range" and "the database is down" are the same bytes.
762
+ Assert on `errors` for that, and reach for `#input_errors` only once your
763
+ server [says the error is about the input](errors.md#when-the-server-rejects-the-input);
764
+ the `Failure.graphql(code:, extensions:)` call above is the shape that says it.
765
+
367
766
  ## Capture and replay
368
767
 
369
768
  Cassettes record real API responses and replay them offline, above the