graph_weaver 0.5.1 → 0.6.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +75 -54
  5. data/docs/cassettes.md +6 -1
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +73 -16
  8. data/docs/federation.md +201 -151
  9. data/docs/generated_modules.md +222 -165
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +34 -4
  12. data/docs/real_world.md +4 -4
  13. data/docs/scalars.md +206 -46
  14. data/docs/testing.md +191 -151
  15. data/docs/transports.md +47 -19
  16. data/docs/upgrading.md +210 -11
  17. data/lib/generators/graph_weaver/install_generator.rb +16 -1
  18. data/lib/graph_weaver/client.rb +46 -13
  19. data/lib/graph_weaver/codegen/aliases.rb +5 -4
  20. data/lib/graph_weaver/codegen/emit.rb +96 -39
  21. data/lib/graph_weaver/codegen/enum_type.rb +3 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +167 -98
  24. data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
  25. data/lib/graph_weaver/codegen.rb +279 -84
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +30 -7
  28. data/lib/graph_weaver/federation.rb +6 -5
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
  38. data/lib/graph_weaver/internal/values.rb +184 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +5 -4
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +50 -11
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +96 -29
  50. data/lib/graph_weaver/tasks.rb +78 -29
  51. data/lib/graph_weaver/testing/cassette.rb +49 -65
  52. data/lib/graph_weaver/testing/coverage.rb +5 -4
  53. data/lib/graph_weaver/testing/failure.rb +10 -6
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +94 -808
  57. data/lib/graph_weaver/testing.rb +35 -84
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +203 -119
  63. metadata +10 -5
  64. data/lib/graph_weaver/schemas.rb +0 -48
  65. data/lib/graph_weaver/selection.rb +0 -120
  66. data/lib/graph_weaver/testing/values.rb +0 -98
data/docs/testing.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Testing
2
2
 
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.
7
+
3
8
  One line in your spec helper:
4
9
 
