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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1314 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -154
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -258
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -124
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +593 -334
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +9 -1
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +14 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +7 -0
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +794 -59
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +43 -8
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +160 -61
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +181 -21
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +28 -10
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +67 -14
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +389 -170
  62. metadata +20 -3
data/docs/testing.md CHANGED
@@ -1,22 +1,162 @@
1
1
  # Testing
2
2
 
3
- Everything here is a *client* — the one interface queries run
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
- `require "graph_weaver/rspec"` from your spec helper (or
10
- `graph_weaver/testing` outside rspec — never in production) for a
11
- zero-setup fake backend. `FakeClient` fabricates
12
- schema-correct responses for whatever query arrives: real enum values,
13
- valid `__typename` members, iso8601 date scalars every fake casts
14
- cleanly through your generated structs.
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
- fake = GraphWeaver::Testing::FakeClient.new(schema:)
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
- person = PersonQuery.execute!(fake, id: "1").person
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
- With rspec, the setup is two lines in `spec/support/graph_weaver.rb`
35
- the require, plus an explicit opt-in to per-example fakes (deliberately
36
- not a default: silently swapping every example onto a fake would be
37
- surprising). The schema auto-locates from the committed dump at
38
- `GraphWeaver.schema_path`:
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
- require "graph_weaver/rspec" # seed follows --seed
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
- GraphWeaver::Testing.configure do |config|
44
- config.auto_fake = true # every example runs against a fresh fake
45
- # config.schema = MySchema # optional: an in-process class instead of the dump
46
- # config.mode = :faker # or :literal (plain typed values); nil = auto
47
- # config.overrides = { "Person.name" => "Daniel" }
48
- # config.list_size = 1..3
49
- # config.null_chance = 0.1 # nullable fields go nil sometimes
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
- With the rspec integration, `rspec --seed 1234` reproduces fake data
54
- along with test order, and `auto_fake` installs a seeded fake as the
55
- app client per example (generate modules *without* a baked `client:` so
56
- they consult `GraphWeaver.client`). `mode:` picks value fabrication: `:faker`
57
- (semantic, field-name matched — raises if the gem is missing),
58
- `:literal` (plain type-derived), or nil to auto-detect faker.
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 queries don't have to live in `app/` — the conventional paths
61
- are appendable lists, so the same support file can register a
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
- GraphWeaver.generated_paths << "spec/support/graphql/generated"
66
- GraphWeaver.queries_paths << "spec/support/graphql/queries"
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
- **Simulating failures** every failure mode is just a client, so
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", client: Failure.transport) # TransportError (cause preserved)
76
- PersonQuery.execute(id: "1", client: Failure.server(status: 502)) # ServerError
77
- PersonQuery.execute(id: "1", client: Failure.throttled) # QueryError, code THROTTLED
78
- PersonQuery.execute(id: "1", client: Failure.stale_schema) # schema_stale? => true
79
- PersonQuery.execute(id: "1", client: Failure.graphql("boom", data: {...})) # partial failure
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
- **Capture and replay** — cassettes record real API responses and replay
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
- cassette = GraphWeaver::Testing::Cassette.use("github", client: live)
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
- Re-record with `GRAPHWEAVER_RECORD=1`, anonymize before committing
107
- (`config.anonymize = true` scrubs as recordings happen, or
108
- `rake graph_weaver:cassettes:anonymize` after) the full workflow guide
109
- is **[cassettes](cassettes.md)**.
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 (in-process execution), a
6
- [FakeClient](testing.md), or anything you write. Every slot that takes a
7
- client accepts any of them.
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): the best transport
26
- with auth applied (exposed as `client.transport`), the schema
27
- introspected lazily, and `parse`/`execute` bound to both.
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 auto_fake swaps in a fake per example.
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) connection,
58
- # mutex-serialized; timeouts raise retriable TransportError
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: Faraday's default net_http
75
- # adapter opens a fresh connection per request. For persistent sockets
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 # gem "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, ...)` — the optional first positional
90
- argument, so variables keep the entire kwarg namespace
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` only on 5xx — a 4xx is a bug in the request, retrying won't
116
- fix it. `retry_codes:` re-inspects response envelopes so GraphQL-level
117
- throttling can retry too (off by default — pass the codes your API uses).
118
- Exhausting `tries:` re-raises the last error (or returns 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