graph_weaver 0.4.6 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1314 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -154
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -258
- data/lib/graph_weaver/codegen/enum_type.rb +27 -124
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +593 -334
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +9 -1
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +14 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +7 -0
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +794 -59
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +43 -8
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +160 -61
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +181 -21
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +28 -10
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +67 -14
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +389 -170
- metadata +20 -3
data/docs/testing.md
CHANGED
|
@@ -1,22 +1,162 @@
|
|
|
1
1
|
# Testing
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
through: anything with `execute(query, variables:)` returning
|
|
5
|
-
`{"data" => ..., "errors" => ...}` (see [transports](transports.md)).
|
|
6
|
-
Fakes, failures, and cassettes all slot in wherever a real transport
|
|
7
|
-
would.
|
|
3
|
+
One line in your spec helper:
|
|
8
4
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
```ruby
|
|
6
|
+
require "graph_weaver/rspec"
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
(In Rails, put it **above** the `spec/support` glob in `rails_helper.rb` —
|
|
10
|
+
rspec-rails requires those partway through, and a support file mentioning
|
|
11
|
+
`GraphWeaver::Testing` before this line dies on `NameError`.)
|
|
12
|
+
|
|
13
|
+
Then **one tag says what an example runs against** — on the example, or on
|
|
14
|
+
the group it belongs to, since rspec metadata inherits:
|
|
15
15
|
|
|
16
16
|
```ruby
|
|
17
|
-
|
|
17
|
+
describe "checkout", graphql: :router do
|
|
18
|
+
it "stitches the dashboard" do … end # every example here, too
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "renders the empty state", graphql: :fake do … end
|
|
22
|
+
it "authorizes drafts", graphql: :in_process do … end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
| mode | reach for it when | what it costs |
|
|
26
|
+
|---|---|---|
|
|
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 |
|
|
30
|
+
| [cassettes](cassettes.md) | pinning a real server's exact response | must be re-recorded when the query changes |
|
|
31
|
+
|
|
32
|
+
The tag installs its client as `GraphWeaver.client` for that example, so
|
|
33
|
+
generated modules run against it with zero per-test setup. (Generate them
|
|
34
|
+
*without* a baked `client:` — a module that has one never consults
|
|
35
|
+
`GraphWeaver.client`.) `rspec --tag graphql:router` runs one mode's
|
|
36
|
+
examples; an untagged example is left alone unless you set
|
|
37
|
+
`config.default_mode`, and **`graphql: false` opts one back out** of that
|
|
38
|
+
default.
|
|
39
|
+
|
|
40
|
+
`GraphWeaver.client` is **snapshotted before every example and restored
|
|
41
|
+
after** — tagged, untagged or opted out, and whatever the example did to
|
|
42
|
+
it. So building your own client is a plain assignment, cleaned up like a
|
|
43
|
+
tagged one:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
before { GraphWeaver.client = GraphWeaver::Testing::Failure.throttled }
|
|
47
|
+
```
|
|
48
|
+
|
|
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:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
router = GraphWeaver::Testing::Router.new(supergraph: "app/graphql/supergraph.graphql")
|
|
60
|
+
DashboardQuery = router.parse("query Dashboard { me { username } }")
|
|
61
|
+
DashboardQuery.execute!.me.username
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
All three modes, tagged and running end to end, are
|
|
65
|
+
[`spec/rspec_spec.rb`](../spec/rspec_spec.rb) — the reference for anything
|
|
66
|
+
this page leaves out.
|
|
67
|
+
|
|
68
|
+
## Nothing to configure
|
|
69
|
+
|
|
70
|
+
Each mode works out what to run against, and **refuses — naming what it
|
|
71
|
+
looked for — rather than guessing**:
|
|
72
|
+
|
|
73
|
+
- **the schema** is `config.schema` if you set one, else the committed dump
|
|
74
|
+
at `GraphWeaver.schema_path`, else the schema `GraphWeaver.client` talks to.
|
|
75
|
+
(`config.schema` refuses a federation *subgraph* class: fakes are fabricated
|
|
76
|
+
against it too, so one subgraph would be a fraction of the graph. A
|
|
77
|
+
federated graph has no one schema class — that's what `:router` is.)
|
|
78
|
+
- **`:in_process`** needs the live schema *class*, since only that has
|
|
79
|
+
resolvers: the one your client already runs in-process, else the loaded
|
|
80
|
+
class that defines everything the schema declares — the same
|
|
81
|
+
derive-verify-refuse rule that
|
|
82
|
+
[maps subgraphs](federation.md#which-schema-serves-which-subgraph).
|
|
83
|
+
- **`:router`** plans against the composed supergraph. If your committed dump
|
|
84
|
+
*is* one (it carries `@join__*` markers), that's it — no config at all. A
|
|
85
|
+
client can't stand in for it: a client's schema is the API schema the router
|
|
86
|
+
serves, with the `@join__*` routing table stripped out, so the supergraph has
|
|
87
|
+
to be named. Subgraphs are derived either way.
|
|
88
|
+
|
|
89
|
+
So configure only to override a derivation, or to tune fabricated values:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
GraphWeaver::Testing.configure do |config|
|
|
93
|
+
# config.schema = MySchema # the live class, rather than the dump
|
|
94
|
+
# config.router = { supergraph: Rails.root.join("supergraph.graphql") }
|
|
95
|
+
# config.router = { subgraphs: { "reviews" => :fake } } # either key alone
|
|
96
|
+
# config.context = { tenant: } # baseline context every example starts from
|
|
97
|
+
# config.default_mode = :fake # what an UNtagged example runs against
|
|
98
|
+
# # (graphql: false opts one back out)
|
|
99
|
+
# config.mode = :faker # or :literal (plain typed values); nil = auto
|
|
100
|
+
# config.overrides = { "Person.name" => "Daniel" }
|
|
101
|
+
# 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
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
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
|
+
```
|
|
18
144
|
|
|
19
|
-
|
|
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.
|
|
149
|
+
|
|
150
|
+
## Fabricated data — `graphql: :fake`
|
|
151
|
+
|
|
152
|
+
`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.
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
fake = GraphWeaver::Testing::FakeClient.new # schema: falls back to Testing.config
|
|
158
|
+
|
|
159
|
+
person = PersonQuery.execute!(client: fake, id: "1").person
|
|
20
160
|
person.name # => "Eliza Kertzmann" (faker-matched on field name, when faker is loaded)
|
|
21
161
|
person.birthday # => a real Date
|
|
22
162
|
```
|
|
@@ -31,52 +171,120 @@ GraphWeaver::Testing::FakeClient.new(schema:, overrides: {
|
|
|
31
171
|
})
|
|
32
172
|
```
|
|
33
173
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
177
|
+
|
|
178
|
+
### The example that's *about* the data
|
|
179
|
+
|
|
180
|
+
Fabricated data answers "does this render", not "does it render Ada's two
|
|
181
|
+
orders". `graphql_fake` is the tag with options — same client, built where
|
|
182
|
+
the example can say what it needs:
|
|
39
183
|
|
|
40
184
|
```ruby
|
|
41
|
-
|
|
185
|
+
it "shows the two paid orders", graphql: :fake do
|
|
186
|
+
graphql_fake(overrides: {
|
|
187
|
+
"Reader.name" => "Ada",
|
|
188
|
+
"Reader.orders" => [{ "status" => "PAID" }, {}],
|
|
189
|
+
})
|
|
42
190
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
191
|
+
expect(DashboardQuery.execute!.reader.orders.size).to eq 2
|
|
192
|
+
end
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
An override pins a **subtree** as readily as a leaf, and **merges**: name
|
|
196
|
+
the fields the example is about and everything else in the selection is
|
|
197
|
+
still fabricated. A pinned list is exactly as long as you write it — `{}`
|
|
198
|
+
means "another one, all fabricated". Inside a subtree the keys are
|
|
199
|
+
*response* keys, as they come back on the wire (`priceCents`, or an alias
|
|
200
|
+
you selected); one the query doesn't select is refused and spellchecked,
|
|
201
|
+
same as a typo'd coordinate. At a union or interface, name the member with
|
|
202
|
+
`"__typename"`.
|
|
203
|
+
|
|
204
|
+
`graphql_fake` returns the client, which records what it was asked:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
fake = graphql_fake
|
|
208
|
+
2.times { Dashboard.load }
|
|
209
|
+
expect(fake.requests.size).to eq 1 # memoized
|
|
210
|
+
expect(fake.requests.first[:variables]).to eq({ "id" => "1" })
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
It works in a `before` block, an example body, or a shared context — and
|
|
214
|
+
with no tag at all, since it installs the client itself. The tag is
|
|
215
|
+
`graphql_fake` with no options.
|
|
216
|
+
|
|
217
|
+
One thing to know: **two identical queries fabricate different data**, so
|
|
218
|
+
assert a memoization with `requests.size`, not by comparing two responses.
|
|
219
|
+
|
|
220
|
+
### Naming the schema your resolvers run on
|
|
221
|
+
|
|
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:
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
it "hides another reader's drafts", graphql: :in_process do
|
|
228
|
+
expect(DraftsQuery.execute!.drafts.map(&:id)).to eq %w[d3]
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
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
|
|
235
|
+
worth asking, and a suite asks them of different subgraphs:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
it "rejects a review from a blocked reader" do
|
|
239
|
+
graphql_in_process(Reviews::Schema)
|
|
240
|
+
…
|
|
50
241
|
end
|
|
51
242
|
```
|
|
52
243
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
248
|
+
|
|
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.
|
|
253
|
+
|
|
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.
|
|
59
258
|
|
|
60
|
-
Test-only
|
|
61
|
-
|
|
62
|
-
spec-local set that `load_generated!` (and the Railtie) pick up:
|
|
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:
|
|
63
261
|
|
|
64
262
|
```ruby
|
|
65
|
-
|
|
66
|
-
GraphWeaver.
|
|
263
|
+
# spec/support/graph_weaver.rb
|
|
264
|
+
GraphWeaver.generated_paths << "spec/graphql/generated"
|
|
265
|
+
GraphWeaver.load_generated! # the appended path needs this call
|
|
67
266
|
```
|
|
68
267
|
|
|
69
|
-
|
|
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`.
|
|
274
|
+
|
|
275
|
+
## Simulating failures
|
|
276
|
+
|
|
277
|
+
Every failure mode is just a client, so
|
|
70
278
|
error-handling paths are testable without a server that misbehaves on cue:
|
|
71
279
|
|
|
72
280
|
```ruby
|
|
73
281
|
Failure = GraphWeaver::Testing::Failure
|
|
74
282
|
|
|
75
|
-
PersonQuery.execute(id: "1"
|
|
76
|
-
PersonQuery.execute(
|
|
77
|
-
PersonQuery.execute(id: "1"
|
|
78
|
-
PersonQuery.execute(id: "1"
|
|
79
|
-
PersonQuery.execute(
|
|
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
|
|
286
|
+
PersonQuery.execute(client: Failure.stale_schema, id: "1") # schema_stale? => true
|
|
287
|
+
PersonQuery.execute(client: Failure.graphql("boom"), id: "1") # partial failure
|
|
80
288
|
|
|
81
289
|
# retries: clients run in sequence (the last repeats) — here, two
|
|
82
290
|
# transport failures and then a FakeClient serving good responses
|
|
@@ -87,24 +295,63 @@ GraphWeaver::Testing::Sequence.new(Failure.transport, Failure.transport, fake)
|
|
|
87
295
|
# casting raises GraphWeaver::TypeError (overrides remain the manual escape hatch)
|
|
88
296
|
GraphWeaver::Testing::FakeClient.new(schema:, corrupt: "Person.birthday")
|
|
89
297
|
|
|
90
|
-
# stale schema naming a real (sampled) field
|
|
91
|
-
Failure.stale_schema(schema: MySchema)
|
|
92
|
-
|
|
93
298
|
# field-level partial failure with real GraphQL null propagation: the error
|
|
94
299
|
# lands with its concrete path and nulls bubble to the nearest nullable spot
|
|
95
300
|
GraphWeaver::Testing::FakeClient.new(schema:, fail_at: { path: "person.email", code: "PRIVATE" })
|
|
96
301
|
```
|
|
97
302
|
|
|
98
|
-
|
|
303
|
+
## Capture and replay
|
|
304
|
+
|
|
305
|
+
Cassettes record real API responses and replay
|
|
99
306
|
them offline, above the transport (no HTTP interception):
|
|
100
307
|
|
|
101
308
|
```ruby
|
|
102
309
|
# records against the live client when the file is missing, replays after
|
|
103
|
-
|
|
310
|
+
client = GraphWeaver::Testing.cassette("github", client: live)
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Re-record with `GRAPHWEAVER_RECORD=1`, and set `config.anonymize = true` so
|
|
314
|
+
real data never lands in a committed file — the full workflow guide is
|
|
315
|
+
**[cassettes](cassettes.md)**.
|
|
316
|
+
|
|
317
|
+
## Real resolvers, one schema — `graphql: :in_process`
|
|
318
|
+
|
|
319
|
+
Your actual resolvers, your actual `context`, in the same process — no
|
|
320
|
+
socket, no serialization, and a resolver's real backtrace when it raises.
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
it "hides other people's drafts", graphql: :in_process do
|
|
324
|
+
graphql_context(current_user: alice)
|
|
325
|
+
expect(DraftsQuery.execute!.drafts.map(&:owner)).to all(eq alice.name)
|
|
326
|
+
end
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
The live schema *class* is found for you (a schema dump has no resolvers,
|
|
330
|
+
so it won't do). If two loaded classes match, or none does, it says so and
|
|
331
|
+
asks for `config.schema = MySchema` — and in Rails, remember that an
|
|
332
|
+
autoloaded schema isn't loaded until something references it.
|
|
333
|
+
|
|
334
|
+
## A federated graph — `graphql: :router`
|
|
335
|
+
|
|
336
|
+
Same thing across a federated graph: the tag builds a
|
|
337
|
+
[`Testing::Router`](federation.md#the-local-router), which plans the query
|
|
338
|
+
across your subgraphs and runs it against those **real resolvers** — no
|
|
339
|
+
gateway, no node, no sockets.
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
describe "the dashboard", graphql: :router do
|
|
343
|
+
it "stitches a user's reviews" do
|
|
344
|
+
graphql_context(current_user: user)
|
|
345
|
+
expect(DashboardQuery.execute!.me.reviews.size).to eq 2
|
|
346
|
+
end
|
|
347
|
+
end
|
|
104
348
|
```
|
|
105
349
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
350
|
+
The router is built once for the suite (parsing a supergraph per example
|
|
351
|
+
would be real time) and installed as `GraphWeaver.client` for each; its
|
|
352
|
+
context is reset from `config.context` every time, so an example that runs
|
|
353
|
+
as someone else can't leak into the next.
|
|
110
354
|
|
|
355
|
+
What it plans, what it **refuses** and why, how subgraphs are matched to your
|
|
356
|
+
schema classes, and what to do about a supergraph only partly local:
|
|
357
|
+
**[federation → the local router](federation.md#the-local-router)**.
|
data/docs/transports.md
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
# Transports
|
|
2
2
|
|
|
3
|
-
A *client* is anything with `execute(query, variables:)` whose result
|
|
3
|
+
A *client* is anything with `execute(query, variables:, operation_name:)` whose result
|
|
4
4
|
`to_h`s into `{"data" => ..., "errors" => ...}` — from a full
|
|
5
|
-
`GraphWeaver::Client` down to a schema class
|
|
6
|
-
[
|
|
7
|
-
|
|
5
|
+
`GraphWeaver::Client` down to a schema class
|
|
6
|
+
([in-process execution](getting_started.md#your-apps-own-schema-in-process) —
|
|
7
|
+
typed access to your own app's API, no socket), a [FakeClient](testing.md), or
|
|
8
|
+
anything you write. Every slot that takes a client accepts any of them.
|
|
9
|
+
|
|
10
|
+
**Anything holding a schema parses against it.** `client.parse(query)`, and
|
|
11
|
+
the same on `InProcess`, `FakeClient` and `Testing::Router` — a typed module
|
|
12
|
+
bound to that schema, running on that object, without naming either. It sits
|
|
13
|
+
on top of the contract rather than in it: `Retry` wraps a client and holds no
|
|
14
|
+
schema, so it has no `parse`, and a bare schema class fills the client slot
|
|
15
|
+
without one. `load_queries!` is the same rule over a directory.
|
|
8
16
|
|
|
9
17
|
A *transport* is the network end of that contract — GraphQL-over-HTTP. The bundled
|
|
10
18
|
two — `Transport::HTTP` (net/http, zero dependencies, loaded by default)
|
|
@@ -22,31 +30,31 @@ Most apps need one line:
|
|
|
22
30
|
github = GraphWeaver.new("https://api.example.com/graphql", auth: ENV["API_TOKEN"])
|
|
23
31
|
```
|
|
24
32
|
|
|
25
|
-
`GraphWeaver.new` builds a [`Client`](real_world.md):
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
`GraphWeaver.new` builds a [`Client`](real_world.md): a transport with
|
|
34
|
+
auth applied (exposed as `client.transport`), the schema introspected
|
|
35
|
+
lazily, and `parse`/`run` bound to both. A `Client` answers the client
|
|
36
|
+
contract itself, so it goes anywhere a transport does — `Retry.new(client)`,
|
|
37
|
+
`subgraphs:`, a cassette recorder.
|
|
28
38
|
|
|
29
39
|
- `auth:` — a token; "Bearer" is assumed unless the string carries its own
|
|
30
40
|
scheme (`"Basic dXNlcjpwYXNz..."`)
|
|
41
|
+
- `transport:` — `:http` (the default) or `:faraday`
|
|
31
42
|
- `headers:` — anything else (API keys, custom headers)
|
|
32
43
|
- `retries:` — off by default; `true` for a `Retry` with defaults,
|
|
33
44
|
or a Hash of its options
|
|
45
|
+
- `open_timeout:` / `read_timeout:` — seconds, defaulting to 10 and 30 on
|
|
46
|
+
either transport
|
|
34
47
|
- `cache:` / `ttl:` — schema introspection caching (see
|
|
35
48
|
[real world](real_world.md)); url clients only — a schema source never
|
|
36
49
|
introspects, so passing them raises
|
|
37
50
|
- a block customizes the Faraday connection (Faraday only — raises without it)
|
|
38
51
|
|
|
52
|
+
What you pass is what you get; the client logs which transport it built at
|
|
53
|
+
`info`.
|
|
54
|
+
|
|
39
55
|
To wire generated modules that don't bake a client, make it the app's
|
|
40
56
|
default: `GraphWeaver.client = github`. Anything satisfying the execute
|
|
41
|
-
contract works there — testing's
|
|
42
|
-
|
|
43
|
-
**Transport pick**: `Transport::Faraday` when the app already loads
|
|
44
|
-
faraday (its middleware/proxy/timeout ecosystem comes along), the
|
|
45
|
-
zero-dependency `Transport::HTTP` otherwise. Detection is `defined?(Faraday)` —
|
|
46
|
-
deliberately *not* a require: faraday rides along transitively in most
|
|
47
|
-
bundles (stripe, octokit, ...), and try-requiring would silently switch
|
|
48
|
-
transports on apps that never chose it. With faraday under
|
|
49
|
-
`require: false`, load it before building the client.
|
|
57
|
+
contract works there — testing's `graphql:` tag swaps in a client per example.
|
|
50
58
|
|
|
51
59
|
## Building blocks
|
|
52
60
|
|
|
@@ -54,16 +62,29 @@ The client is convenience, not the only door — construct and assign
|
|
|
54
62
|
yourself for full control:
|
|
55
63
|
|
|
56
64
|
```ruby
|
|
57
|
-
# zero-dependency Net::HTTP — persistent (keep-alive)
|
|
58
|
-
#
|
|
65
|
+
# zero-dependency Net::HTTP — a pool of persistent (keep-alive)
|
|
66
|
+
# connections; timeouts raise retriable TransportError
|
|
59
67
|
GraphWeaver::Transport::HTTP.new(
|
|
60
68
|
url,
|
|
61
69
|
headers: { ... },
|
|
62
70
|
open_timeout: 10, read_timeout: 30, # seconds (the defaults)
|
|
63
71
|
keep_alive_timeout: 2, # idle window before reconnecting
|
|
72
|
+
pool_size: 5, # concurrent requests in flight
|
|
73
|
+
# (default: RAILS_MAX_THREADS, else 5)
|
|
74
|
+
|
|
75
|
+
# TLS, forwarded to Net::HTTP.start — a private CA, or mTLS, without
|
|
76
|
+
# reaching for Faraday. Passing any of these to an http:// url raises
|
|
77
|
+
# rather than quietly doing nothing.
|
|
78
|
+
ca_file: "/etc/ssl/private-ca.pem", # or ca_path: for a directory
|
|
79
|
+
cert: OpenSSL::X509::Certificate.new(File.read("client.crt")),
|
|
80
|
+
key: OpenSSL::PKey::RSA.new(File.read("client.key")),
|
|
81
|
+
verify_mode: OpenSSL::SSL::VERIFY_PEER, # the default; VERIFY_NONE to skip
|
|
64
82
|
)
|
|
65
83
|
|
|
66
|
-
# Faraday: a url (+ optional middleware block), or a ready connection
|
|
84
|
+
# Faraday: a url (+ optional middleware block), or a ready connection.
|
|
85
|
+
# Timeouts default to the same 10/30 as Transport::HTTP — without them
|
|
86
|
+
# Faraday inherits net/http's 60s/60s.
|
|
87
|
+
GraphWeaver::Transport::Faraday.new(url, open_timeout: 10, read_timeout: 30)
|
|
67
88
|
GraphWeaver::Transport::Faraday.new(url) do |conn|
|
|
68
89
|
conn.request :authorization, "Bearer", -> { Tokens.fetch } # dynamic tokens
|
|
69
90
|
conn.response :logger
|
|
@@ -71,23 +92,83 @@ end
|
|
|
71
92
|
GraphWeaver::Transport::Faraday.new(MyApp.faraday_connection)
|
|
72
93
|
|
|
73
94
|
# One Faraday::Connection is reused for the transport's lifetime, but
|
|
74
|
-
# socket keep-alive depends on the ADAPTER
|
|
75
|
-
#
|
|
76
|
-
# (and real pooling), pick a persistent adapter:
|
|
95
|
+
# socket keep-alive depends on the ADAPTER (see below) — the transport
|
|
96
|
+
# logs the one it ended up with at :info:
|
|
77
97
|
GraphWeaver::Transport::Faraday.new(url) do |conn|
|
|
78
|
-
conn.adapter :net_http_persistent
|
|
98
|
+
conn.adapter :net_http_persistent
|
|
79
99
|
end
|
|
80
100
|
|
|
101
|
+
# In-process: a live graphql-ruby schema class, no socket — typed access
|
|
102
|
+
# to your own app's API. The class alone works in any client slot; the
|
|
103
|
+
# wrapper adds a request context, the same debug logging the network
|
|
104
|
+
# transports emit, and errors branded under GraphWeaver::Error (a resolver
|
|
105
|
+
# raise becomes a ServerError, status 500, with the original as #cause).
|
|
106
|
+
GraphWeaver::InProcess.new(MySchema, context: { current_user: user })
|
|
107
|
+
GraphWeaver.new(MySchema, context: { current_user: user }) # same, via a client
|
|
108
|
+
|
|
81
109
|
GraphWeaver.client = ... # the app default (a Client or any of the above)
|
|
82
110
|
```
|
|
83
111
|
|
|
112
|
+
**Keeping Faraday's sockets alive.** Faraday's default `net_http` adapter
|
|
113
|
+
opens a fresh connection per request — 10 TCP connections for 10 requests,
|
|
114
|
+
and over HTTPS a TLS handshake each time. `:net_http_persistent` is the
|
|
115
|
+
adapter that gets Faraday the connection reuse and thread-safe pooling
|
|
116
|
+
`Transport::HTTP` has by default. It needs two gems, and the version
|
|
117
|
+
pairing matters — **Faraday 2.x requires `faraday-net_http_persistent`
|
|
118
|
+
2.x**; the Faraday-1.x-era 1.2.0 raises `NoMethodError: undefined method
|
|
119
|
+
'dependency' for class Faraday::Adapter::NetHttpPersistent` at load:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
gem "net-http-persistent" # the HTTP client
|
|
123
|
+
gem "faraday-net_http_persistent", "~> 2.0" # the Faraday adapter for it
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
graph_weaver depends on neither and never selects an adapter for you.
|
|
127
|
+
|
|
128
|
+
**Headers.** Both transports send `Content-Type: application/json`,
|
|
129
|
+
`Accept: application/graphql-response+json, application/json;q=0.9` (the
|
|
130
|
+
media type [GraphQL-over-HTTP](https://graphql.github.io/graphql-over-http/draft/)
|
|
131
|
+
requires a conforming client to accept, with the legacy type as
|
|
132
|
+
fallback), and `User-Agent: graph_weaver/<version>` so a server operator
|
|
133
|
+
can attribute the traffic. Anything you pass in `headers:` wins over
|
|
134
|
+
these. A prebuilt `Faraday::Connection` owns its own headers; only the
|
|
135
|
+
ones it leaves unset are filled in.
|
|
136
|
+
|
|
137
|
+
**Request body.** `{"query": ..., "variables": ...}`, plus
|
|
138
|
+
`"operationName"` when the operation has a name — the field Apollo Studio,
|
|
139
|
+
Hasura and most APMs key traces, rate limits and slow-query reports on.
|
|
140
|
+
Generated modules always send one — an anonymous document is named after its
|
|
141
|
+
module at generation, so the name is declared in the query too. A raw query
|
|
142
|
+
string handed straight to a transport falls back to the name in the document,
|
|
143
|
+
and a genuinely anonymous one sends no `operationName` key at all.
|
|
144
|
+
|
|
145
|
+
**Concurrency.** One transport is normally the whole app's transport
|
|
146
|
+
(`GraphWeaver.client = api`), so it has to serve every thread.
|
|
147
|
+
`Transport::HTTP` opens up to `pool_size:` sockets lazily and reuses the
|
|
148
|
+
warmest one; requests beyond that queue for a free slot rather than
|
|
149
|
+
opening unbounded connections. A socket that errors is closed and its slot
|
|
150
|
+
left empty, so the next call reconnects.
|
|
151
|
+
|
|
152
|
+
`pool_size:` defaults to `RAILS_MAX_THREADS` (else 5) — the same variable
|
|
153
|
+
Rails sizes its own connection pool from, because it is the same question:
|
|
154
|
+
how many requests this process can have in flight at once. Lower it for a
|
|
155
|
+
server that counts connections.
|
|
156
|
+
|
|
157
|
+
Under a fiber scheduler (`async`, Falcon) everything here works unchanged —
|
|
158
|
+
`SizedQueue`, `Mutex`, `net/http` and `Kernel#sleep` are all scheduler-aware,
|
|
159
|
+
so requests multiplex on one thread at thread-equivalent throughput. But
|
|
160
|
+
`pool_size:` is the same hard ceiling there, and nothing sets
|
|
161
|
+
`RAILS_MAX_THREADS` for you, so set it to the concurrency you expect.
|
|
162
|
+
Saturation is not silent: the first request that has to queue logs a warning
|
|
163
|
+
naming how long it waited and what to raise.
|
|
164
|
+
|
|
84
165
|
## Client resolution
|
|
85
166
|
|
|
86
167
|
The canonical order — how a generated module finds its client (each slot
|
|
87
168
|
takes a `Client` or any bare transport/fake):
|
|
88
169
|
|
|
89
|
-
1. per call: `execute(some_client, ...)` —
|
|
90
|
-
|
|
170
|
+
1. per call: `execute(client: some_client, ...)` — a kwarg like the
|
|
171
|
+
variables, and a name no GraphQL variable is allowed to take
|
|
91
172
|
2. per module: `MyQuery.client = something`
|
|
92
173
|
3. baked constant: `Codegen.generate(..., client: MyApi::CLIENT)`
|
|
93
174
|
4. the app default: `GraphWeaver.client=`
|
|
@@ -112,11 +193,29 @@ GraphWeaver::Retry.new(
|
|
|
112
193
|
```
|
|
113
194
|
|
|
114
195
|
Defaults: transport failures always retry (the request never arrived);
|
|
115
|
-
`ServerError`
|
|
116
|
-
fix it. `retry_codes:` re-inspects response
|
|
117
|
-
throttling can retry too (off by default —
|
|
118
|
-
Exhausting `tries:` re-raises the last
|
|
119
|
-
code-matched response).
|
|
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).
|
|
201
|
+
|
|
202
|
+
**`Retry-After` wins over the backoff.** When the server names a delay
|
|
203
|
+
(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
|
|
205
|
+
"come back in an hour" can't park a thread for an hour, and not
|
|
206
|
+
jittered, since it's an instruction rather than a guess.
|
|
207
|
+
|
|
208
|
+
`ServerError` carries the response `#headers` (names downcased), so the
|
|
209
|
+
rate-limit budget and request id are in hand without monkey-patching a
|
|
210
|
+
transport:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
rescue GraphWeaver::ServerError => e
|
|
214
|
+
e.throttled? # 429, or 503 + Retry-After
|
|
215
|
+
e.retry_after # seconds, or nil
|
|
216
|
+
e.headers["x-ratelimit-remaining"]
|
|
217
|
+
end
|
|
218
|
+
```
|
|
120
219
|
|
|
121
220
|
Or via the client: `GraphWeaver.new(url, retries: { tries: 5, retry_codes: ["THROTTLED"] })`.
|
|
122
221
|
|