5
10
  ```ruby
@@ -24,9 +29,9 @@ it "authorizes drafts", graphql: :in_process do … end
24
29
 
25
30
  | mode | reach for it when | what it costs |
26
31
  |---|---|---|
27
- | `graphql: :fake` | most unit tests — you need *a* well-shaped response | no resolver code runs |
28
- | `graphql: :in_process` | the point of the test is that your resolver logic works | slower; needs a live schema class |
29
- | `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
+ | [`:fake`](#fabricated-data--graphql-fake) | most unit tests — you need *a* well-shaped response | no resolver code runs |
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
+ | [`: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 |
30
35
  | [cassettes](cassettes.md) | pinning a real server's exact response | must be re-recorded when the query changes |
31
36
 
32
37
  The tag installs its client as `GraphWeaver.client` for that example, so
@@ -46,14 +51,19 @@ tagged one:
46
51
  before { GraphWeaver.client = GraphWeaver::Testing::Failure.throttled }
47
52
  ```
48
53
 
49
- Everything here is a *client* the one interface queries run through:
50
- anything with `execute(query, variables:, operation_name:)` returning
51
- `{"data" => ..., "errors" => ...}` (see [transports](transports.md)). Fakes,
52
- the router, failures, and cassettes all slot in wherever a real transport
53
- would, so they work outside rspec too (`require "graph_weaver/testing"` —
54
- never from production code). Outside the tags there's no `GraphWeaver.client`
55
- to lean on, so parse from the fake or the router itself — anything holding a
56
- schema parses against it, and the module runs on what parsed it:
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.
59
+
60
+ 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:
57
67
 
58
68
  ```ruby
59
69
  router = GraphWeaver::Testing::Router.new(supergraph: "app/graphql/supergraph.graphql")
@@ -72,9 +82,6 @@ looked for — rather than guessing**:
72
82
 
73
83
  - **the schema** is `config.schema` if you set one, else the committed dump
74
84
  at `GraphWeaver.schema_path`, else the schema `GraphWeaver.client` talks to.
75
- (A federated app has no one schema class, so `config.schema` is the whole
76
- graph's shape and an example names the subgraph whose resolvers it wants —
77
- see `graphql_in_process` below.)
78
85
  - **`:in_process`** needs the live schema *class*, since only that has
79
86
  resolvers: the one your client already runs in-process, else the loaded
80
87
  class that defines everything the schema declares — the same
@@ -96,84 +103,95 @@ GraphWeaver::Testing.configure do |config|
96
103
  # config.context = { tenant: } # baseline context every example starts from
97
104
  # config.default_mode = :fake # what an UNtagged example runs against
98
105
  # # (graphql: false opts one back out)
99
- # config.mode = :faker # or :literal (plain typed values); nil = auto
100
- # config.overrides = { "Person.name" => "Daniel" }
106
+ # config.seed = 4242 # defaults to rspec's own --seed
107
+ # config.overrides = { "Money" => "12.00", "Person.name" => "Daniel" }
101
108
  # config.list_size = 1..3
102
- # config.null_chance = 0.1 # nullable fields go nil sometimes
103
- end
104
- ```
105
-
106
- ## The context your resolvers see
107
-
108
- `graphql_context` is available in every example. It **merges** onto
109
- `config.context` — the baseline survives unless you override a key — and is
110
- **reset before the next example**, so one example running as somebody else
111
- can't leak into the one after it.
112
-
113
- Context is setup, so it usually belongs in a `before` block — a group of
114
- examples sharing one identity says who they are once:
115
-
116
- ```ruby
117
- describe "as the owner", graphql: :in_process do
118
- before { graphql_context(current_user: alice) }
119
-
120
- it "shows the drafts" do
121
- expect(DraftsQuery.execute!.drafts.size).to eq 2
122
- end
123
-
124
- it "counts them" do … end
125
- end
126
- ```
127
-
128
- The reset runs ahead of any group hook, so each example re-applies that
129
- `before` from the same baseline rather than stacking onto the last one's
130
- context. Set it inline for the one-off:
131
-
132
- ```ruby
133
- it "shows the owner's drafts", graphql: :in_process do
134
- graphql_context(current_user: alice)
135
- expect(DraftsQuery.execute!.drafts.size).to eq 2
136
109
  end
137
110
  ```
138
111
 
139
- Pass a block to scope it, for the example that needs two identities:
140
-
141
- ```ruby
142
- graphql_context(admin: true) { expect(SettingsQuery.execute!.settings).to be_present }
143
- ```
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.
144
117
 
145
- Called with nothing it reads the context back. Under `graphql: :fake` it
146
- refuses: there are no resolvers to receive a context, and silently ignoring
147
- one would leave an example asserting on data nothing scoped. Pin the data
148
- itself instead `graphql_fake(overrides: …)`, below.
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.
149
122
 
150
123
  ## Fabricated data — `graphql: :fake`
151
124
 
152
125
  `FakeClient` fabricates schema-correct responses for whatever query
153
- arrives: real enum values, valid `__typename` members, iso8601 date scalars
154
- every fake casts cleanly through your generated structs.
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.
155
132
 
156
133
  ```ruby
157
134
  fake = GraphWeaver::Testing::FakeClient.new # schema: falls back to Testing.config
158
135
 
159
136
  person = PersonQuery.execute!(client: fake, id: "1").person
160
- person.name # => "Eliza Kertzmann" (faker-matched on field name, when faker is loaded)
161
- person.birthday # => a real Date
137
+ person.name # a plausible name, when faker is loaded
138
+ person.birthday # a real Date
162
139
  ```
163
140
 
164
- Pin what matters, keyed by GraphQL names (schema vocabulary — keys
165
- survive query refactors); `"Type.field"` beats `"field"`:
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:
166
146
 
167
147
  ```ruby
168
- GraphWeaver::Testing::FakeClient.new(schema:, overrides: {
169
- "Person.name" => "Daniel",
170
- "email" => -> { "test@example.com" },
171
- })
148
+ graphql_fake(values: :literal)
149
+ GraphWeaver::Testing::FakeClient.new(values: :literal)
172
150
  ```
173
151
 
174
- Keys are checked against the schema, spellchecked — `"Person.nmae"` raises
175
- rather than quietly pinning nothing and leaving the example green against
176
- random data.
152
+ ### Pins
153
+
154
+ **A pin says what the fake uses instead of inventing a value** — keyed by a
155
+ scalar type, an object type, or a field; worth a wire value, an object the fake
156
+ reads the selected fields off, or a proc handed the seeded `Random`:
157
+
158
+ ```ruby
159
+ graphql_fake("Money" => "12.00", # every Money field, however deep
160
+ "Person" => build(:person), # the selected fields, read off the object
161
+ "Order.total" => "999.00") # this one field — and it beats the type's pin
162
+ ```
163
+
164
+ Keys are schema vocabulary, so they survive query refactors — a type name, or
165
+ `"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.
168
+
169
+ An **object pin** is anything answering the field names — a FactoryBot build, a
170
+ 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
172
+ [scalar registration](scalars.md) serializes it (a `Time` as its iso8601 string,
173
+ a `T::Enum` as its value), recurses into nested objects and arrays of them, and
174
+ **fabricates any field the object doesn't answer**. Readers are field names, not
175
+ 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`.
179
+
180
+ A **scalar type pin** is the one thing a scalar registered as *your own class*
181
+ 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.
187
+
188
+ 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:)`.
177
195
 
178
196
  ### The example that's *about* the data
179
197
 
@@ -183,10 +201,8 @@ the example can say what it needs:
183
201
 
184
202
  ```ruby
185
203
  it "shows the two paid orders", graphql: :fake do
186
- graphql_fake(overrides: {
187
- "Reader.name" => "Ada",
188
- "Reader.orders" => [{ "status" => "PAID" }, {}],
189
- })
204
+ graphql_fake("Reader.name" => "Ada",
205
+ "Reader.orders" => [{ "status" => "PAID" }, {}])
190
206
 
191
207
  expect(DashboardQuery.execute!.reader.orders.size).to eq 2
192
208
  end
@@ -217,21 +233,26 @@ with no tag at all, since it installs the client itself. The tag is
217
233
  One thing to know: **two identical queries fabricate different data**, so
218
234
  assert a memoization with `requests.size`, not by comparing two responses.
219
235
 
220
- ### Naming the schema your resolvers run on
236
+ ## Real resolvers `graphql: :in_process`
221
237
 
222
- `graphql_in_process` is the same idea for real resolvers. The tag runs
223
- `config.schema` when that's a live class, which is the whole story for an app
224
- that serves the API it calls:
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.
225
240
 
226
241
  ```ruby
227
- it "hides another reader's drafts", graphql: :in_process do
228
- expect(DraftsQuery.execute!.drafts.map(&:id)).to eq %w[d3]
242
+ it "hides other people's drafts", graphql: :in_process do
243
+ graphql_context(current_user: alice)
244
+ expect(DraftsQuery.execute!.drafts.map(&:owner)).to all(eq alice.name)
229
245
  end
230
246
  ```
231
247
 
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.
252
+
232
253
  A federated app has no one live class, so the example says which subgraph it
233
- means testing one subgraph's resolvers directly is a different question from
234
- `graphql: :router`, which plans across the whole graph and stitches. Both are
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
235
256
  worth asking, and a suite asks them of different subgraphs:
236
257
 
237
258
  ```ruby
@@ -241,52 +262,95 @@ it "rejects a review from a blocked reader" do
241
262
  end
242
263
  ```
243
264
 
244
- Like `graphql_fake`, it needs no tag, works in a `before` block, and is
245
- restored after the example. Set the GraphQL context with `graphql_context`
246
- rather than the helper's `context:` — the helper's is a baseline, and
247
- `graphql_context` is what merges onto it per example.
265
+ Like `graphql_fake`, `graphql_in_process` needs no tag, works in a `before`
266
+ block, and is restored after the example.
248
267
 
249
- `rspec --seed 1234` reproduces fake data along with test order. `config.mode`
250
- picks value fabrication: `:faker` (semantic, field-name matched — raises if
251
- the gem is missing), `:literal` (plain type-derived), or nil to auto-detect
252
- faker.
268
+ ### The context your resolvers see
253
269
 
254
- Need the schema itself inside an example to sample a field, or build a
255
- query on the fly? The client in play exposes it as
256
- `GraphWeaver.client.schema`, and `GraphWeaver::Testing.config.schema` reads
257
- back what `config.schema =` set, falling back to the committed dump.
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.)
258
275
 
