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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +432 -0
- data/Gemfile.lock +19 -19
- data/README.md +75 -54
- data/docs/cassettes.md +6 -1
- data/docs/editors.md +3 -1
- data/docs/errors.md +73 -16
- data/docs/federation.md +201 -151
- data/docs/generated_modules.md +222 -165
- data/docs/getting_started.md +106 -82
- data/docs/logging.md +34 -4
- data/docs/real_world.md +4 -4
- data/docs/scalars.md +206 -46
- data/docs/testing.md +191 -151
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +210 -11
- data/lib/generators/graph_weaver/install_generator.rb +16 -1
- data/lib/graph_weaver/client.rb +46 -13
- data/lib/graph_weaver/codegen/aliases.rb +5 -4
- data/lib/graph_weaver/codegen/emit.rb +96 -39
- data/lib/graph_weaver/codegen/enum_type.rb +3 -0
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +167 -98
- data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
- data/lib/graph_weaver/codegen.rb +279 -84
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +30 -7
- data/lib/graph_weaver/federation.rb +6 -5
- data/lib/graph_weaver/hints.rb +76 -2
- data/lib/graph_weaver/in_process.rb +11 -8
- data/lib/graph_weaver/inflect.rb +2 -0
- data/lib/graph_weaver/input_struct.rb +115 -12
- data/lib/graph_weaver/internal/overrides.rb +101 -0
- data/lib/graph_weaver/internal/planner.rb +868 -0
- data/lib/graph_weaver/internal/schemas.rb +50 -0
- data/lib/graph_weaver/internal/selection.rb +127 -0
- data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
- data/lib/graph_weaver/internal/values.rb +184 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +5 -4
- data/lib/graph_weaver/query_module.rb +2 -0
- data/lib/graph_weaver/railtie.rb +113 -14
- data/lib/graph_weaver/representation.rb +30 -2
- data/lib/graph_weaver/response.rb +15 -0
- data/lib/graph_weaver/retry.rb +54 -22
- data/lib/graph_weaver/rspec.rb +50 -11
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +96 -29
- data/lib/graph_weaver/tasks.rb +78 -29
- data/lib/graph_weaver/testing/cassette.rb +49 -65
- data/lib/graph_weaver/testing/coverage.rb +5 -4
- data/lib/graph_weaver/testing/failure.rb +10 -6
- data/lib/graph_weaver/testing/fake_client.rb +253 -60
- data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
- data/lib/graph_weaver/testing/router.rb +94 -808
- data/lib/graph_weaver/testing.rb +35 -84
- data/lib/graph_weaver/transport/faraday.rb +1 -1
- data/lib/graph_weaver/transport/http.rb +29 -12
- data/lib/graph_weaver/transport.rb +11 -34
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +203 -119
- metadata +10 -5
- data/lib/graph_weaver/schemas.rb +0 -48
- data/lib/graph_weaver/selection.rb +0 -120
- 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
|
|
28
|
-
| `graphql
|
|
29
|
-
| `graphql
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
`
|
|
52
|
-
the
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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,
|
|
154
|
-
—
|
|
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 #
|
|
161
|
-
person.birthday #
|
|
137
|
+
person.name # a plausible name, when faker is loaded
|
|
138
|
+
person.birthday # a real Date
|
|
162
139
|
```
|
|
163
140
|
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
"email" => -> { "test@example.com" },
|
|
171
|
-
})
|
|
148
|
+
graphql_fake(values: :literal)
|
|
149
|
+
GraphWeaver::Testing::FakeClient.new(values: :literal)
|
|
172
150
|
```
|
|
173
151
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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(
|
|
187
|
-
|
|
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
|
-
|
|
236
|
+
## Real resolvers — `graphql: :in_process`
|
|
221
237
|
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
228
|
-
|
|
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
|
|
234
|
-
`graphql: :router`, which plans across the whole graph and stitches
|
|
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`,
|
|
245
|
-
restored after the example.
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
|
284
|
-
PersonQuery.execute(client: Failure.server(status: 502), id: "1") # ServerError
|
|
285
|
-
PersonQuery.execute(client: Failure.throttled, id: "1") #
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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; `
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
196
|
+
GraphWeaver.new(
|
|
197
|
+
url,
|
|
198
|
+
retries: 5, # attempts after the first
|
|
186
199
|
backoff: :exponential, # or :linear, or ->(attempt) { seconds }
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
retry_if: ->(error) { ... }, # fine-grain within
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 `
|
|
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`).
|