graph_weaver 0.6.1 → 0.7.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +40 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +474 -106
  70. metadata +56 -1
@@ -6,7 +6,11 @@ Follow it once when you add the gem to an app. (Exploring an API from a console
6
6
  instead? Start with [dynamic mode](real_world.md) — no build step.)
7
7
 
8
8
  Rails is assumed below; the [non-Rails note](#not-rails) at the bottom
9
- covers the differences.
9
+ covers the differences. Still deciding whether to adopt at all?
10
+ [Alternatives](alternatives.md) compares the field, this gem included.
11
+ No Sorbet in your app? None needed: `sorbet-runtime` comes with the gem and the
12
+ generated code checks itself at runtime — running `srb tc` on top is your call,
13
+ and [Sorbet, with or without](#sorbet-with-or-without) says what that buys.
10
14
 
11
15
  ## 1. Install
12
16
 
@@ -27,6 +31,7 @@ rails g graph_weaver:install https://api.example.com/graphql
27
31
  create app/graphql/fragments/.keep
28
32
  create app/graphql/generated/.keep
29
33
  create graphql.config.yml
34
+ insert spec/rails_helper.rb
30
35
  introspect app/graphql/schema.json from https://api.example.com/graphql
31
36
  ```
32
37
 
@@ -42,11 +47,18 @@ initializer that fits:
42
47
 
43
48
  | flag | |
44
49
  |---|---|
45
- | `--auth` | name of the ENV var holding the auth token — default `GRAPHWEAVER_AUTH`. Url only. The name is recorded into the dump, so `schema:refresh`/`schema:diff`/`queries:check` read the same one the initializer does |
46
- | `--no-schema` | skip writing the dump; `rake graph_weaver:schema:refresh URL=...` does it later |
50
+ | `--auth` | name of the ENV var holding the auth token — default `GRAPHWEAVER_AUTH`. Url only, and omitted entirely for a public API that needs no token. The name is recorded into the dump, so `schema:refresh`/`schema:diff`/`queries:check` read the same one the initializer does |
51
+ | `--no-schema` | skip writing the dump; `rake graph_weaver:schema:refresh` does it later (`URL=...` to name an endpoint the first time) |
47
52
 
48
- Re-running is safe — every file goes through the usual Rails conflict
49
- prompt, so an initializer you've edited is never overwritten silently.
53
+ Re-running is safe — every file it writes goes through the usual Rails
54
+ conflict prompt, so an initializer you've edited is never overwritten
55
+ silently. The schema dump is the one file that isn't Thor's to diff, so it is
56
+ **kept** rather than prompted for — replacing it would drop the source url it
57
+ records, which `schema:diff`/`:refresh` read:
58
+
59
+ ```
60
+ keep app/graphql/schema.json — delete it and re-run to re-introspect
61
+ ```
50
62
 
51
63
  What it wrote:
52
64
 
@@ -55,15 +67,20 @@ What it wrote:
55
67
  it at execute time (the full
56
68
  [resolution order](transports.md#client-resolution)). Custom
57
69
  scalars/enums/type helpers register here too — the rake tasks bake them
58
- into generated source, so they have to run first:
70
+ into generated source, so they have to run first ([scalars](scalars.md)):
59
71
 
60
72
  ```ruby
61
73
  GraphWeaver.register_scalar("Money", Money) # a scalar the registry can't know
62
74
  ```
63
75
 
76
+ Every setting also takes the block form the file's neighbours use —
77
+ `GraphWeaver.configure { |config| … }`, where `config` is `GraphWeaver`
78
+ itself, so the two spellings are one call.
79
+
64
80
  A registration that names one of your own constants — a `T::Enum` for
65
- `register_enum`, a mixin for `extend_type` — goes in a `to_prepare` block,
66
- the same place the in-process client goes and for the same reason:
81
+ `register_enum`, a mixin for [`extend_type`](generated_modules.md#type-helpers)
82
+ — goes in a `to_prepare` block, the same place the in-process client goes and
83
+ for the same reason:
67
84
  autoloading is set up after `config/initializers` run. Generation depends on
68
85
  `:environment`, which runs `to_prepare` too, so the registration is in place
69
86
  before it emits.
@@ -75,6 +92,12 @@ What it wrote:
75
92
  end
76
93
  ```
77
94
 
95
+ A custom `GraphQL::Schema::Validator` ([the `extensions.input`
96
+ recipe](errors.md#what-your-server-can-send)) is installed by *symbol*, so
97
+ nothing references its constant and Zeitwerk never autoloads it — name it in
98
+ the same `to_prepare` block, above the schema, or the schema raises
99
+ `unknown validation: :your_rule` on whichever file boots first.
100
+
78
101
  - **`app/graphql/schema.json`.** The schema dump codegen reads
79
102
  (`GraphWeaver.schema_path`) — never written by hand, always committed.
80
103
  `cache: true` in the initializer reuses it; delete the file to
@@ -88,6 +111,12 @@ What it wrote:
88
111
  - **`app/graphql/queries/`, `app/graphql/fragments/`, `app/graphql/generated/`.**
89
112
  Where you write queries, where shared fragments live, and where generation
90
113
  writes Ruby.
114
+ - **`.rubocop.yml`**, if you have one. Generated code is machine-written and
115
+ marked "do not edit," so the output directory is added to `AllCops: Exclude:`
116
+ — otherwise `Style/Documentation`, `Style/ClassAndModuleChildren` and
117
+ `Metrics/*` fire on every generated file. An `AllCops:` you already have is
118
+ left alone (a second one would replace it, not merge); the generator prints
119
+ the line to add.
91
120
 
92
121
  Rake needs no wiring either: in Rails the `graph_weaver:*` tasks register
93
122
  themselves (a Railtie) and depend on `:environment`, so your initializer —
@@ -95,79 +124,9 @@ and its registrations — runs first. The generated modules load at boot from
95
124
  a `to_prepare` block, so a helper or enum you registered in one is already
96
125
  in place when the file that names it loads.
97
126
 
98
- ### Your app's own schema, in-process
99
-
100
- An app that *serves* GraphQL with graphql-ruby can have the same typed
101
- access to its own API — same generated structs, no socket, no HTTP:
102
-
103
- ```sh
104
- rails g graph_weaver:install MyApp::Schema
105
- ```
106
-
107
- ```ruby
108
- # config/initializers/graph_weaver.rb
109
- Rails.application.config.to_prepare do
110
- # queries run in-process against the app's own schema — no socket
111
- GraphWeaver.client = GraphWeaver.new(MyApp::Schema)
112
- end
113
- ```
114
-
115
- `to_prepare`, not a bare assignment: the schema class is autoloaded, so it
116
- isn't resolvable while initializers run, and a dev reload replaces it with
117
- a new class object that a captured one would go stale against.
118
-
119
- **Context is per request, not per app.** A resolver reading
120
- `context[:current_user]` gets nil from the app default — build a client
121
- where you know the request and pass it per call:
122
-
123
- ```ruby
124
- client = GraphWeaver.new(MyApp::Schema, context: { current_user: })
125
- PetQuery.execute!(client:, id: "1").pet.owner # => the context's user
126
- ```
127
-
128
- Each query gets its own copy of that hash, so a resolver writing
129
- `context[:loader] =` can't hand what it wrote to the next request — which
130
- matters because one in-process client is normally the whole app's.
131
-
132
- **Keep the dump in step with the schema.** Codegen reads the committed
133
- dump at `GraphWeaver.schema_path`, never the live class — that's what
134
- makes `rake graph_weaver:verify` a deterministic CI check. The generator
135
- writes the first dump; after that it's an artifact derived from code in
136
- your own repo, so rebuild it with graphql-ruby's own rake task, ahead of
137
- `verify` in CI:
138
-
139
- ```ruby
140
- # lib/tasks/graphql.rake
141
- require "graphql/rake_task"
142
- GraphQL::RakeTask.new(schema_name: "MyApp::Schema", directory: "app/graphql",
143
- dependencies: [:environment])
144
- ```
145
-
146
- ```sh
147
- rake graphql:schema:json # rewrites app/graphql/schema.json
148
- rake graph_weaver:generate
149
- ```
150
-
151
- A stale dump makes `verify` fail on a query that is fine. `queries:check` is
152
- unaffected: running in-process it validates against the live class, not the
153
- dump. (`schema:diff` and `:refresh` are for servers you *don't* own; a dump
154
- taken from a schema class records no url, and they say so.)
155
-
156
- **Scaffolding the app too?** On a `rails new --skip-active-record`,
157
- `rails g graphql:install` writes `config.active_record.query_log_tags` lines
158
- into `config/application.rb` that an app without ActiveRecord can't boot
159
- with — a graphql-ruby bug. Run it as
160
- `rails g graphql:install --skip-query-logs`, or delete the lines it wrote.
161
-
162
- ### A schema dump you already have
163
-
164
- ```sh
165
- rails g graph_weaver:install db/schema.graphql
166
- ```
167
-
168
- Sets `GraphWeaver.schema_path` to that file rather than writing a second
169
- copy, and introspects nothing. A dump has no resolvers, so it can't
170
- execute — set `GraphWeaver.client` to whatever serves the API.
127
+ Everything above describes **one** schema, which is the usual case. An app
128
+ with a second one declares each as a graph — [more than one
129
+ schema](#more-than-one-schema), at the end; skip it until you have two.
171
130
 
172
131
  ## 3. Write a query, generate, commit
173
132
 
@@ -190,13 +149,18 @@ PersonQuery.execute!(id: "1").person&.name # typed, via GraphWeaver.client
190
149
  ```
191
150
 
192
151
  Commit the schema dump and the generated files. Generated code is reviewed like
193
- any other code — and never edited by hand. The module name comes from the file
194
- name; the full set of naming rules is in
152
+ any other code — and never edited by hand. One rule covers the names: the file
153
+ name becomes the module (`person.graphql` `PersonQuery`), and every selection
154
+ inside it becomes a struct named for its **response key**, not its schema type
155
+ — a `countries { … }` selection is `CountriesQuery::Result::Countries` even
156
+ where the schema calls the type `Country`. The corners are in
195
157
  [generated modules](generated_modules.md#naming).
196
158
 
197
- `graphql.config.yml` is already there, so VS Code and RubyMine validate the
198
- `.graphql` files as you type, with schema autocomplete and hover docs see
199
- [editors](editors.md).
159
+ **Not sure what the API offers?** `app/graphql/schema.json` is the whole schema
160
+ as plain JSON types, fields, descriptions already in your repo. And
161
+ `graphql.config.yml` is there too, so VS Code and RubyMine validate the
162
+ `.graphql` files as you type, with autocomplete and hover docs off that same
163
+ dump — see [editors](editors.md).
200
164
 
201
165
  **In development you don't type that command again.** While the server is
202
166
  running, a `.graphql` edit — or a refreshed schema dump — regenerates before
@@ -233,21 +197,38 @@ works everywhere. See
233
197
 
234
198
  ## 4. Test against fakes
235
199
 
200
+ The generator put `require "graph_weaver/rspec"` in your `spec/rails_helper.rb`
201
+ — add it there yourself if rspec arrived after the install, since the tags
202
+ below do nothing without it.
203
+
236
204
  ```ruby
237
- # spec/support/graph_weaver.rb
238
- require "graph_weaver/rspec"
205
+ it "renders the empty state", graphql: :fake do … end # or tag the describe
239
206
  ```
240
207
 
208
+ The tag installs a seeded, schema-correct `FakeClient` for that example — and
209
+ **nothing leaves the process**: no server, no HTTP at all, so no webmock and
210
+ no VCR. `rspec --seed 1234` reproduces the fake data along with test order.
211
+ The schema it fabricates from is derived (the committed dump, or your
212
+ client's), so there is nothing to configure. Tag `graphql: :in_process`
213
+ instead and the same example runs against your real resolvers.
214
+
215
+ A request spec is the usual shape: tag it, pin the value the assertion is
216
+ about, and everything else in the selection is still fabricated.
217
+
241
218
  ```ruby
242
- it "renders the empty state", graphql: :fake do … end # or tag the describe
219
+ # spec/requests/people_spec.rb
220
+ RSpec.describe "People", type: :request do
221
+ it "lists people", graphql: :fake do
222
+ graphql_fake("Person" => { "name" => "Ada Lovelace" })
223
+
224
+ get "/people"
225
+
226
+ expect(response.body).to include("Ada Lovelace")
227
+ end
228
+ end
243
229
  ```
244
230
 
245
- The tag installs a seeded, schema-correct `FakeClient` for that example — no
246
- server, no stubs, and `rspec --seed 1234` reproduces the fake data along with
247
- test order. The schema it fabricates from is derived (the committed dump, or
248
- your client's), so there's nothing to configure. Tag `graphql: :in_process`
249
- instead and the same example runs against your real resolvers. Pinning values,
250
- simulating failures, and the federated `graphql: :router` are in
231
+ Pins, simulating failures, and the federated `graphql: :router` are in
251
232
  [testing](testing.md).
252
233
 
253
234
  A fresh `rails g rspec:install` leaves the `spec/support` glob commented
@@ -257,23 +238,97 @@ unread.
257
238
 
258
239
  ## 5. Verify in CI
259
240
 
260
- Four questions, four tasks — the last only on a federated graph:
241
+ Five questions, five tasks — the last only on a federated graph:
261
242
 
262
243
  | ask | task | needs network |
263
244
  |---|---|---|
264
245
  | is the checked-in Ruby fresh? | `rake graph_weaver:verify` | no |
265
246
  | has the server's schema drifted from the dump? | `rake graph_weaver:schema:diff` | yes |
266
247
  | did that drift break any of my queries? | `rake graph_weaver:queries:check` | yes |
248
+ | does the app still read what it selects? | `rake graph_weaver:unused` | no |
267
249
  | did a subgraph change without a recompose? | `rake graph_weaver:federation:diff` | no |
268
250
 
251
+ (`rake graph_weaver:graphs` answers a sixth, when an app has more than one
252
+ schema: which graphs are configured, where each generates, and what each
253
+ registers — the one place those registrations are listed, since a registration
254
+ belongs to one graph.)
255
+
269
256
  `verify` compares the committed generated files against what the current
270
257
  schema + queries + registrations would produce, so it belongs in every CI
271
- build. `schema:diff` needs a dump with a recorded source url (introspected
272
- dumps have one) and `GRAPHWEAVER_AUTH` for private APIs run it on a
273
- schedule and repair with `rake graph_weaver:schema:refresh`.
274
- `federation:diff` needs no network either, so it goes in the same PR run;
258
+ build. `schema:diff` asks whatever the dump came from — a recorded source url
259
+ (with `GRAPHWEAVER_AUTH` for private APIs), or your own schema class when the
260
+ app [serves the schema itself](#your-apps-own-schema-in-process) — and
261
+ `rake graph_weaver:schema:refresh` is the repair either way.
262
+ `federation:diff` needs no network, so it goes in the same PR run;
275
263
  see [federation](federation.md#has-the-supergraph-been-recomposed).
276
264
 
265
+ Every one of them exits non-zero on a finding, so the gate is a chain. The two
266
+ topologies differ only in what reaches a network:
267
+
268
+ ```sh
269
+ # an API you don't own — the dump records the url it was introspected from
270
+ bundle exec rake graph_weaver:verify # offline checks first, so a
271
+ bundle exec rake graph_weaver:cassettes:check # network blip can't mask one
272
+ bundle exec rake graph_weaver:queries:check # re-introspects the recorded url
273
+ bundle exec rake graph_weaver:schema:diff # …so does this one
274
+ ```
275
+
276
+ ```sh
277
+ # your own graphql-ruby schema, in-process — none of this touches a network
278
+ bundle exec rake graph_weaver:schema:diff # has the class moved past the dump?
279
+ bundle exec rake graph_weaver:queries:check # do the queries still validate?
280
+ bundle exec rake graph_weaver:verify # is the checked-in Ruby current?
281
+ bundle exec rake graph_weaver:cassettes:check # do the recordings still cast?
282
+ ```
283
+
284
+ In-process the order is the repair order: refresh the dump, regenerate,
285
+ re-record. As a GitHub Actions job:
286
+
287
+ ```yaml
288
+ # .github/workflows/graphql.yml
289
+ name: graphql
290
+ on: [push]
291
+ jobs:
292
+ graph_weaver:
293
+ runs-on: ubuntu-latest
294
+ steps:
295
+ - uses: actions/checkout@v4
296
+ - uses: ruby/setup-ruby@v1
297
+ with:
298
+ bundler-cache: true
299
+ - run: bundle exec rake graph_weaver:verify
300
+ - run: bundle exec rake graph_weaver:cassettes:check
301
+ - run: bundle exec rake graph_weaver:queries:check
302
+ - run: bundle exec rake graph_weaver:schema:diff
303
+ env:
304
+ GRAPHWEAVER_AUTH: ${{ secrets.GRAPHWEAVER_AUTH }}
305
+ # federated apps only — needs no network, so it runs beside the rest
306
+ - run: bundle exec rake graph_weaver:federation:diff
307
+ ```
308
+
309
+ An in-process schema needs no `env:` — it answers introspection itself.
310
+
311
+ **On a federated graph, none of the five looks at the schema production is
312
+ serving.** `verify`, `queries:check`, `unused` and `federation:diff` compare the
313
+ app to artifacts checked in beside it, and `schema:diff` reads a live source
314
+ only where the dump records one — a composed supergraph doesn't, because no
315
+ endpoint serves it. Hot-reload a router onto a supergraph that dropped a field
316
+ your queries select and all five still exit 0 while every one of those requests
317
+ fails; the first detector is a user's failed request, answered by the runtime
318
+ [drift message](errors.md#stale-schemas). The pre-deploy check against the
319
+ live graph is Apollo's, not this gem's — run it in the same job:
320
+
321
+ ```sh
322
+ rover subgraph check my-graph@prod --name products --schema products.graphql
323
+ rover supergraph fetch my-graph@prod # then recompose and diff what you get back
324
+ ```
325
+
326
+ `rover subgraph check` asks GraphOS whether publishing this subgraph would break
327
+ the composition or a client's registered operations; `rover supergraph fetch`
328
+ hands you what the router is actually running, which is the artifact
329
+ `federation:diff` should have been pointed at all along. See
330
+ [federation → in CI](federation.md#in-ci).
331
+
277
332
  `schema:diff` names what moved, breaking changes first — breaking meaning
278
333
  a query written against your dump stops validating, or stops casting:
279
334
 
@@ -340,6 +395,317 @@ Left off, it re-introspects the url the dump records — and when that dump is a
340
395
  composed supergraph, each error also names the subgraphs behind the type it
341
396
  points at ([federation](federation.md#the-routing-table)).
342
397
 
398
+ ### The selections nothing reads
399
+
400
+ `rake graph_weaver:unused` asks the one question the others can't: not "is the
401
+ Ruby fresh" but "does the app still use what the query asks for". A field
402
+ someone stopped rendering stays in the `.graphql` forever — the query keeps
403
+ validating, the struct keeps casting, and the server keeps paying to resolve
404
+ it. graphql-client catches that at runtime by masking the data a caller didn't
405
+ declare; the structs are checked in here, so it can be recovered without
406
+ running anything:
407
+
408
+ ```
409
+ app/graphql/queries/products.graphql: Products.sku — selected, never read (Catalog::ProductsQuery::Result::Products#sku)
410
+ app/graphql/queries/products.graphql: Products.blurb — selected, never read (Catalog::ProductsQuery::Result::Products#blurb)
411
+
412
+ 13 selections, 2 unread — 2 queries, 58 files swept under .
413
+ ```
414
+
415
+ Each line names the query file, the selection to go and delete, and the
416
+ generated prop behind it. It reads the generated structs for the props a
417
+ query produced, then sweeps your `.rb`, `.rake`, `.builder`, `.erb`, `.slim`,
418
+ `.haml` and `.jbuilder` **once** for every name they could be read by —
419
+ `.sku`, `sku:`, `:sku`, `"sku"`. `PATHS=app,lib` narrows the sweep, and a
420
+ `PATHS=` naming a directory that isn't there is refused rather than swept as
421
+ nothing. Everything under a directory named `generated`, plus `vendor`,
422
+ `node_modules`, `tmp` and `log`, is skipped either way, as is any file
423
+ defining a graphql-ruby **type** — `< GraphQL::Schema::Object` or the
424
+ `< Types::BaseObject` the generator writes — since a `field :sku` there is
425
+ your *server* offering a field, not this app reading one back. A `Resolver` or
426
+ a `Mutation` is swept like any other code: that is where a BFF reads the graph
427
+ it consumes. Nothing is edited and the exit is 0; `STRICT=1` exits 1 when
428
+ anything is unread, for teams who want the gate.
429
+
430
+ A line handing a query module straight to a serializer — `render json:`,
431
+ `to_h`, `to_json`, `as_json`, `serialize`, `deconstruct_keys` — reads every
432
+ prop at once, so that module is excused and the line is quoted, because
433
+ matching a serializer by name is the softest thing here and a wrong excuse
434
+ should be obvious:
435
+
436
+ ```
437
+ Accounts::MeQuery: every prop counted as read — handed whole to a serializer at app/controllers/accounts_controller.rb:3
438
+ render json: Accounts::MeQuery.execute!.me
439
+ ```
440
+
441
+ A local counts too, which is what makes the ordinary two-line controller work
442
+ — `result = Accounts::MeQuery.execute!` on one line, `render json: result.me`
443
+ on the next — and the excuse names the local it followed.
444
+
445
+ **It is a lint, not a proof**, and the task's own footer says so. It matches
446
+ names as text, so a prop called `name` counts as read the moment anything at
447
+ all says `.name`; and it can't see a prop reached by `public_send` or a read
448
+ in a file type it doesn't sweep. The example above is the best case, not the
449
+ typical one: measured against real corpora, **half to two thirds of genuinely
450
+ unread selections go unreported**, the share rising with the size of the app,
451
+ because common prop names collide with ordinary words somewhere in it.
452
+ Silence is the safe direction here. Treat a finding as a prompt to go and
453
+ look, and a clean run as nothing more than the absence of an obvious one —
454
+ which is why it exits 0 unless you ask it not to.
455
+
456
+ ## Your app's own schema, in-process
457
+
458
+ An app that *serves* GraphQL with graphql-ruby can have the same typed
459
+ access to its own API — same generated structs, no socket, no HTTP:
460
+
461
+ ```sh
462
+ rails g graph_weaver:install MyApp::Schema
463
+ ```
464
+
465
+ ```ruby
466
+ # config/initializers/graph_weaver.rb
467
+ Rails.application.config.to_prepare do
468
+ # queries run in-process against the app's own schema — no socket
469
+ GraphWeaver.client = GraphWeaver.new(MyApp::Schema)
470
+ end
471
+ ```
472
+
473
+ `to_prepare`, not a bare assignment: the schema class is autoloaded, so it
474
+ isn't resolvable while initializers run, and a dev reload replaces it with
475
+ a new class object that a captured one would go stale against.
476
+
477
+ **Context is per request, not per app.** A resolver reading
478
+ `context[:current_user]` gets nil from the app default — build a client
479
+ where you know the request and pass it per call:
480
+
481
+ ```ruby
482
+ client = GraphWeaver.new(MyApp::Schema, context: { current_user: })
483
+ PetQuery.execute!(client:, id: "1").pet.owner # => the context's user
484
+ ```
485
+
486
+ Each query gets its own copy of that hash, so a resolver writing
487
+ `context[:loader] =` can't hand what it wrote to the next request — which
488
+ matters because one in-process client is normally the whole app's.
489
+
490
+ **Keep the dump in step with the schema.** Codegen reads the committed
491
+ dump at `GraphWeaver.schema_path`, never the live class — that's what
492
+ makes `rake graph_weaver:verify` a deterministic CI check. The generator
493
+ writes the first dump; after that it's an artifact derived from code in
494
+ your own repo, and the same two tasks a remote schema uses keep it in step:
495
+
496
+ ```sh
497
+ rake graph_weaver:schema:diff # what has the class changed since the dump?
498
+ rake graph_weaver:schema:refresh # rewrite the dump from the class
499
+ rake graph_weaver:generate
500
+ ```
501
+
502
+ Neither touches a network here — your schema class answers introspection
503
+ itself — and `refresh` rewrites the dump in whatever format it already is, so
504
+ a repo that chose `cache: :graphql` keeps SDL. graphql-ruby's own
505
+ `GraphQL::RakeTask` writes the same artifact, but you don't need to wire it
506
+ up: `schema:refresh` writes to the path graph_weaver already reads, and is
507
+ what `schema:diff` and a runtime "the schema may have changed" error both
508
+ name.
509
+
510
+ `verify` **fails** when the dump has fallen behind the class, rather than
511
+ calling the tree up to date: generating from a dump that old would produce
512
+ Ruby for a schema your resolvers have already left. `queries:check` is
513
+ unaffected either way — running in-process it validates against the live
514
+ class, not the dump — so the two can disagree about the same query while the
515
+ dump is stale, and `schema:refresh` is what settles it.
516
+
517
+ **Scaffolding the app too?** On a `rails new --skip-active-record`,
518
+ `rails g graphql:install` writes `config.active_record.query_log_tags` lines
519
+ into `config/application.rb` that an app without ActiveRecord can't boot
520
+ with — a graphql-ruby bug. Run it as
521
+ `rails g graphql:install --skip-query-logs`, or delete the lines it wrote.
522
+
523
+ ## A schema dump you already have
524
+
525
+ ```sh
526
+ rails g graph_weaver:install db/schema.graphql
527
+ ```
528
+
529
+ Sets `GraphWeaver.schema_path` to that file rather than writing a second
530
+ copy, and introspects nothing. A dump has no resolvers, so it can't
531
+ execute — set `GraphWeaver.client` to whatever serves the API.
532
+
533
+ ## More than one schema
534
+
535
+ The five steps above describe one graph — a schema, its queries, its output.
536
+ An app with a second schema declares each one:
537
+
538
+ ```ruby
539
+ # config/initializers/graph_weaver.rb
540
+ GraphWeaver.graph :billing do
541
+ schema -> { Billing::Schema }
542
+ queries "app/graphql/billing/queries"
543
+ output "app/graphql/billing/generated"
544
+ client "Billing::Schema"
545
+ namespace "Billing"
546
+ register_scalar "Money", BigDecimal
547
+ end
548
+
549
+ GraphWeaver.graph :github do
550
+ schema "db/github.json"
551
+ queries "app/graphql/github/queries"
552
+ output "app/graphql/github/generated"
553
+ client "GITHUB"
554
+ namespace "GitHub"
555
+ end
556
+ ```
557
+
558
+ **Declaring graph two means declaring graph one.** A declared graph *replaces*
559
+ the implicit one the top-level settings describe, so the moment any graph is
560
+ named, the `app/graphql/queries` and `app/graphql/generated` an existing app was
561
+ already using belong to no graph — nothing generates or prunes them, and
562
+ `generate` refuses the whole app rather than leave them there silently. Wrap
563
+ them in a graph of their own, its directories and nothing else:
564
+
565
+ ```ruby
566
+ GraphWeaver.graph :app do
567
+ queries "app/graphql/queries"
568
+ output "app/graphql/generated"
569
+ end
570
+ ```
571
+
572
+ No `schema`, so it keeps reading the one `schema_path` names; no `namespace`,
573
+ so every constant keeps the name it has. Nothing about the existing queries or
574
+ generated files changes.
575
+
576
+ One `rake graph_weaver:generate` does the app, one `rake graph_weaver:verify`
577
+ gates it, and `rake graph_weaver:graphs` lists what is configured. Everything a
578
+ graph knows is said inside the block — six settings, and the same three
579
+ registrations you write at the top level. Each setting falls back to the
580
+ matching top-level one, so a graph says only what differs, and anything else
581
+ the block calls is refused naming the nine it takes.
582
+
583
+ | setting | takes |
584
+ |---|---|
585
+ | `schema` | a graphql-ruby schema class, a [`Client`](transports.md), a path to a dump, SDL, or a lambda returning one |
586
+ | `queries` | a directory, or a list of them — the `.graphql` files this graph generates from |
587
+ | `output` | one directory — where this graph's generated Ruby is written |
588
+ | `client` | a constant, or its name — what this graph's modules execute against |
589
+ | `namespace` | a constant, or its name — what every constant this graph generates nests under |
590
+ | `types_module` | a constant name for the shared types module (default: `GraphQLTypes`, under `namespace`) |
591
+
592
+ **`client` names a constant, not a url.** Its value is spelled into every module
593
+ this graph generates and resolved the first time one of them executes — so it
594
+ has to be something generated source can write down, and a url is not. Build
595
+ the client wherever you like and put the constant holding it here:
596
+
597
+ ```ruby
598
+ GITHUB = GraphWeaver.new("https://api.github.com/graphql", auth: ENV["GITHUB_TOKEN"])
599
+ ```
600
+
601
+ Resolving at first use rather than at declaration is what lets an initializer
602
+ name `Billing::Schema` before Zeitwerk has loaded it, and what lets a dev reload
603
+ swap the class object underneath. A graph with no `client` generates modules
604
+ that fall back to `GraphWeaver.client`, the app default.
605
+
606
+ `schema "x"` sets and a bare `schema` reads back. There is no `schema = "x"`
607
+ form: the block is `instance_eval`'d, so that would be a local variable that
608
+ silently does nothing — the same reason graphql-ruby writes `field :name`.
609
+
610
+ ### Two remote APIs, and no schema of your own
611
+
612
+ An app that is a pure client of someone else's GraphQL owns no schema class at
613
+ all, so every graph's schema is a dump — and the dumps have to come from
614
+ somewhere. Name the file you want and the client that can fetch it, and
615
+ `schema:refresh` writes it:
616
+
617
+ ```ruby
618
+ # config/initializers/graph_weaver.rb
619
+ COUNTRIES = GraphWeaver.new("https://countries.trevorblades.com/")
620
+ POKE = GraphWeaver.new("https://beta.pokeapi.co/graphql/v1beta")
621
+
622
+ GraphWeaver.graph :countries do
623
+ schema "app/graphql/countries/schema.json"
624
+ queries "app/graphql/countries/queries"
625
+ output "app/graphql/countries/generated"
626
+ client "COUNTRIES"
627
+ namespace "Countries"
628
+ end
629
+
630
+ GraphWeaver.graph :poke do
631
+ schema "app/graphql/poke/schema.json"
632
+ queries "app/graphql/poke/queries"
633
+ output "app/graphql/poke/generated"
634
+ client "POKE"
635
+ namespace "Poke"
636
+ end
637
+ ```
638
+
639
+ ```sh
640
+ rake graph_weaver:schema:refresh # introspects each graph's client into its schema
641
+ rake graph_weaver:generate
642
+ ```
643
+
644
+ A graph whose dump isn't there yet is introspected from the url its own client
645
+ posts to, and the dump records that url — so every later `schema:refresh` and
646
+ `schema:diff` re-reads the right server without being told again. `URL=` is for
647
+ the app that has one dump and no graphs; it names a single endpoint, and here
648
+ each graph has its own.
649
+
650
+ In specs, `graph:` is how an example says which graph a helper stands in for —
651
+ `graphql_fake(graph: :poke, "pokemon_v2_pokemon.name" => "pikachu")`. See
652
+ [testing.md](testing.md).
653
+
654
+ **In Rails, declare graphs in the initializer itself, and name an autoloaded
655
+ schema class with a lambda** — `schema -> { Billing::Schema }` — as above.
656
+ Zeitwerk is set up *after* `config/initializers` run, so a bare
657
+ `Billing::Schema` there raises `uninitialized constant`; the lambda is resolved
658
+ when generation asks, and resolved again after a dev reload has replaced the
659
+ class object. (`client` and `namespace` take the constant or its name, since
660
+ either way it is baked into generated source as a name.)
661
+
662
+ **The block runs where you write it**, registrations included — so a
663
+ registration naming one of your own constants is in exactly the position a
664
+ top-level one is, and has the same answer: declare that graph from a
665
+ `to_prepare` block, as [above](#2-run-the-generator).
666
+
667
+ ```ruby
668
+ Rails.application.config.to_prepare do
669
+ GraphWeaver.graph :billing do
670
+ schema Billing::Schema
671
+ queries "app/graphql/billing/queries"
672
+ output "app/graphql/billing/generated"
673
+ namespace "Billing"
674
+ register_enum "Species", PetKind
675
+ end
676
+ end
677
+ ```
678
+
679
+ Re-running is safe — the name is the identity, so the second declaration
680
+ replaces the first — and watch mode sees the graph either way.
681
+
682
+ One constraint: `to_prepare` runs after Rails has set Zeitwerk up, and Zeitwerk
683
+ reads its ignore list only then, so an `output` declared there can't be hidden
684
+ from autoloading. Under the conventional `app/graphql/*/generated` it already
685
+ is; anywhere else is refused at boot, naming the two fixes — declare the graph
686
+ in `config/initializers` with `schema -> { Billing::Schema }`, or add the
687
+ directory to `GraphWeaver.generated_paths` there.
688
+
689
+ Two things are worth knowing:
690
+
691
+ - **`namespace` nests everything that graph generates** — `person.graphql`
692
+ becomes `Billing::PersonQuery`, and its shared types module becomes
693
+ `Billing::GraphQLTypes`. Constants are global, so two schemas that both have a
694
+ `person.graphql`, or that both hoist an enum, would otherwise fight over one
695
+ name. Without a namespace the collision is refused at generation, naming both
696
+ files.
697
+ - **The block's registrations reach that graph alone**, laid over the top-level
698
+ ones. That is what makes the build quiet: `Money` is checked against the
699
+ schema it was registered for, and against no other. The top-level layer is
700
+ read when generation asks, not when the graph is declared, so a
701
+ `register_scalar` in another initializer reaches every graph whichever
702
+ initializer Rails happened to run first.
703
+
704
+ Declaring any graph replaces the implicit one the settings describe — an app
705
+ either has graphs or has settings, never a silent third thing. The name is the
706
+ identity, so re-declaring `:billing` replaces it rather than adding a second
707
+ one; a `to_prepare` block that re-runs on every reload is safe.
708
+
343
709
  ## Sorbet, with or without
344
710
 
345
711
  `sorbet-runtime` is a hard dependency, so generated `T::Struct`s and sigs
@@ -350,12 +716,17 @@ generated files — dynamic `parse` is invisible to `srb tc`. Everything
350
716
  works without Sorbet; codegen plus Sorbet is what moves type errors from
351
717
  runtime to CI.
352
718
 
719
+ A misspelled field is caught either way — by `srb tc` before it runs, or by
720
+ `NoMethodError` the first time it does. Nullability is the gap:
721
+ `country.capital.upcase` is a typecheck error because `capital` is `T.nilable`,
722
+ but at runtime it only raises on the rows where `capital` really is nil — which
723
+ may be none of your dev data and plenty of production's.
724
+
353
725
  If your app globally injects `T::Sig` (`class Module; include T::Sig`), the
354
726
  per-struct `extend T::Sig` in generated files is redundant — rubocop's
355
727
  `Sorbet/RedundantExtendTSig` flags it. GraphWeaver auto-detects that at
356
728
  generation time and skips the `extend`; override with
357
- `GraphWeaver.extend_t_sig = true`/`false`. (Generated code is machine-generated
358
- and marked "do not edit," so excluding `generated/**` from rubocop is also fine.)
729
+ `GraphWeaver.extend_t_sig = true`/`false`.
359
730
 
360
731
  ## Not Rails?
361
732
 
@@ -376,7 +747,29 @@ mkdir -p app/graphql/queries app/graphql/generated
376
747
  rake graph_weaver:schema:refresh URL=https://api.example.com/graphql
377
748
  ```
378
749
 
750
+ Those paths are the Rails convention, not a requirement: put the files where
751
+ your project already puts things and say so with `GraphWeaver.queries_paths`
752
+ and `GraphWeaver.generated_paths`.
753
+
379
754
  Add `require "graph_weaver/tasks"` to your Rakefile for the rake tasks —
380
755
  and, since there's no `:environment` hook to run your registrations,
381
- require the file that does them from the Rakefile too. `graphql.config.yml`
382
- is copy/paste from [editors](editors.md).
756
+ require the file that does them from the Rakefile too.
757
+
758
+ **Or skip rake too.** The tasks are a thin wrapper over public calls, so a
759
+ script of your own does the same work — and `cache: true` writes the dump on
760
+ that first introspection, so there's nothing to refresh first:
761
+
762
+ ```ruby
763
+ client = GraphWeaver.new("https://api.example.com/graphql", cache: true)
764
+ client.schema # introspects once, writing the dump
765
+
766
+ schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
767
+ GraphWeaver.generate!(schema:) # => every file the plan produces
768
+ GraphWeaver.changed_files # => only the ones whose bytes moved
769
+ ```
770
+
771
+ Pruning, the shared types module, and `verify_generated!` — the freshness
772
+ guard `rake graph_weaver:verify` runs — are in
773
+ [generated modules](generated_modules.md#generating).
774
+
775
+ `graphql.config.yml` is copy/paste from [editors](editors.md).