259
- Test-only generated modules don't have to live in `app/``generated_paths` is
260
- an appendable list, so a support file can register a spec-local set:
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:
261
278
 
262
279
  ```ruby
263
- # spec/support/graph_weaver.rb
264
- GraphWeaver.generated_paths << "spec/graphql/generated"
265
- GraphWeaver.load_generated! # the appended path needs this call
280
+ describe "as the owner", graphql: :in_process do
281
+ 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
+ end
266
287
  ```
267
288
 
268
- Both lines matter. In Rails the Railtie loads generated modules during boot,
269
- which is finished before `spec/support/*.rb` runs so a path appended here is
270
- never loaded unless you load it. And keep the directory *outside*
271
- `spec/support/`: rspec-rails requires every `spec/support/**/*.rb` itself, in
272
- sorted order, so a generated module gets required before the shared `types.rb`
273
- it needs and dies on `LoadError`.
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.
302
+
303
+ ## A federated graph — `graphql: :router`
304
+
305
+ 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.
309
+
310
+ ```ruby
311
+ describe "the dashboard", graphql: :router do
312
+ it "stitches a user's reviews" do
313
+ graphql_context(current_user: user)
314
+ expect(DashboardQuery.execute!.me.reviews.size).to eq 2
315
+ end
316
+ end
317
+ ```
318
+
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.
322
+
323
+ `graphql_router` is the tag with options, the way `graphql_fake` is — one
324
+ option, `fake:`, saying how the subgraphs the router
325
+ [fakes](federation.md#a-supergraph-only-partly-local) fabricate: the pins and
326
+ options `graphql_fake` takes, in one hash:
327
+
328
+ ```ruby
329
+ graphql_router(fake: { "Shipment.carrier" => "UPS", list_size: 2 })
330
+ ```
331
+
332
+ What it plans, what it **refuses** and why, how subgraphs are matched to your
333
+ schema classes, and what to do about a supergraph only partly local:
334
+ **[federation → the local router](federation.md#the-local-router)**.
274
335
 
275
336
  ## Simulating failures
276
337
 
277
- Every failure mode is just a client, so
278
- error-handling paths are testable without a server that misbehaves on cue:
338
+ Every failure mode is just a client, so error-handling paths are testable
339
+ without a server that misbehaves on cue:
279
340
 
280
341
  ```ruby
281
342
  Failure = GraphWeaver::Testing::Failure
282
343
 
283
- PersonQuery.execute(client: Failure.transport, id: "1") # TransportError (cause preserved)
284
- PersonQuery.execute(client: Failure.server(status: 502), id: "1") # ServerError
285
- PersonQuery.execute(client: Failure.throttled, id: "1") # QueryError, code THROTTLED
344
+ PersonQuery.execute(client: Failure.transport, id: "1") # raises TransportError
345
+ PersonQuery.execute(client: Failure.server(status: 502), id: "1") # raises ServerError
346
+ PersonQuery.execute(client: Failure.throttled, id: "1") # errors.first.code => "THROTTLED"
286
347
  PersonQuery.execute(client: Failure.stale_schema, id: "1") # schema_stale? => true
287
348
  PersonQuery.execute(client: Failure.graphql("boom"), id: "1") # partial failure
288
349
 
289
- # retries: clients run in sequence (the last repeats) here, two
350
+ # a throttling server, with the header a backoff reads
351
+ PersonQuery.execute(client: Failure.server(status: 429, headers: { "retry-after" => "2" }), id: "1")
352
+
353
+ # testing a retry — clients run in sequence, the last one repeating: two
290
354
  # transport failures and then a FakeClient serving good responses
291
355
  fake = GraphWeaver::Testing::FakeClient.new(schema:)
292
356
  GraphWeaver::Testing::Sequence.new(Failure.transport, Failure.transport, fake)
@@ -302,8 +366,8 @@ GraphWeaver::Testing::FakeClient.new(schema:, fail_at: { path: "person.email", c
302
366
 
303
367
  ## Capture and replay
304
368
 
305
- Cassettes record real API responses and replay
306
- them offline, above the transport (no HTTP interception):
369
+ Cassettes record real API responses and replay them offline, above the
370
+ transport (no HTTP interception):
307
371
 
308
372
  ```ruby
309
373
  # records against the live client when the file is missing, replays after
@@ -315,44 +379,20 @@ response is scrubbed on its way to disk — the query and its variables are the
315
379
  replay key and are recorded verbatim, so read a cassette before committing it.
316
380
  The full workflow guide is **[cassettes](cassettes.md)**.
317
381
 
318
- ## Real resolvers, one schema — `graphql: :in_process`
319
-
320
- Your actual resolvers, your actual `context`, in the same process — no
321
- socket, no serialization, and a resolver's real backtrace when it raises.
322
-
323
- ```ruby
324
- it "hides other people's drafts", graphql: :in_process do
325
- graphql_context(current_user: alice)
326
- expect(DraftsQuery.execute!.drafts.map(&:owner)).to all(eq alice.name)
327
- end
328
- ```
329
-
330
- The live schema *class* is found for you (a schema dump has no resolvers,
331
- so it won't do). If two loaded classes match, or none does, it says so and
332
- asks for `config.schema = MySchema` — and in Rails, remember that an
333
- autoloaded schema isn't loaded until something references it.
334
-
335
- ## A federated graph — `graphql: :router`
382
+ ## Test-only generated modules
336
383
 
337
- Same thing across a federated graph: the tag builds a
338
- [`Testing::Router`](federation.md#the-local-router), which plans the query
339
- across your subgraphs and runs it against those **real resolvers** — no
340
- gateway, no node, no sockets.
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:
341
386
 
342
387
  ```ruby
343
- describe "the dashboard", graphql: :router do
344
- it "stitches a user's reviews" do
345
- graphql_context(current_user: user)
346
- expect(DashboardQuery.execute!.me.reviews.size).to eq 2
347
- end
348
- end
388
+ # spec/support/graph_weaver.rb
389
+ GraphWeaver.generated_paths << "spec/graphql/generated"
390
+ GraphWeaver.load_generated! # the appended path needs this call
349
391
  ```
350
392
 
351
- The router is built once for the suite (parsing a supergraph per example
352
- would be real time) and installed as `GraphWeaver.client` for each; its
353
- context is reset from `config.context` every time, so an example that runs
354
- as someone else can't leak into the next.
355
-
356
- What it plans, what it **refuses** and why, how subgraphs are matched to your
357
- schema classes, and what to do about a supergraph only partly local:
358
- **[federation → the local router](federation.md#the-local-router)**.
393
+ Both lines matter. In Rails the Railtie loads generated modules during boot,
394
+ which is finished before `spec/support/*.rb` runs so a path appended here is
395
+ never loaded unless you load it. And keep the directory *outside*
396
+ `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`.
data/docs/transports.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Transports
2
2
 
3
+ Where queries actually go: what fills the client slot, how to build and tune the
4
+ bundled HTTP transports, and how a generated module decides which one to use.
5
+ Read it when the default one-liner isn't enough — custom headers, mTLS, Faraday
6
+ middleware, retries, or connection pooling under load.
7
+
3
8
  A *client* is anything with `execute(query, variables:, operation_name:)` whose result
4
9
  `to_h`s into `{"data" => ..., "errors" => ...}` — from a full
5
10
  `GraphWeaver::Client` down to a schema class
@@ -40,8 +45,9 @@ contract itself, so it goes anywhere a transport does — `Retry.new(client)`,
40
45
  scheme (`"Basic dXNlcjpwYXNz..."`)
41
46
  - `transport:` — `:http` (the default) or `:faraday`
42
47
  - `headers:` — anything else (API keys, custom headers)
43
- - `retries:` — off by default; `true` for a `Retry` with defaults,
44
- or a Hash of its options
48
+ - `retries:` — off by default; a count (`retries: 3`), or `true` for the
49
+ default count. Every other [`Retry`](#retries) option sits beside it
50
+ (`backoff:`, `retry_codes:`, ...)
45
51
  - `open_timeout:` / `read_timeout:` — seconds, defaulting to 10 and 30 on
46
52
  either transport
47
53
  - `cache:` / `ttl:` — schema introspection caching (see
@@ -149,6 +155,11 @@ warmest one; requests beyond that queue for a free slot rather than
149
155
  opening unbounded connections. A socket that errors is closed and its slot
150
156
  left empty, so the next call reconnects.
151
157
 
158
+ A url client introspects its schema lazily, and the first requests of a cold
159
+ process arrive together — so that fetch is done **once**, by whoever asks
160
+ first, with the rest waiting on it rather than each making its own round trip
161
+ and writing its own copy of the schema cache.
162
+
152
163
  `pool_size:` defaults to `RAILS_MAX_THREADS` (else 5) — the same variable
153
164
  Rails sizes its own connection pool from, because it is the same question:
154
165
  how many requests this process can have in flight at once. Lower it for a
@@ -173,35 +184,54 @@ takes a `Client` or any bare transport/fake):
173
184
  3. baked constant: `Codegen.generate(..., client: MyApi::CLIENT)`
174
185
  4. the app default: `GraphWeaver.client=`
175
186
 
176
- Nothing set anywhere raises with a message saying which knobs exist.
187
+ Nothing set anywhere raises, naming the two you'd usually reach for:
188
+ `no client configured — set GraphWeaver.client= or pass a client`.
177
189
 
178
190
  ## Retries
179
191
 
180
- `Retry` wraps any client/transport:
192
+ A url client retries when you give it a count; the rest of the options
193
+ sit beside it:
181
194
 
182
195
  ```ruby
183
- GraphWeaver::Retry.new(
184
- inner_transport,
185
- tries: 5, # total attempts, first included
196
+ GraphWeaver.new(
197
+ url,
198
+ retries: 5, # attempts after the first
186
199
  backoff: :exponential, # or :linear, or ->(attempt) { seconds }
187
- base: 0.5, max: 30, # seconds; delays clamp at max:
200
+ base_delay: 0.5, max_delay: 30, # seconds; delays clamp at max_delay:
188
201
  jitter: true, # randomize each delay by 50-100%
189
- on: [GraphWeaver::TransportError, GraphWeaver::ServerError],
190
- retry_if: ->(error) { ... }, # fine-grain within on:
202
+ retry_on: [GraphWeaver::TransportError, GraphWeaver::ServerError],
203
+ retry_if: ->(error) { ... }, # fine-grain within retry_on:
191
204
  retry_codes: ["THROTTLED"], # also retry GraphQL errors by code
205
+ retry_mutations: false, # true if your mutations are idempotent
192
206
  )
193
207
  ```
194
208
 
195
- Defaults: transport failures always retry (the request never arrived);
196
- `ServerError` on 5xx plus **408 and 429** — the rest of 4xx is a bug in
197
- the request, retrying won't fix it. `retry_codes:` re-inspects response
198
- envelopes so GraphQL-level throttling can retry too (off by default —
199
- pass the codes your API uses). Exhausting `tries:` re-raises the last
200
- error (or returns the last code-matched response).
209
+ `GraphWeaver::Retry.new(inner_transport, ...)` takes the same options and
210
+ wraps any client/transport directly — the client just passes them along.
211
+
212
+ Defaults: transport failures always retry; `ServerError` on 5xx plus
213
+ **408 and 429** — the rest of 4xx is a bug in the request, retrying
214
+ won't fix it. `retry_codes:` re-inspects response envelopes so
215
+ GraphQL-level throttling can retry too (off by default — pass the codes
216
+ your API uses). Exhausting the retries re-raises the last error (or
217
+ returns the last code-matched response).
218
+
219
+ `retries:` counts the attempts *after* the first, so
220
+ `GraphWeaver.new(url, retries: 3)` makes up to four and `retries: 0` never
221
+ retries; `retries: true` takes `Retry`'s own default of 2. Every misspelling
222
+ raises rather than quietly doing nothing — a retry option passed without a
223
+ count says so, and the old Hash form (`retries: { retries: 5 }`) names its
224
+ flat replacement.
225
+
226
+ **A mutation gets one attempt.** A failure with no answer — a read
227
+ timeout, a 502, a reset socket — does not say whether the server applied
228
+ it, and a second `charge` is worse than a failed one.
229
+ `retry_mutations: true` opts an idempotent API back in; the skipped
230
+ retry says so on the logger.
201
231
 
202
232
  **`Retry-After` wins over the backoff.** When the server names a delay
203
233
  (seconds or an HTTP-date), that's the wait — the server is the only
204
- party that knows when its window reopens. It's clamped to `max:` so a
234
+ party that knows when its window reopens. It's clamped to `max_delay:` so a
205
235
  "come back in an hour" can't park a thread for an hour, and not
206
236
  jittered, since it's an instruction rather than a guess.
207
237
 
@@ -217,7 +247,5 @@ rescue GraphWeaver::ServerError => e
217
247
  end
218
248
  ```
219
249
 
220
- Or via the client: `GraphWeaver.new(url, retries: { tries: 5, retry_codes: ["THROTTLED"] })`.
221
-
222
250
  What classifies as a transport failure is an extensible set — see
223
251
  [errors](errors.md#extending-transporterror) (`GraphWeaver.register_transport_error`).