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/testing.md CHANGED
@@ -1,22 +1,18 @@
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.
7
6
 
8
7
  One line in your spec helper:
9
8
 
10
9
  ```ruby
10
+ # spec/support/graph_weaver.rb — or rails_helper.rb itself
11
11
  require "graph_weaver/rspec"
12
12
  ```
13
13
 
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
- Then **one tag says what an example runs against** — on the example, or on
19
- the group it belongs to, since rspec metadata inherits:
14
+ Then **one tag says what an example runs against** — on the example, or on the
15
+ group it belongs to, since rspec metadata inherits:
20
16
 
21
17
  ```ruby
22
18
  describe "checkout", graphql: :router do
@@ -25,6 +21,7 @@ end
25
21
 
26
22
  it "renders the empty state", graphql: :fake do … end
27
23
  it "authorizes drafts", graphql: :in_process do … end
24
+ it "sends the caller tag", graphql: :wire do … end
28
25
  ```
29
26
 
30
27
  | mode | reach for it when | what it costs |
@@ -32,122 +29,84 @@ it "authorizes drafts", graphql: :in_process do … end
32
29
  | [`:fake`](#fabricated-data--graphql-fake) | most unit tests — you need *a* well-shaped response | no resolver code runs |
33
30
  | [`: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
31
  | [`: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 |
32
+ | [`:wire`](#over-the-wire--graphql-wire) | the test is about your own transport — headers, middleware, deserialization | needs webmock and rack, and an http client |
33
+ | `: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
34
  | [cassettes](cassettes.md) | pinning a real server's exact response | must be re-recorded when the query changes |
36
35
 
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.
44
-
45
- `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
48
- tagged one:
49
-
50
- ```ruby
51
- before { GraphWeaver.client = GraphWeaver::Testing::Failure.throttled }
52
- ```
53
-
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.
36
+ There is nothing else to set up: each mode works out what to run against per
37
+ graph, and [refuses rather than guessing](#nothing-to-configure). The tag
38
+ installs a stand-in per graph, and every generated module of that graph runs
39
+ against it including one generated *with* a baked `client:`, since that
40
+ constant is exactly what the tag means to replace. `rspec --tag graphql:router`
41
+ runs one mode's examples.
42
+
43
+ **Every example has exactly one mode.** An untagged one takes
44
+ `config.default_mode`, which is `:live` your own client, exactly as it is —
45
+ unless the suite sets another; and **`graphql: :live` is how one example steps
46
+ back out** of a default the suite did set.
47
+
48
+ `GraphWeaver.client` is **snapshotted before every example and restored after**,
49
+ whatever the example did to it, so building your own client is a plain
50
+ assignment: `before { GraphWeaver.client = GraphWeaver::Testing::Failure.throttled }`.
51
+ Do both at once and **the tag wins**: the assignment reads back while the modules
52
+ keep using the mode. Two things do step out of a tag — a per-call `client:`, and
53
+ `DashboardQuery.client =` on the module itself
54
+ ([client resolution](transports.md#client-resolution) has the full order).
55
+
56
+ **The half `:fake` can't reach** is refusal. It fabricates a shape-correct
57
+ *success*, so your server's `validates:` rules and custom validators never run —
58
+ a `:fake`-only suite has zero coverage of server-side rejection. Cover it with
59
+ [`Failure.graphql(code:, extensions:)`](#simulating-failures) for the rejection
60
+ you expect, or with `:in_process`, where the real validators do run. (A bad
61
+ *variable* never gets as far as a mode: `execute` coerces before it asks any
62
+ client for anything, so a
63
+ [client-side `InputError`](errors.md#what-an-inputerror-says-without-reading-english)
64
+ raises the same way under every tag and under none.)
59
65
 
60
66
  Everything here is a *client* — the one interface queries run through (the
61
- contract is in [transports](transports.md)). Fakes, the router, failures and
62
- cassettes all slot in wherever a real transport would, so they work outside
63
- rspec too (`require "graph_weaver/testing"` never from production code).
64
- Outside the tags there's no `GraphWeaver.client` to lean on, so parse from
65
- the fake or the router itself — anything holding a schema parses against it,
66
- and the module runs on what parsed it:
67
+ contract is in [transports](transports.md)) — so fakes, the router, failures and
68
+ cassettes work outside rspec too (`require "graph_weaver/testing"`, never from
69
+ production code). There's no `GraphWeaver.client` to lean on there, so parse from
70
+ the fake or the router itself: anything holding a schema parses, and the module
71
+ runs on what parsed it.
67
72
 
68
73
  ```ruby
69
74
  router = GraphWeaver::Testing::Router.new(supergraph: "app/graphql/supergraph.graphql")
70
75
  DashboardQuery = router.parse("query Dashboard { me { username } }")
71
- DashboardQuery.execute!.me.username
72
76
  ```
73
77
 
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.
77
-
78
- ## Nothing to configure
79
-
80
- Each mode works out what to run against, and **refuses — naming what it
81
- looked for — rather 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.
85
- - **`: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
89
- [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.
95
-
96
- So configure only to override a derivation, or to tune fabricated values:
97
-
98
- ```ruby
99
- GraphWeaver::Testing.configure do |config|
100
- # config.schema = MySchema # the live class, rather than the dump
101
- # config.router = { supergraph: Rails.root.join("supergraph.graphql") }
102
- # config.router = { subgraphs: { "reviews" => :fake } } # either key alone
103
- # 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)
106
- # config.seed = 4242 # defaults to rspec's own --seed
107
- # config.overrides = { "Money" => "12.00", "Person.name" => "Daniel" }
108
- # config.list_size = 1..3
109
- end
110
- ```
111
-
112
- Anything whose honest answer differs per example belongs on the fake instead
113
- — `graphql_fake(null_chance: 1.0)` for the example that's about an empty
114
- state, `graphql_fake(values: :literal)` for the one that reads better without
115
- faker's prose. A suite-wide `null_chance` would sprinkle nils through every
116
- *other* example, one run in ten, on a seed the failure doesn't name.
117
-
118
- Need the schema itself inside an example — to sample a field, or build a
119
- query on the fly? The client in play exposes it as
120
- `GraphWeaver.client.schema`, and `GraphWeaver::Testing.config.schema` reads
121
- back what `config.schema =` set, falling back to the committed dump.
78
+ Every mode, tagged and running end to end, is
79
+ [`spec/rspec_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/rspec_spec.rb) (and
80
+ [`spec/wire_mode_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/wire_mode_spec.rb) for `:wire`) — the
81
+ reference for anything this page leaves out.
122
82
 
123
83
  ## Fabricated data — `graphql: :fake`
124
84
 
125
- `FakeClient` fabricates schema-correct responses for whatever query
126
- arrives: real enum values, valid `__typename` members, and — for a custom
127
- scalar — a value the Ruby type you registered it as can hold, so every fake
128
- casts cleanly through your generated structs. `@skip`/`@include` are
129
- evaluated against the variables you passed (defaults included), so a field
130
- the server would leave out is left out. `rspec --seed 1234` reproduces the
131
- values along with test order.
85
+ `FakeClient` fabricates schema-correct responses for whatever query arrives:
86
+ real enum values, valid `__typename` members, and — for a custom scalar — a
87
+ value the Ruby type you registered it as can hold, so every fake casts cleanly
88
+ through your generated structs. `@skip`/`@include` are evaluated against the
89
+ variables you passed (defaults included), so a field the server would leave out
90
+ is left out. `rspec --seed 1234` reproduces the values along with test order.
132
91
 
133
92
  ```ruby
134
- fake = GraphWeaver::Testing::FakeClient.new # schema: falls back to Testing.config
93
+ it "shows the profile", graphql: :fake do
94
+ person = PersonQuery.execute!(id: "1").person
135
95
 
136
- person = PersonQuery.execute!(client: fake, id: "1").person
137
- person.name # a plausible name, when faker is loaded
138
- person.birthday # a real Date
96
+ person.name # a plausible name, when faker is loaded
97
+ person.birthday # a real Date
98
+ end
139
99
  ```
140
100
 
141
- Values are semantic when the [faker](https://github.com/faker-ruby/faker) gem
142
- is loaded — `name` gets a name, `email` an email — and plain and type-derived
143
- (`"name-1"`, seeded numbers) when it isn't. Say `values: :literal` on a fake
144
- that reads better without the prose, or `values: :faker` to insist on the
145
- semantic ones and get told if the gem is missing:
101
+ Values are semantic when the [faker](https://github.com/faker-ruby/faker) gem is
102
+ loaded — `name` gets a name, `email` an email — and plain and type-derived
103
+ (`"name-1"`, seeded numbers) when it isn't. Say `values: :literal` on a fake that
104
+ reads better without the prose, or `values: :faker` to insist on the semantic
105
+ ones and get told if the gem is missing.
146
106
 
147
- ```ruby
148
- graphql_fake(values: :literal)
149
- GraphWeaver::Testing::FakeClient.new(values: :literal)
150
- ```
107
+ Outside a tagged example, build one yourself:
108
+ `GraphWeaver::Testing::FakeClient.new` (its `schema:` falls back to
109
+ `Testing.config`), then pass it as `client:`.
151
110
 
152
111
  ### Pins
153
112
 
@@ -163,59 +122,64 @@ graphql_fake("Money" => "12.00", # every Money field, however deep
163
122
 
164
123
  Keys are schema vocabulary, so they survive query refactors — a type name, or
165
124
  `"Type.field"` (a bare `"field"` pins it on every type) — and they are checked
166
- and spellchecked: `"Person.nmae"` raises rather than quietly pinning nothing
167
- and leaving the example green against random data.
125
+ and spellchecked: `"Person.nmae"` raises rather than quietly pinning nothing and
126
+ leaving the example green against random data. **Schema vocabulary, not Ruby:** a
127
+ `countries` field generates a `Countries` struct, but the pin is `"Country"`,
128
+ spelled the way *that* schema spells it, so a Hasura table type is
129
+ `"pokemon_v2_pokemon"` and not a Ruby-cased guess at it.
130
+
131
+ A pin **merges**, and pins a **subtree** as readily as a leaf: name the fields
132
+ the example is about and everything else in the selection is still fabricated. A
133
+ pinned list is exactly as long as you write it — `{}` means "another one, all
134
+ fabricated".
135
+
136
+ ```ruby
137
+ graphql_fake("Reader.name" => "Ada",
138
+ "Reader.orders" => [{ "status" => "PAID" }, {}])
139
+ ```
140
+
141
+ Inside a subtree the keys are *response* keys, as they come back on the wire
142
+ (`priceCents`, or an alias you selected); one the query doesn't select is refused
143
+ and spellchecked, same as a typo'd coordinate. At a union or interface, name the
144
+ member with `"__typename"`.
168
145
 
169
146
  An **object pin** is anything answering the field names — a FactoryBot build, a
170
147
  model, a `Struct`, an `OpenStruct`. For each selected field the fake calls the
171
- snake_cased reader, puts a Ruby value on the wire the way its
148
+ snake_cased reader, puts the value on the wire the way its
172
149
  [scalar registration](scalars.md) serializes it (a `Time` as its iso8601 string,
173
150
  a `T::Enum` as its value), recurses into nested objects and arrays of them, and
174
151
  **fabricates any field the object doesn't answer**. Readers are field names, not
175
152
  aliases; `__typename` comes from the key, so at a union or interface pin the
176
- concrete type (`"Person"`, never `"Named"`). One thing rspec's seed can't reach:
177
- a FactoryBot sequence advances on its own counter, so an object built from one
178
- isn't reproduced by `--seed`.
153
+ concrete type (`"Person"`, never `"Named"`). A FactoryBot sequence advances on
154
+ its own counter, which is the one thing `--seed` can't reproduce.
179
155
 
180
156
  A **scalar type pin** is the one thing a scalar registered as *your own class*
181
157
  needs — only `Money.parse` knows what wire value it accepts — so without one
182
- fabrication refuses at the path it reached (`at reader.orders.0.total`) and
183
- names the pin to add, rather than feeding your cast a placeholder that fails
184
- 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
186
- `config.overrides`, and the [cassette anonymizer](cassettes.md) reads it too.
158
+ fabrication refuses at the path it reached (`at reader.orders.0.total`) and names
159
+ the pin to add, rather than feeding your cast a placeholder that fails deep
160
+ inside `from_h`. A scalar registered as `BigDecimal`, `Time`, `Date`, `Integer`,
161
+ `Float`, `String` or `T::Boolean` needs nothing. The key is the **schema's scalar
162
+ name**, not the Ruby class it maps to, so two scalars that both deserialize into
163
+ `Money` want a pin each. What you pin there is **what the wire carries** —
164
+ `"12.00"`, not `Money.parse("12.00")` — though the object is accepted wherever
165
+ the registration can serialize one. (A `serialize:` **Proc** builds source rather
166
+ than converting a value, so a registration spelled that way has nothing to run:
167
+ pin the wire value, and the fake says so if you don't.) Suite-wide, the same hash
168
+ is `config.overrides`, and the [cassette anonymizer](cassettes.md) reads it too.
187
169
 
188
170
  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
171
+ :literal)`. They are the same keywords, told apart by a lookup: a key the fake
172
+ takes is an option, a key **your schema** knows is a pin, and a key that is
173
+ neither is refused naming both. `overrides:` takes the same hash by keyword, and
174
+ the leading pins win where both name a key write it as that leading hash when a
175
+ schema's own vocabulary collides with an option name. The refusal is the same at
176
+ every door: `FakeClient.new`, `graphql_fake`, `Router.new(fake:)` and
194
177
  `graphql_router(fake:)`.
195
178
 
196
- ### The example that's *about* the data
197
-
198
- Fabricated data answers "does this render", not "does it render Ada's two
199
- orders". `graphql_fake` is the tag with options same client, built where
200
- the example can say what it needs:
201
-
202
- ```ruby
203
- it "shows the two paid orders", graphql: :fake do
204
- graphql_fake("Reader.name" => "Ada",
205
- "Reader.orders" => [{ "status" => "PAID" }, {}])
206
-
207
- expect(DashboardQuery.execute!.reader.orders.size).to eq 2
208
- end
209
- ```
210
-
211
- An override pins a **subtree** as readily as a leaf, and **merges**: name
212
- the fields the example is about and everything else in the selection is
213
- still fabricated. A pinned list is exactly as long as you write it — `{}`
214
- means "another one, all fabricated". Inside a subtree the keys are
215
- *response* keys, as they come back on the wire (`priceCents`, or an alias
216
- you selected); one the query doesn't select is refused and spellchecked,
217
- same as a typo'd coordinate. At a union or interface, name the member with
218
- `"__typename"`.
179
+ **A pin answers every call the same way**, which is how a paging loop fed by a
180
+ fake runs forever — page two is as full as page one.
181
+ `GraphWeaver::Testing::Sequence.new(page, empty)` chains clients and repeats the
182
+ last, so a fake whose pin is `[]` on the second one is what ends the loop.
219
183
 
220
184
  `graphql_fake` returns the client, which records what it was asked:
221
185
 
@@ -226,17 +190,16 @@ expect(fake.requests.size).to eq 1 # memoized
226
190
  expect(fake.requests.first[:variables]).to eq({ "id" => "1" })
227
191
  ```
228
192
 
229
- It works in a `before` block, an example body, or a shared context — and
230
- with no tag at all, since it installs the client itself. The tag is
231
- `graphql_fake` with no options.
232
-
233
- One thing to know: **two identical queries fabricate different data**, so
234
- assert a memoization with `requests.size`, not by comparing two responses.
193
+ It works in a `before` block, an example body, or a shared context — and with no
194
+ tag at all, since it installs the client itself. The tag is `graphql_fake` with
195
+ no options. One thing to know: **two identical queries fabricate different
196
+ data**, so assert a memoization with `requests.size`, not by comparing two
197
+ responses.
235
198
 
236
199
  ## Real resolvers — `graphql: :in_process`
237
200
 
238
- Your actual resolvers, your actual `context`, in the same process — no
239
- socket, no serialization, and a resolver's real backtrace when it raises.
201
+ Your actual resolvers, your actual `context`, in the same process — no socket,
202
+ no serialization, and a resolver's real backtrace when it raises.
240
203
 
241
204
  ```ruby
242
205
  it "hides other people's drafts", graphql: :in_process do
@@ -245,67 +208,51 @@ it "hides other people's drafts", graphql: :in_process do
245
208
  end
246
209
  ```
247
210
 
248
- The live schema *class* is found for you (a schema dump has no resolvers,
249
- so it won't do). If two loaded classes match, or none does, it says so and
250
- asks for `config.schema = MySchema` — and in Rails, remember that an
251
- autoloaded schema isn't loaded until something references it.
211
+ The live schema *class* is found for you (a schema dump has no resolvers, so it
212
+ won't do). If two loaded classes match, or none does, it says so and asks for
213
+ `config.schema = MySchema` — and in Rails, remember that an autoloaded schema
214
+ isn't loaded until something references it.
252
215
 
253
216
  A federated app has no one live class, so the example says which subgraph it
254
- means. Testing one subgraph's resolvers directly is a different question from
255
- `graphql: :router`, which plans across the whole graph and stitches; both are
256
- worth asking, and a suite asks them of different subgraphs:
257
-
258
- ```ruby
259
- it "rejects a review from a blocked reader" do
260
- graphql_in_process(Reviews::Schema)
261
-
262
- end
263
- ```
264
-
265
- Like `graphql_fake`, `graphql_in_process` needs no tag, works in a `before`
266
- block, and is restored after the example.
217
+ means: `graphql_in_process(Reviews::Schema)`. Testing one subgraph's resolvers
218
+ directly is a different question from `graphql: :router`, which plans across the
219
+ whole graph and stitches; both are worth asking, and a suite asks them of
220
+ different subgraphs. Like `graphql_fake`, `graphql_in_process` needs no tag,
221
+ works in a `before` block, and is restored after the example.
267
222
 
268
223
  ### The context your resolvers see
269
224
 
270
- `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.)
225
+ `graphql_context` is available in every example, and is the *only* way to set a
226
+ context from inside one — `config.context` is the suite baseline, read when an
227
+ example's clients are built, before any `before` hook runs, so setting it there
228
+ is refused rather than silently dropped. It **merges** onto that baseline,
229
+ reaches every stand-in the example runs through (all your graphs', and the ones
230
+ `:wire` serves behind its endpoints), and is **reset before the next example**,
231
+ so one example running as somebody else can't leak into the one after it.
275
232
 
276
- Context is setup, so it usually belongs in a `before` block — a group of
277
- examples sharing one identity says who they are once:
233
+ Context is setup, so it usually belongs in a `before` block — a group of examples
234
+ sharing one identity says who they are once:
278
235
 
279
236
  ```ruby
280
237
  describe "as the owner", graphql: :in_process do
281
238
  before { graphql_context(current_user: alice) }
282
-
283
- it "shows the drafts" do
284
- expect(DraftsQuery.execute!.drafts.size).to eq 2
285
- end
286
239
  end
287
240
  ```
288
241
 
289
- The reset runs ahead of any group hook, so each example re-applies that
290
- `before` from the same baseline rather than stacking onto the last one's
291
- context. Set it inline for the one-off, or pass a block to scope it for the
292
- example that needs two identities:
293
-
294
- ```ruby
295
- graphql_context(admin: true) { expect(SettingsQuery.execute!.settings).to be_present }
296
- ```
297
-
298
- Called with nothing it reads the context back. Under `graphql: :fake` it
299
- refuses: there are no resolvers to receive a context, and silently ignoring
300
- one would leave an example asserting on data nothing scoped. Pin the data
301
- itself instead — `graphql_fake("Person.name" => "Ada")`, above.
242
+ The reset runs ahead of any group hook, so each example re-applies that `before`
243
+ from the same baseline rather than stacking onto the last one's context. Pass a
244
+ block to scope it for the example that needs two identities
245
+ `graphql_context(admin: true) { }` — and call it with nothing to read the
246
+ context back. Under `graphql: :fake` it refuses: there are no resolvers to
247
+ receive a context, and silently ignoring one would leave an example asserting on
248
+ data nothing scoped. Pin the data itself instead.
302
249
 
303
250
  ## A federated graph — `graphql: :router`
304
251
 
305
252
  Same thing across a federated graph: the tag builds a
306
- [`Testing::Router`](federation.md#the-local-router), which plans the query
307
- across your subgraphs and runs it against those **real resolvers** — no
308
- gateway, no node, no sockets.
253
+ [`Testing::Router`](federation.md#the-local-router), which plans the query across
254
+ your subgraphs and runs it against those **real resolvers** — no gateway, no
255
+ node, no sockets.
309
256
 
310
257
  ```ruby
311
258
  describe "the dashboard", graphql: :router do
@@ -316,36 +263,207 @@ describe "the dashboard", graphql: :router do
316
263
  end
317
264
  ```
318
265
 
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.
266
+ A router is built once per supergraph (parsing one per example would be real
267
+ time) and stands in for that graph's modules; its context is reset from
268
+ `config.context` every time.
322
269
 
323
- `graphql_router` is the tag with options, the way `graphql_fake` is — one
324
- option, `fake:`, saying how the subgraphs the router
270
+ `graphql_router` is the tag with options, the way `graphql_fake` is — one option,
271
+ `fake:`, saying how the subgraphs the router
325
272
  [fakes](federation.md#a-supergraph-only-partly-local) fabricate: the pins and
326
- options `graphql_fake` takes, in one hash:
273
+ options `graphql_fake` takes, in one hash.
327
274
 
328
275
  ```ruby
329
276
  graphql_router(fake: { "Shipment.carrier" => "UPS", list_size: 2 })
330
277
  ```
331
278
 
279
+ It names no schema, so with more than one graph it refuses: the tag alone already
280
+ routes each module through its own graph's supergraph, and `config.router = {
281
+ fake: … }` says how the faked subgraphs fabricate for the suite.
282
+
332
283
  What it plans, what it **refuses** and why, how subgraphs are matched to your
333
284
  schema classes, and what to do about a supergraph only partly local:
334
285
  **[federation → the local router](federation.md#the-local-router)**.
335
286
 
287
+ ### Production redacts what this router hands you
288
+
289
+ A subgraph's error reaches you here in full — its message, and
290
+ `extensions: {"service" => "<subgraph>"}` saying which subgraph produced it. So
291
+ does a dev router configured with `include_subgraph_errors.all: true`. A
292
+ production Apollo Router, with that setting **omitted** — the default — replaces
293
+ the message and empties the extensions:
294
+
295
+ ```
296
+ here, and a dev router
297
+ {"message" => "carrier unavailable for this weight",
298
+ "path" => ["product", "shippingEstimate"], "extensions" => {"service" => "reviews"}}
299
+ a production router
300
+ {"message" => "Subgraph errors redacted", "path" => ["product", "shippingEstimate"]}
301
+ ```
302
+
303
+ `path` survives; nothing else about the subgraph does — including the
304
+ `extensions.code` your own subgraph set, since it is a subgraph like any other.
305
+ So assert on `path` and on what your app does with the failure, not on a message,
306
+ a code, or the `service` stamp. An example that needs the redacted shape gets it
307
+ from [`Failure`](#simulating-failures), which reproduces it exactly:
308
+
309
+ ```ruby
310
+ GraphWeaver::Testing::Failure.graphql(
311
+ "Subgraph errors redacted", path: ["product", "shippingEstimate"],
312
+ )
313
+ ```
314
+
315
+ ## Over the wire — `graphql: :wire`
316
+
317
+ Your schema, served at the endpoint your own client posts to — with
318
+ **`GraphWeaver.client` left exactly where it is**. So the request really is
319
+ serialized, posted through your middleware, answered at the far end, and read
320
+ back by `from_h` over the server's own bytes. That is the half the other three
321
+ tags skip: they sit *in* the client slot, so the transport your app ships — APM
322
+ tracing, a caller tag, mTLS — never runs.
323
+
324
+ ```ruby
325
+ it "sends the caller tag", graphql: :wire do
326
+ DashboardQuery.execute!
327
+
328
+ expect(WebMock).to have_requested(:post, "https://api.example.com/graphql")
329
+ .with(headers: { "X-Caller" => "web" })
330
+ end
331
+ ```
332
+
333
+ **It needs [webmock](https://github.com/bblimke/webmock) and
334
+ [rack](https://github.com/rack/rack)** in the Gemfile (`group :test`), and
335
+ webmock has to be *enabled* — `require "webmock/rspec"` in the spec helper, in
336
+ either order with `graph_weaver/rspec`. Having it in the Gemfile is not enough:
337
+ `Bundler.require` loads webmock without installing its adapters, so `:wire`
338
+ checks and refuses *before* the first request rather than letting it leave the
339
+ suite. The tag adds one stub per endpoint and takes each back after the example —
340
+ it never disables net connections on your behalf, and never resets stubs it
341
+ didn't make.
342
+
343
+ **Every endpoint an example can reach is served**, one per graph: the client each
344
+ [declared graph](getting_started.md#more-than-one-schema) bakes into its modules
345
+ with `client:`, or `GraphWeaver.client` for a graph that bakes none — so a
346
+ billing module posts to billing's url and is answered by billing's schema. What
347
+ sits behind each is **what that graph is**, in descending faithfulness: that
348
+ graph's [router](#a-federated-graph--graphql-router) when it is in a composed
349
+ supergraph, its [live schema class](#real-resolvers--graphql-in_process) when it
350
+ has one, else a [fake](#fabricated-data--graphql-fake) of its schema. So an app
351
+ that is a pure *client* of someone else's API gets a schema-correct server
352
+ without writing one. A graph with no schema at all is refused, **by `:wire`'s own
353
+ name** — the one fallback the other tags have and this one can't use is your
354
+ client's own schema, since reading it means introspecting the endpoint `:wire`
355
+ has just stubbed. Commit a dump, or set `config.schema`. A graph whose baked
356
+ client posts nowhere is refused by name too.
357
+
358
+ **It says which, on the logger** — the choice is the one thing this tag makes for
359
+ you, and it is invisible from inside the example. One line per endpoint, at
360
+ `info` (a Rails app already has a logger; elsewhere set `GraphWeaver.logger`):
361
+
362
+ ```
363
+ graph_weaver: :wire serving Shop::Schema (in-process) at https://api.example.com/graphql
364
+ ```
365
+
366
+ When a **fake** stands in while the process has a `GraphQL::Schema` class that
367
+ nothing named, that line is a `warn` instead and names the class, telling you to
368
+ set `GraphWeaver::Testing.config.schema` — the case worth catching, because an
369
+ app that owns real resolvers otherwise goes green against fabricated data with
370
+ nothing said. A warning rather than a refusal, because a loaded class isn't proof
371
+ you meant it *here* — a federated suite loads every subgraph's — and a fake
372
+ behind the wire is a thing to want.
373
+
374
+ **A helper says what goes behind the wire.** Under the other tags a `graphql_*`
375
+ helper takes the client slot; under `:wire` it is served instead — the client
376
+ slot has to keep your own client for the transport to run at all — so
377
+ `graphql_fake("Reader.orders" => [{ "status" => "PAID" }, {}])` reads exactly as
378
+ it does under `:fake`, and the helper returns the client it serves, so
379
+ `fake.requests` is what the *endpoint* was asked.
380
+
381
+ **Identity comes from the request.** A `context:` **proc** is called per request
382
+ with the headers as sent, which is the seam nothing above the wire can test:
383
+
384
+ ```ruby
385
+ GraphWeaver::Testing.configure do |config|
386
+ config.context = ->(headers) { { current_user: User.find_by(token: headers["Authorization"]) } }
387
+ end
388
+ ```
389
+
390
+ A hash still works, and is still the baseline `graphql_context` merges onto; a
391
+ proc replaces it, and `graphql_context` then says so rather than merging onto
392
+ something that isn't there. (Rack drops a header's capitalization, so `X-CALLER`
393
+ arrives as `X-Caller`.) Like every suite setting, it goes in `Testing.configure`
394
+ or an `around` — [never a plain `before`](#nothing-to-configure), `:wire` least
395
+ of all, since the tag stubs this example's endpoints in a `before` hook of its
396
+ own.
397
+
398
+ **The wire adds a hop, not a capability.** Behind a router, everything
399
+ [it refuses](federation.md#what-it-refuses) is still refused, before any resolver
400
+ runs. Behind a fake, what you are testing is your *transport* — the request your
401
+ middleware wrote, the headers it sent, the retry it does on a 500, and that
402
+ `from_h` reads real JSON off a socket. What it can't tell you is whether your
403
+ `cast:` agrees with the real server: the fabricated bytes are written to match
404
+ your own [scalar registrations](scalars.md), so the round trip agrees with
405
+ itself. Put the live schema class behind the wire for that, or pin a real
406
+ response with a [cassette](cassettes.md).
407
+
408
+ `GraphWeaver::Testing::Endpoint` is an ordinary Rack app wrapping anything that
409
+ satisfies the [client contract](transports.md), so mount it yourself
410
+ (`run GraphWeaver::Testing::Endpoint.new(router)`) if you'd rather have a real
411
+ socket. A client answering a `context:` **proc** is served **one request at a
412
+ time** — answering one means setting the client's context for the length of that
413
+ dispatch, so the identity a request asked for is the identity it gets, whatever
414
+ else is in flight. The lock is the client's own, so it holds however the endpoint
415
+ is mounted. A `context:` hash is served **concurrently**: nothing writes it, so
416
+ there is nothing to serialize.
417
+
418
+ ### Making the served endpoint fail
419
+
420
+ The tag adds **one stub per endpoint**, and webmock answers with the *last* stub
421
+ declared for a url — so an example that wants the server to misbehave declares
422
+ its own, and it wins for that example:
423
+
424
+ ```ruby
425
+ it "surfaces a 503, after the retries it's allowed", graphql: :wire do
426
+ stub_request(:post, "https://api.example.com/graphql")
427
+ .to_return(status: 503, headers: { "Retry-After" => "0" }, body: "down for maintenance")
428
+
429
+ expect { PlaceOrderMutation.execute!(input:) }.to raise_error(GraphWeaver::ServerError) { |e|
430
+ expect(e.status).to eq 503
431
+ expect(e.retry_after).to eq 0 # the header your backoff read
432
+ }
433
+ end
434
+
435
+ # .to_timeout instead, and the call raises GraphWeaver::TransportError
436
+ ```
437
+
438
+ That is a **served** failure: your transport reads the status and the headers off
439
+ a real response, and your `retries:` budget really spends itself against it —
440
+ including [the one attempt a mutation gets](transports.md#retries). That is the
441
+ half a [`Failure` client](#simulating-failures) can't reach, since those sit *in*
442
+ the client slot and raise above the wire. Use `Failure.server` /
443
+ `Failure.timeout` when the example is about your `rescue`; a stub when it is
444
+ about the transport.
445
+
446
+ `to_timeout` raises instantly, so it tests what your app *does* with a timeout —
447
+ not that a `read_timeout:` of yours is short enough. Sleeping inside
448
+ `to_return { |req| … }` doesn't either: webmock stands in for the socket, so
449
+ there is nothing to time out and the call simply takes that long and succeeds. A
450
+ timeout *value* can only be proven against a genuinely slow server — the
451
+ `Endpoint` above on a real port, or a `TCPServer` that dawdles before it replies.
452
+
336
453
  ## Simulating failures
337
454
 
338
- Every failure mode is just a client, so error-handling paths are testable
339
- without a server that misbehaves on cue:
455
+ Every failure mode is just a client, so error-handling paths are testable without
456
+ a server that misbehaves on cue:
340
457
 
341
458
  ```ruby
342
459
  Failure = GraphWeaver::Testing::Failure
343
460
 
344
461
  PersonQuery.execute(client: Failure.transport, id: "1") # raises TransportError
462
+ PersonQuery.execute(client: Failure.timeout, id: "1") # raises TransportError, cause Net::ReadTimeout
345
463
  PersonQuery.execute(client: Failure.server(status: 502), id: "1") # raises ServerError
346
464
  PersonQuery.execute(client: Failure.throttled, id: "1") # errors.first.code => "THROTTLED"
347
465
  PersonQuery.execute(client: Failure.stale_schema, id: "1") # schema_stale? => true
348
- PersonQuery.execute(client: Failure.graphql("boom"), id: "1") # partial failure
466
+ PersonQuery.execute(client: Failure.graphql("boom"), id: "1") # errors, and no data
349
467
 
350
468
  # a throttling server, with the header a backoff reads
351
469
  PersonQuery.execute(client: Failure.server(status: 429, headers: { "retry-after" => "2" }), id: "1")
@@ -356,33 +474,168 @@ fake = GraphWeaver::Testing::FakeClient.new(schema:)
356
474
  GraphWeaver::Testing::Sequence.new(Failure.transport, Failure.transport, fake)
357
475
 
358
476
  # type mismatch: corrupt: derives a wrong-typed wire value for the field —
359
- # casting raises GraphWeaver::TypeError (overrides remain the manual escape hatch)
477
+ # casting raises GraphWeaver::CastError (overrides remain the manual escape hatch)
360
478
  GraphWeaver::Testing::FakeClient.new(schema:, corrupt: "Person.birthday")
361
479
 
362
480
  # 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
481
+ # lands with its concrete path and nulls bubble to the nearest nullable spot.
482
+ # The path is response keys joined by dots, and a list index is a segment of
483
+ # its own — state only the indices you mean, the rest match any position
364
484
  GraphWeaver::Testing::FakeClient.new(schema:, fail_at: { path: "person.email", code: "PRIVATE" })
485
+ GraphWeaver::Testing::FakeClient.new(schema:, fail_at: "people.2.pets.name")
486
+ ```
487
+
488
+ `Failure.graphql` is the **whole response** failing — `data` is null unless you
489
+ pass `data:`, which is what makes it a partial one. Shape the error by naming its
490
+ wire fields beside the message (`code:`, `extensions:`, `path:`, `locations:` —
491
+ anything else is refused rather than swallowed), so a rejection that follows the
492
+ [`extensions.input` convention](errors.md#what-your-server-can-send) is one call:
493
+
494
+ ```ruby
495
+ # a plain code, the coarse bucket every server states
496
+ Failure.graphql("that input was bad", code: "BAD_USER_INPUT")
497
+
498
+ # the convention: response.input_errors reads this back as one InputError,
499
+ # kind :out_of_range, details { min: 1 }, on path ["input", "min"]
500
+ Failure.graphql(
501
+ "min must be at least 1",
502
+ code: "BAD_USER_INPUT",
503
+ extensions: { "input" => { "kind" => "out_of_range", "path" => ["input", "min"],
504
+ "coordinate" => "RangeInput.min", "value" => 0, "min" => 1 } },
505
+ )
506
+
507
+ # several errors, and partial data: each carries its own hash
508
+ Failure.graphql({ message: "boom", path: ["person"] }, "and again", data: { "person" => nil })
365
509
  ```
366
510
 
511
+ **A server's own rejection lands in `#errors` unless it marked it.** A
512
+ graphql-ruby `validates:` failure carries no `extensions` at all, so it is an
513
+ ordinary error in `response.errors` — `#input_errors` is empty, because "the
514
+ value was out of range" and "the database is down" are the same bytes. Assert on
515
+ `errors` for that, and reach for `#input_errors` only once your server
516
+ [says the error is about the input](errors.md#when-the-server-rejects-the-input).
517
+
367
518
  ## Capture and replay
368
519
 
369
- Cassettes record real API responses and replay them offline, above the
370
- transport (no HTTP interception):
520
+ `GraphWeaver::Testing.cassette("github", client: live)` returns a client that
521
+ records real API responses and replays them offline, above the transport (no HTTP
522
+ interception). Re-record with `GRAPHWEAVER_RECORD=1`, and set
523
+ `config.anonymize = true` so the response is scrubbed on its way to disk. The
524
+ full workflow guide is **[cassettes](cassettes.md)**.
525
+
526
+ ## Nothing to configure
527
+
528
+ Each mode works out what to run against **per graph** — with more than one, the
529
+ honest answer varies per module — and **refuses, naming what it looked for,
530
+ rather than guessing**:
531
+
532
+ - **the schema** is `config.schema` if you set one, else the one that
533
+ [graph](getting_started.md#more-than-one-schema) names, else the committed dump
534
+ at `GraphWeaver.schema_path`, else the schema `GraphWeaver.client` talks to. A
535
+ fake reads the scalar registrations of the graph it is answering, so it invents
536
+ the wire value that graph's generated cast expects. (Pins and `overrides:` stay
537
+ suite-wide, keyed by scalar name — one `"Money"` override for the run.)
538
+ - **`:in_process`** needs the live schema *class*, since only that has resolvers:
539
+ the one that graph names, else the one your client already runs in-process,
540
+ else the loaded class that defines everything the schema declares — the same
541
+ derive-verify-refuse rule that
542
+ [maps subgraphs](federation.md#which-schema-serves-which-subgraph).
543
+ - **`:router`** plans against the composed supergraph **that graph** names, else
544
+ `config.router = { supergraph: … }`, else the committed dump when *that*
545
+ carries `@join__*` markers — which for a federated app is usually no config at
546
+ all. A graph that is in no supergraph is refused **by name**, rather than
547
+ planned against another graph's. A client can't stand in for one: a client's
548
+ schema is the API schema the router serves, with the `@join__*` routing table
549
+ stripped out. Subgraphs are derived either way.
550
+
551
+ So configure only to override a derivation, or to tune fabricated values — in the
552
+ same file as the require, since support files load in sorted order and one naming
553
+ `GraphWeaver::Testing` before it dies on `NameError`:
554
+
555
+ ```ruby
556
+ GraphWeaver::Testing.configure do |config|
557
+ # config.schema = MySchema # the live class, rather than the dump
558
+ # config.router = { supergraph: Rails.root.join("supergraph.graphql") }
559
+ # config.router = { subgraphs: { "reviews" => :fake } } # either key alone
560
+ # config.context = { tenant: } # baseline context every example starts from
561
+ # config.default_mode = :fake # what an UNtagged example runs against;
562
+ # # :live (the default) leaves your client
563
+ # # alone, and graphql: :live opts one out
564
+ # config.seed = 4242 # defaults to rspec's own --seed
565
+ # config.overrides = { "Money" => "12.00", "Person.name" => "Daniel" }
566
+ # config.list_size = 1..3
567
+ end
568
+ ```
569
+
570
+ **The rule: a helper sets the stand-in for the graph it names; the tag sets the
571
+ mode for every graph no helper named.** `graphql_fake`, `graphql_in_process` and
572
+ `graphql_router` stand in for the modules of the graph their schema names — your
573
+ only graph when they name none — and with more than one, `graph:` says which:
574
+ `graphql_fake(graph: :poke, "pokemon_v2_pokemon.name" => "pikachu")`. A schema
575
+ class names its graph and its schema in one word
576
+ (`graphql_in_process(Reviews::Schema)`), but only for a graph that runs that
577
+ class in-process; `graph:` is the handle every graph has. Naming none they could
578
+ reach is refused, naming your graphs.
579
+
580
+ So one example can run two graphs in two modes — the federated one through its
581
+ router, the plain one faked — and neither helper disturbs the other's graph:
371
582
 
372
583
  ```ruby
373
- # records against the live client when the file is missing, replays after
374
- client = GraphWeaver::Testing.cassette("github", client: live)
584
+ it "renders the dashboard", graphql: :router do
585
+ graphql_fake(graph: :countries, "Country.name" => "Canada")
586
+ # :storefront routes through its supergraph (the tag); :countries is faked
587
+ end
375
588
  ```
376
589
 
377
- Re-record with `GRAPHWEAVER_RECORD=1`, and set `config.anonymize = true` so the
378
- response is scrubbed on its way to disk the query and its variables are the
379
- replay key and are recorded verbatim, so read a cassette before committing it.
380
- The full workflow guide is **[cassettes](cassettes.md)**.
590
+ A helper that speaks for the whole example *is* refused, though: with one graph,
591
+ or with no `graph:`/schema to narrow it, `graphql: :fake` plus
592
+ `graphql_in_process` is two answers to one question, and the later one winning
593
+ silently would hide which was the mistake.
594
+
595
+ Anything whose honest answer differs per example belongs on the fake instead —
596
+ `graphql_fake(null_chance: 1.0)` for the example that's about an empty state,
597
+ `graphql_fake(values: :literal)` for the one that reads better without faker's
598
+ prose. A suite-wide `null_chance` would sprinkle nils through every *other*
599
+ example, one run in ten, on a seed the failure doesn't name.
600
+
601
+ **Configure at load, or in an `around` — never in a plain `before`.** The tag
602
+ builds this example's clients in a `before` hook of its own, and rspec runs that
603
+ one ahead of yours, so a `before` setting `config.schema`, `config.router` or
604
+ `config.context` arrives after the decision it meant to change. It is
605
+ **refused**, not ignored — a green example running against the wrong stand-in is
606
+ the expensive outcome.
607
+
608
+ ### Fabricated list lengths
609
+
610
+ `list_size` is how long an **unbounded** list is — an Integer exactly that many,
611
+ a Range randomized within it, or a Hash saying it per list. A list with a
612
+ `first:`/`last:`/`limit:` argument is that long instead, whatever this says.
613
+
614
+ **Every list the fabricator reaches reads the same setting, so nested lists
615
+ multiply.** A query selecting `rows { owner { … } tags }` with `tags` uncapped
616
+ fabricates `list_size` rows and `list_size` tags *in each of them* — at 1600 that
617
+ is 2.5M tags, and three nested lists cube it. Say it per list instead, keyed the
618
+ way a pin is (a `"Type.field"` coordinate or a bare field name), with `default:`
619
+ for the rest — `config.list_size = { "Row.tags" => 3, default: 1000 }` holds the
620
+ inner list at 3 however large the outer one grows. Or cap it in the query
621
+ (`tags(first: 3)`), where the query is yours to change.
622
+
623
+ **A list field whose name ends in `errors` fabricates empty** — `userErrors`,
624
+ `errors`, `mutationErrors`. The Relay/Shopify payload
625
+ (`placeOrder { order userErrors }`) is the ecosystem's mutation shape, and a
626
+ fabricated order beside a fabricated failure is a response no server can send;
627
+ [pin it](#pins) to write the failure path
628
+ (`{ "userErrors" => [{ "message" => "Out of stock" }] }`).
629
+
630
+ Need the schema itself inside an example — to sample a field, or build a query on
631
+ the fly? The client in play exposes it as `GraphWeaver.client.schema`, and
632
+ `GraphWeaver::Testing.config.schema` reads back what `config.schema =` set,
633
+ falling back to the committed dump.
381
634
 
382
635
  ## Test-only generated modules
383
636
 
384
- They don't have to live in `app/` — `generated_paths` is an appendable list,
385
- so a support file can register a spec-local set:
637
+ They don't have to live in `app/` — `generated_paths` is an appendable list, so a
638
+ support file can register a spec-local set:
386
639
 
387
640
  ```ruby
388
641
  # spec/support/graph_weaver.rb
@@ -394,5 +647,5 @@ Both lines matter. In Rails the Railtie loads generated modules during boot,
394
647
  which is finished before `spec/support/*.rb` runs — so a path appended here is
395
648
  never loaded unless you load it. And keep the directory *outside*
396
649
  `spec/support/`: rspec-rails requires every `spec/support/**/*.rb` itself, in
397
- sorted order, so a generated module gets required before the shared `types.rb`
398
- it needs and dies on `LoadError`.
650
+ sorted order, so a generated module gets required before the shared `types.rb` it
651
+ needs and dies on `LoadError`.