graph_weaver 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +8 -0
  3. data/Gemfile.lock +153 -4
  4. data/README.md +45 -79
  5. data/docs/alternatives.md +195 -0
  6. data/docs/cassettes.md +61 -50
  7. data/docs/editors.md +32 -47
  8. data/docs/errors.md +360 -103
  9. data/docs/federation.md +692 -473
  10. data/docs/generated_modules.md +441 -314
  11. data/docs/getting_started.md +370 -194
  12. data/docs/i18n.md +171 -0
  13. data/docs/logging.md +197 -50
  14. data/docs/real_world.md +42 -27
  15. data/docs/scalars.md +307 -176
  16. data/docs/testing.md +473 -220
  17. data/docs/transports.md +224 -151
  18. data/docs/upgrading.md +258 -305
  19. data/examples/README.md +38 -0
  20. data/examples/countries.rb +39 -0
  21. data/examples/federation.rb +62 -0
  22. data/examples/github/generate.rb +20 -0
  23. data/examples/github/generated/star_mutation.rb +126 -0
  24. data/examples/github/generated/stargazers_query.rb +232 -0
  25. data/examples/github/generated/starred_query.rb +151 -0
  26. data/examples/github/queries/star.graphql +8 -0
  27. data/examples/github/queries/stargazers.graphql +22 -0
  28. data/examples/github/queries/starred.graphql +11 -0
  29. data/examples/github/run.rb +43 -0
  30. data/examples/github/setup.rb +18 -0
  31. data/examples/rick_and_morty.rb +57 -0
  32. data/graph_weaver.gemspec +19 -3
  33. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  34. data/lib/graph_weaver/client.rb +69 -11
  35. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  36. data/lib/graph_weaver/codegen/emit.rb +98 -29
  37. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  38. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  39. data/lib/graph_weaver/codegen/registry.rb +175 -0
  40. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  41. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  42. data/lib/graph_weaver/codegen.rb +406 -195
  43. data/lib/graph_weaver/coerce.rb +155 -26
  44. data/lib/graph_weaver/context_seam.rb +54 -0
  45. data/lib/graph_weaver/errors.rb +284 -46
  46. data/lib/graph_weaver/federation.rb +129 -27
  47. data/lib/graph_weaver/graph.rb +315 -0
  48. data/lib/graph_weaver/hints.rb +100 -24
  49. data/lib/graph_weaver/in_process.rb +27 -15
  50. data/lib/graph_weaver/input_struct.rb +119 -32
  51. data/lib/graph_weaver/internal/endpoint.rb +80 -0
  52. data/lib/graph_weaver/internal/headers.rb +70 -0
  53. data/lib/graph_weaver/internal/overrides.rb +67 -5
  54. data/lib/graph_weaver/internal/planner.rb +45 -15
  55. data/lib/graph_weaver/internal/refusal.rb +49 -0
  56. data/lib/graph_weaver/internal/schemas.rb +23 -9
  57. data/lib/graph_weaver/internal/selection.rb +34 -0
  58. data/lib/graph_weaver/internal/server_input.rb +251 -0
  59. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  60. data/lib/graph_weaver/internal/unused.rb +287 -0
  61. data/lib/graph_weaver/internal/values.rb +40 -4
  62. data/lib/graph_weaver/internal.rb +249 -14
  63. data/lib/graph_weaver/log_subscriber.rb +74 -0
  64. data/lib/graph_weaver/logging.rb +163 -19
  65. data/lib/graph_weaver/query_module.rb +44 -3
  66. data/lib/graph_weaver/railtie.rb +237 -17
  67. data/lib/graph_weaver/representation.rb +55 -17
  68. data/lib/graph_weaver/result_struct.rb +90 -0
  69. data/lib/graph_weaver/retry.rb +45 -13
  70. data/lib/graph_weaver/rspec.rb +404 -93
  71. data/lib/graph_weaver/schema_loader.rb +266 -56
  72. data/lib/graph_weaver/tasks.rb +380 -89
  73. data/lib/graph_weaver/testing/cassette.rb +34 -10
  74. data/lib/graph_weaver/testing/endpoint.rb +107 -0
  75. data/lib/graph_weaver/testing/failure.rb +69 -12
  76. data/lib/graph_weaver/testing/fake_client.rb +164 -45
  77. data/lib/graph_weaver/testing/router.rb +64 -13
  78. data/lib/graph_weaver/testing.rb +200 -58
  79. data/lib/graph_weaver/transport/faraday.rb +41 -8
  80. data/lib/graph_weaver/transport/http.rb +48 -6
  81. data/lib/graph_weaver/transport.rb +134 -27
  82. data/lib/graph_weaver/version.rb +1 -1
  83. data/lib/graph_weaver.rb +495 -106
  84. metadata +71 -3
  85. data/CHANGELOG.md +0 -2355
@@ -2,11 +2,10 @@
2
2
 
3
3
  The setup that ships, end to end: queries live as `.graphql` files, generation
4
4
  writes `# typed: strict` Ruby you check in, and CI fails when anything drifts.
5
- Follow it once when you add the gem to an app. (Exploring an API from a console
6
- instead? Start with [dynamic mode](real_world.md) no build step.)
7
-
8
- Rails is assumed below; the [non-Rails note](#not-rails) at the bottom
9
- covers the differences.
5
+ Follow it once when you add the gem to an app. Rails is assumed;
6
+ [not Rails?](#not-rails) covers the differences. Exploring an API from a console
7
+ instead? Start with [dynamic mode](real_world.md) — no build step. No Sorbet in
8
+ your app? None needed [Sorbet, with or without](#sorbet-with-or-without).
10
9
 
11
10
  ## 1. Install
12
11
 
@@ -27,12 +26,12 @@ rails g graph_weaver:install https://api.example.com/graphql
27
26
  create app/graphql/fragments/.keep
28
27
  create app/graphql/generated/.keep
29
28
  create graphql.config.yml
29
+ insert spec/rails_helper.rb
30
30
  introspect app/graphql/schema.json from https://api.example.com/graphql
31
31
  ```
32
32
 
33
- The argument is whatever you'd pass to `GraphWeaver.new` — the generator
34
- takes the same three source forms the library does, and writes the
35
- initializer that fits:
33
+ The argument is whatever you'd pass to `GraphWeaver.new` — the same three source
34
+ forms the library takes and the generator writes the initializer that fits:
36
35
 
37
36
  | source | |
38
37
  |---|---|
@@ -42,31 +41,35 @@ initializer that fits:
42
41
 
43
42
  | flag | |
44
43
  |---|---|
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 |
44
+ | `--auth` | name of the ENV var holding the auth token — default `GRAPHWEAVER_AUTH`, url only, omitted for a public API. The name is recorded into the dump, so `schema:refresh`/`schema:diff`/`queries:check` read the same one the initializer does |
45
+ | `--no-schema` | skip writing the dump; `rake graph_weaver:schema:refresh` does it later (`URL=...` to name an endpoint the first time) |
47
46
 
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.
47
+ Re-running is safe: every file goes through the usual Rails conflict prompt, so
48
+ an initializer you've edited is never overwritten silently. The schema dump is
49
+ the exception — it is **kept** rather than prompted for, since replacing it
50
+ would drop the source url it records; delete it and re-run to re-introspect.
50
51
 
51
52
  What it wrote:
52
53
 
53
54
  - **`config/initializers/graph_weaver.rb`.** `GraphWeaver.client =` is the
54
- load-bearing line: generated modules without a baked transport resolve to
55
- it at execute time (the full
55
+ load-bearing line: generated modules without a baked transport resolve to it
56
+ at execute time (the full
56
57
  [resolution order](transports.md#client-resolution)). Custom
57
- scalars/enums/type helpers register here too — the rake tasks bake them
58
- into generated source, so they have to run first:
58
+ scalars/enums/type helpers register here too — the rake tasks bake them into
59
+ generated source, so they have to run first ([scalars](scalars.md)):
59
60
 
60
61
  ```ruby
61
62
  GraphWeaver.register_scalar("Money", Money) # a scalar the registry can't know
62
63
  ```
63
64
 
64
- 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:
67
- autoloading is set up after `config/initializers` run. Generation depends on
68
- `:environment`, which runs `to_prepare` too, so the registration is in place
69
- before it emits.
65
+ Every setting also takes the block form `GraphWeaver.configure { |config| }`,
66
+ where `config` is `GraphWeaver` itself, so the two spellings are one call.
67
+
68
+ A registration that names one of your **own** constants — a `T::Enum` for
69
+ `register_enum`, a mixin for [`extend_type`](generated_modules.md#type-helpers)
70
+ goes in a `to_prepare` block instead, because autoloading is set up after
71
+ `config/initializers` run. Generation depends on `:environment`, which runs
72
+ `to_prepare` too, so the registration is still in place before it emits:
70
73
 
71
74
  ```ruby
72
75
  Rails.application.config.to_prepare do
@@ -74,100 +77,34 @@ What it wrote:
74
77
  GraphWeaver.extend_type("Pet", PetHelpers)
75
78
  end
76
79
  ```
77
-
78
80
  - **`app/graphql/schema.json`.** The schema dump codegen reads
79
81
  (`GraphWeaver.schema_path`) — never written by hand, always committed.
80
- `cache: true` in the initializer reuses it; delete the file to
81
- re-introspect. Prefer PR-reviewable diffs? `cache: :graphql` writes SDL
82
- instead; both generate identical code. (`cache:`/`ttl:` apply only to url
83
- clients — a schema source never introspects, so passing them raises.)
84
- - **`graphql.config.yml`.** Five lines of YAML that give VS Code and
85
- RubyMine schema autocomplete, hover docs, and validation as you type in
86
- `.graphql` files no JS project, no `npm install`. Details and the honest
87
- limits in [editors](editors.md).
82
+ `cache: true` in the initializer reuses it; delete the file to re-introspect.
83
+ Prefer PR-reviewable diffs? `cache: :graphql` writes SDL instead; both
84
+ generate identical code. (`cache:`/`ttl:` apply only to url clients — a schema
85
+ source never introspects, so passing them raises.) A dump records the url it
86
+ came from, so a second client at a second origin caches under
87
+ `app/graphql/schema-<digest>.json` rather than overwriting the first's; name
88
+ the file yourself (`cache: "app/graphql/billing.json"`) to say which is which.
89
+ - **`graphql.config.yml`.** Five lines of YAML that give VS Code and RubyMine
90
+ schema autocomplete, hover docs, and validation as you type in `.graphql`
91
+ files — no JS project, no `npm install`. See [editors](editors.md).
88
92
  - **`app/graphql/queries/`, `app/graphql/fragments/`, `app/graphql/generated/`.**
89
93
  Where you write queries, where shared fragments live, and where generation
90
94
  writes Ruby.
95
+ - **`.rubocop.yml`**, if you have one. Generated code is machine-written and
96
+ marked "do not edit," so the output directory is added to `AllCops: Exclude:`.
97
+ An `AllCops:` you already have is left alone (a second one would replace it,
98
+ not merge); the generator prints the line to add.
91
99
 
92
- Rake needs no wiring either: in Rails the `graph_weaver:*` tasks register
93
- themselves (a Railtie) and depend on `:environment`, so your initializer
94
- and its registrations runs first. The generated modules load at boot from
95
- a `to_prepare` block, so a helper or enum you registered in one is already
96
- in place when the file that names it loads.
97
-
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
- ```
100
+ Rake needs no wiring: in Rails the `graph_weaver:*` tasks register themselves and
101
+ depend on `:environment`, so your initializer runs first. The generated modules
102
+ load at boot from a `to_prepare` block, so a helper or enum you registered is
103
+ already in place when the file that names it loads.
106
104
 
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.
105
+ All of that describes **one** schema, which is the usual case; an app with a
106
+ second one declares each as a graph ([more than one
107
+ schema](#more-than-one-schema)).
171
108
 
172
109
  ## 3. Write a query, generate, commit
173
110
 
@@ -190,22 +127,24 @@ PersonQuery.execute!(id: "1").person&.name # typed, via GraphWeaver.client
190
127
  ```
191
128
 
192
129
  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
130
+ any other code — and never edited by hand. One rule covers the names: the file
131
+ name becomes the module (`person.graphql` `PersonQuery`), and every selection
132
+ inside it becomes a struct named for its **response key**, not its schema type
133
+ — a `countries { … }` selection is `CountriesQuery::Result::Countries` even
134
+ where the schema calls the type `Country`. The corners are in
195
135
  [generated modules](generated_modules.md#naming).
196
136
 
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).
137
+ **Not sure what the API offers?** `app/graphql/schema.json` is the whole schema
138
+ as plain JSON types, fields, descriptions already in your repo, and
139
+ [your editor reads it](editors.md) as you type the query.
200
140
 
201
141
  **In development you don't type that command again.** While the server is
202
- running, a `.graphql` edit — or a refreshed schema dump — regenerates before
203
- the next request, the way a route or a locale change takes effect. A query that
204
- doesn't compile is logged with its file and position while the modules already
205
- loaded keep serving, so a file saved mid-edit doesn't take the server down.
206
- Development only, and `config.graph_weaver.watch = false` turns it off. The
207
- generated files are still what ships: commit them, and keep `rake
208
- graph_weaver:verify` in CI.
142
+ running, a `.graphql` edit — or a refreshed schema dump — regenerates before the
143
+ next request, the way a route change takes effect; a query that doesn't compile
144
+ is logged with its file and position while the modules already loaded keep
145
+ serving, so a file saved mid-edit doesn't take the server down. Development
146
+ only, and `config.graph_weaver.watch = false` turns it off. The generated files
147
+ are still what ships: commit them, and keep `rake graph_weaver:verify` in CI.
209
148
 
210
149
  ### Shared fragments
211
150
 
@@ -225,57 +164,97 @@ Fragment files hold only fragments (no operations), and names are unique across
225
164
  them. Point elsewhere with `GraphWeaver.fragments_paths` (an appendable list,
226
165
  default `app/graphql/fragments`).
227
166
 
228
- One payoff worth knowing about: when a shared fragment *is* the whole selection
229
- on a union field, its type is hoisted once into `GraphQLTypes` and every query
230
- that spreads it gets the same Ruby type — so one exhaustive `case … T.absurd`
231
- works everywhere. See
232
- [abstract types](generated_modules.md#abstract-types).
167
+ One payoff: when a shared fragment *is* the whole selection on a union field,
168
+ its type is hoisted once into `GraphQLTypes` and every query that spreads it
169
+ gets the same Ruby type — so one exhaustive `case … T.absurd` works everywhere.
170
+ See [abstract types](generated_modules.md#abstract-types).
233
171
 
234
172
  ## 4. Test against fakes
235
173
 
236
- ```ruby
237
- # spec/support/graph_weaver.rb
238
- require "graph_weaver/rspec"
239
- ```
174
+ The generator put `require "graph_weaver/rspec"` in your `spec/rails_helper.rb`
175
+ add it there yourself if rspec arrived after the install, since the tags below
176
+ do nothing without it.
240
177
 
241
178
  ```ruby
242
- it "renders the empty state", graphql: :fake do … end # or tag the describe
179
+ # spec/requests/people_spec.rb
180
+ RSpec.describe "People", type: :request do
181
+ it "lists people", graphql: :fake do
182
+ graphql_fake("Person" => { "name" => "Ada Lovelace" })
183
+
184
+ get "/people"
185
+
186
+ expect(response.body).to include("Ada Lovelace")
187
+ end
188
+ end
243
189
  ```
244
190
 
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
191
+ The tag installs a seeded, schema-correct `FakeClient` for that example, and
192
+ **nothing leaves the process**: no server, no HTTP, so no webmock and no VCR.
193
+ `rspec --seed 1234` reproduces the fake data along with test order. Pin the value
194
+ the assertion is about and everything else in the selection is still fabricated.
195
+ Tag `graphql: :in_process` instead and the same example runs against your real
196
+ resolvers. Pins, simulating failures, and the federated `graphql: :router` are in
251
197
  [testing](testing.md).
252
198
 
253
- A fresh `rails g rspec:install` leaves the `spec/support` glob commented
254
- out in `spec/rails_helper.rb`, so uncomment it — or put the require in
255
- `rails_helper.rb` itself. Nothing warns you that a support file went
256
- unread.
199
+ A fresh `rails g rspec:install` leaves the `spec/support` glob commented out in
200
+ `spec/rails_helper.rb`, so uncomment it — or put the require in
201
+ `rails_helper.rb` itself. Nothing warns you that a support file went unread.
257
202
 
258
203
  ## 5. Verify in CI
259
204
 
260
- Four questions, four tasks — the last only on a federated graph:
205
+ Five questions, five tasks — the last only on a federated graph:
261
206
 
262
207
  | ask | task | needs network |
263
208
  |---|---|---|
264
209
  | is the checked-in Ruby fresh? | `rake graph_weaver:verify` | no |
265
210
  | has the server's schema drifted from the dump? | `rake graph_weaver:schema:diff` | yes |
266
211
  | did that drift break any of my queries? | `rake graph_weaver:queries:check` | yes |
212
+ | does the app still read what it selects? | `rake graph_weaver:unused` | no |
267
213
  | did a subgraph change without a recompose? | `rake graph_weaver:federation:diff` | no |
268
214
 
269
- `verify` compares the committed generated files against what the current
270
- 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;
275
- see [federation](federation.md#has-the-supergraph-been-recomposed).
215
+ Every one of them exits non-zero on a finding, so the gate is a chain.
216
+ `verify` compares the committed generated files against what the current schema
217
+ + queries + registrations would produce, so it belongs in every CI build.
218
+ `schema:diff` asks whatever the dump came froma recorded source url (with
219
+ `GRAPHWEAVER_AUTH` for private APIs), or your own schema class when the app
220
+ [serves the schema itself](#your-apps-own-schema-in-process) and
221
+ `rake graph_weaver:schema:refresh` is the repair either way. On an app with more
222
+ than one schema, `rake graph_weaver:graphs` lists which graphs are configured,
223
+ where each generates, and what each registers.
276
224
 
277
- `schema:diff` names what moved, breaking changes first breaking meaning
278
- a query written against your dump stops validating, or stops casting:
225
+ The two topologies differ only in what reaches a network:
226
+
227
+ ```sh
228
+ # an API you don't own — the dump records the url it was introspected from
229
+ bundle exec rake graph_weaver:verify # offline checks first, so a
230
+ bundle exec rake graph_weaver:cassettes:check # network blip can't mask one
231
+ bundle exec rake graph_weaver:queries:check # re-introspects the recorded url
232
+ bundle exec rake graph_weaver:schema:diff # …so does this one
233
+ ```
234
+
235
+ ```sh
236
+ # your own graphql-ruby schema, in-process — none of this touches a network
237
+ bundle exec rake graph_weaver:schema:diff # has the class moved past the dump?
238
+ bundle exec rake graph_weaver:queries:check # do the queries still validate?
239
+ bundle exec rake graph_weaver:verify # is the checked-in Ruby current?
240
+ bundle exec rake graph_weaver:cassettes:check # do the recordings still cast?
241
+ ```
242
+
243
+ In-process the order is the repair order: refresh the dump, regenerate,
244
+ re-record. In GitHub Actions each line is one `- run:` step; the remote chain
245
+ wants `GRAPHWEAVER_AUTH: ${{ secrets.GRAPHWEAVER_AUTH }}` in the `env:` of the
246
+ two steps that re-introspect, and an in-process schema needs no `env:` at all,
247
+ since it answers introspection itself. A federated app adds `rake
248
+ graph_weaver:federation:diff` to either chain — it needs no network.
249
+
250
+ **On a federated graph, none of the five looks at the schema production is
251
+ serving**, because no endpoint serves a composed supergraph. The pre-deploy
252
+ check against the live graph is Apollo's, not this gem's — run `rover subgraph
253
+ check` and `rover supergraph fetch` in the same job. See
254
+ [federation → in CI](federation.md#in-ci).
255
+
256
+ `schema:diff` names what moved, breaking changes first — breaking meaning a
257
+ query written against your dump stops validating, or stops casting:
279
258
 
280
259
  ```
281
260
  app/graphql/schema.json vs https://api.example.com/graphql: 8 changes, 5 breaking
@@ -295,21 +274,17 @@ other:
295
274
  app/graphql/schema.json is stale — the server's schema has drifted (rake graph_weaver:schema:refresh)
296
275
  ```
297
276
 
298
- Nullability is judged from your side, which is why the two above point
299
- opposite ways: `Person.pets` losing its `!` hands a generated struct the
300
- nil it declared it wouldn't get, while `AdoptionInput.nickname` gaining
301
- one rejects a query that omits it. Any drift exits non-zero — whether a
302
- change matters is yours to judge.
303
-
304
- `GraphWeaver::SchemaLoader.diff(path)` is the same summary as an object —
305
- `#breaking`, `#compatible`, `#to_h`, and `#empty?` for the plain "has it
306
- drifted" question.
277
+ Nullability is judged from your side, which is why the two above point opposite
278
+ ways: `Person.pets` losing its `!` hands a generated struct the nil it declared
279
+ it wouldn't get, while `AdoptionInput.nickname` gaining one rejects a query that
280
+ omits it. Any drift exits non-zero — whether a change matters is yours to judge.
281
+ `GraphWeaver::SchemaLoader.diff(path)` is the same summary as an object
282
+ (`#breaking`, `#compatible`, `#to_h`, `#empty?`).
307
283
 
308
- `queries:check` answers the question that actually matters when the schema
309
- *has* moved: **which of your queries no longer validate, and why.** It
310
- re-introspects the recorded url (without rewriting the dump) and validates
311
- every `.graphql` file against the schema as it is right now, naming each
312
- error's line and column, and exits non-zero:
284
+ `queries:check` answers the question that matters when the schema *has* moved:
285
+ **which of your queries no longer validate, and why.** It re-introspects the
286
+ recorded url (without rewriting the dump) and validates every `.graphql` file
287
+ against the schema as it is right now, naming each error's line and column:
313
288
 
314
289
  ```
315
290
  app/graphql/queries/person.graphql
@@ -318,49 +293,228 @@ app/graphql/queries/person.graphql
318
293
  1 invalid query
319
294
  ```
320
295
 
321
- The Ruby behind it returns the same thing as data, so you can wire it into
322
- whatever you already have (a spec, a Slack ping, an issue):
296
+ `GraphWeaver.check_queries` returns the same findings as data a hash of file
297
+ to `message`/`line`/`column`, empty when everything validates so you can wire
298
+ it into a spec, a Slack ping, an issue. Pass it `schema:` a *loaded* schema (not
299
+ a path) and nothing touches the network, which is how you check your queries
300
+ against a proposed subgraph before it's live.
301
+
302
+ ### The selections nothing reads
303
+
304
+ `rake graph_weaver:unused` asks the one question the others can't: not "is the
305
+ Ruby fresh" but "does the app still use what the query asks for". A field
306
+ someone stopped rendering stays in the `.graphql` forever — the query keeps
307
+ validating, the struct keeps casting, and the server keeps paying to resolve it.
308
+
309
+ ```
310
+ app/graphql/queries/products.graphql: Products.sku — selected, never read (Catalog::ProductsQuery::Result::Products#sku)
311
+
312
+ 13 selections, 2 unread — 2 queries, 58 files swept under .
313
+ ```
314
+
315
+ Each line names the query file, the selection to go and delete, and the
316
+ generated prop behind it. It reads the generated structs for the props a query
317
+ produced, then sweeps your `.rb`, `.rake`, `.builder`, `.erb`, `.slim`, `.haml`
318
+ and `.jbuilder` for every name they could be read by — `.sku`, `sku:`, `:sku`,
319
+ `"sku"`. `PATHS=app,lib` narrows the sweep (a `PATHS=` naming a directory that
320
+ isn't there is refused rather than swept as nothing); anything under a directory
321
+ named `generated`, plus `vendor`, `node_modules`, `tmp` and `log`, is skipped,
322
+ as is any file defining a graphql-ruby **type** — a `field :sku` there is your
323
+ *server* offering a field, not this app reading one back. A module handed whole
324
+ to a serializer (`render json:`, `to_h`, `as_json`, a local and all) counts every
325
+ prop as read, and the report quotes the line it followed. Nothing is edited and
326
+ the exit is 0; `STRICT=1` exits 1 when anything is unread.
327
+
328
+ **It is a lint, not a proof**, and the task's own footer says so. It matches
329
+ names as text, so a prop called `name` counts as read the moment anything says
330
+ `.name`, and it can't see a prop reached by `public_send`. Measured against real
331
+ corpora, **half to two thirds of genuinely unread selections go unreported**,
332
+ the share rising with the size of the app. Silence is the safe direction: treat
333
+ a finding as a prompt to go and look, and a clean run as nothing more than the
334
+ absence of an obvious one.
335
+
336
+ ## Your app's own schema, in-process
337
+
338
+ An app that *serves* GraphQL with graphql-ruby can have the same typed access to
339
+ its own API — same generated structs, no socket, no HTTP:
340
+
341
+ ```sh
342
+ rails g graph_weaver:install MyApp::Schema
343
+ ```
323
344
 
324
345
  ```ruby
325
- GraphWeaver.check_queries
326
- # => { "app/graphql/queries/person.graphql" =>
327
- # [{ "message" => "Field 'nmae' doesn't exist on type 'Person' (Did you mean `name`?)",
328
- # "line" => 4, "column" => 5 }] }
346
+ # config/initializers/graph_weaver.rb
347
+ Rails.application.config.to_prepare do
348
+ # queries run in-process against the app's own schema no socket
349
+ GraphWeaver.client = GraphWeaver.new(MyApp::Schema)
350
+ end
329
351
  ```
330
352
 
331
- Empty means everything validates. Pass `schema:` a *loaded* schema (not a path)
332
- and nothing touches the network handy for checking a proposed subgraph before
333
- it's live:
353
+ `to_prepare`, not a bare assignment: the schema class is autoloaded, so it isn't
354
+ resolvable while initializers run, and a dev reload replaces it with a new class
355
+ object that a captured one would go stale against.
356
+
357
+ **Context is per request, not per app.** A resolver reading
358
+ `context[:current_user]` gets nil from the app default — build a client where
359
+ you know the request and pass it per call:
334
360
 
335
361
  ```ruby
336
- GraphWeaver.check_queries(schema: GraphWeaver::SchemaLoader.load("proposed.graphql"))
362
+ client = GraphWeaver.new(MyApp::Schema, context: { current_user: })
363
+ PetQuery.execute!(client:, id: "1").pet.owner # => the context's user
337
364
  ```
338
365
 
339
- Left off, it re-introspects the url the dump records and when that dump is a
340
- composed supergraph, each error also names the subgraphs behind the type it
341
- points at ([federation](federation.md#the-routing-table)).
366
+ Each query gets its own copy of that hash, so a resolver writing
367
+ `context[:loader] =` can't hand what it wrote to the next request.
368
+
369
+ **Keep the dump in step with the schema.** Codegen reads the committed dump at
370
+ `GraphWeaver.schema_path`, never the live class — that's what makes `rake
371
+ graph_weaver:verify` a deterministic CI check, and why it **fails** when the
372
+ dump has fallen behind the class rather than calling the tree up to date. The
373
+ same two tasks a remote schema uses keep it in step, and neither touches a
374
+ network here:
375
+
376
+ ```sh
377
+ rake graph_weaver:schema:diff # what has the class changed since the dump?
378
+ rake graph_weaver:schema:refresh # rewrite the dump from the class
379
+ rake graph_weaver:generate
380
+ ```
381
+
382
+ `refresh` rewrites the dump in whatever format it already is, so a repo that
383
+ chose `cache: :graphql` keeps SDL. `queries:check` validates against the live
384
+ class rather than the dump, so the two can disagree about one query while the
385
+ dump is stale; `schema:refresh` settles it.
386
+
387
+ **Scaffolding the app too?** On a `rails new --skip-active-record`,
388
+ `rails g graphql:install` writes `config.active_record.query_log_tags` lines
389
+ into `config/application.rb` that an app without ActiveRecord can't boot with —
390
+ a graphql-ruby bug. Run it as `rails g graphql:install --skip-query-logs`, or
391
+ delete the lines it wrote.
392
+
393
+ ## A schema dump you already have
394
+
395
+ ```sh
396
+ rails g graph_weaver:install db/schema.graphql
397
+ ```
398
+
399
+ Sets `GraphWeaver.schema_path` to that file rather than writing a second copy,
400
+ and introspects nothing. A dump has no resolvers, so it can't execute — set
401
+ `GraphWeaver.client` to whatever serves the API.
402
+
403
+ ## More than one schema
404
+
405
+ The five steps above describe one graph — a schema, its queries, its output. An
406
+ app with a second schema declares each one:
407
+
408
+ ```ruby
409
+ # config/initializers/graph_weaver.rb
410
+ GraphWeaver.graph :billing do
411
+ schema -> { Billing::Schema }
412
+ queries "app/graphql/billing/queries"
413
+ output "app/graphql/billing/generated"
414
+ client "Billing::Schema"
415
+ namespace "Billing"
416
+ register_scalar "Money", BigDecimal
417
+ end
418
+ ```
419
+
420
+ One `rake graph_weaver:generate` does the app, one `rake graph_weaver:verify`
421
+ gates it, and `rake graph_weaver:graphs` lists what is configured. Each setting
422
+ falls back to the matching top-level one, so a graph says only what differs, and
423
+ anything else the block calls is refused naming the nine it takes (these six,
424
+ plus `register_scalar`, `register_enum` and `extend_type`).
425
+
426
+ | setting | takes |
427
+ |---|---|
428
+ | `schema` | a graphql-ruby schema class, a [`Client`](transports.md), a path to a dump, SDL, or a lambda returning one |
429
+ | `queries` | a directory, or a list of them — the `.graphql` files this graph generates from |
430
+ | `output` | one directory — where this graph's generated Ruby is written |
431
+ | `client` | a constant, or its name — what this graph's modules execute against |
432
+ | `namespace` | a constant, or its name — what every constant this graph generates nests under |
433
+ | `types_module` | a constant name for the shared types module (default: `GraphQLTypes`, under `namespace`) |
434
+
435
+ **Declaring graph two means declaring graph one.** A declared graph *replaces*
436
+ the implicit one the top-level settings describe, so the moment any graph is
437
+ named, the `app/graphql/queries` and `app/graphql/generated` an existing app was
438
+ already using belong to no graph — and `generate` refuses the whole app rather
439
+ than leave them there silently. Wrap them in a graph of their own, its
440
+ directories and nothing else: no `schema`, so it keeps reading the one
441
+ `schema_path` names, and no `namespace`, so every constant keeps the name it has.
442
+
443
+ ```ruby
444
+ GraphWeaver.graph :app do
445
+ queries "app/graphql/queries"
446
+ output "app/graphql/generated"
447
+ end
448
+ ```
449
+
450
+ **`client` names a constant, not a url** — its value is spelled into every module
451
+ this graph generates and resolved the first time one of them executes, so it has
452
+ to be something generated source can write down. Build the client wherever you
453
+ like (`GITHUB = GraphWeaver.new(url, auth: …)`) and put the constant holding it
454
+ here. A graph with no `client` generates modules that fall back to
455
+ `GraphWeaver.client`, the app default. `schema "x"` sets and a bare `schema`
456
+ reads back; there is no `schema = "x"` form, since the block is `instance_eval`'d
457
+ and that would be a local variable that silently does nothing.
458
+
459
+ **`namespace` nests everything that graph generates** — `person.graphql` becomes
460
+ `Billing::PersonQuery` ([naming](generated_modules.md#naming)). Constants are
461
+ global, so two schemas that both have a `person.graphql` would otherwise fight
462
+ over one name; without a namespace the collision is refused at generation,
463
+ naming both files. **The block's registrations reach that graph alone**, laid
464
+ over the top-level ones — so `Money` is checked against the schema it was
465
+ registered for, and against no other.
466
+
467
+ An app that is a pure client of someone else's GraphQL owns no schema class, so
468
+ every graph's `schema` is a dump. Give each the file you want and a `client`
469
+ that can fetch it: `rake graph_weaver:schema:refresh` introspects each graph's
470
+ client into its own dump, recording the url so every later `schema:refresh` and
471
+ `schema:diff` re-reads the right server. (`URL=` is for the app that has one
472
+ dump and no graphs.)
473
+
474
+ In specs, `graph:` is how an example says which graph a helper stands in for —
475
+ `graphql_fake(graph: :poke, "pokemon_v2_pokemon.name" => "pikachu")`. See
476
+ [testing](testing.md).
477
+
478
+ **In Rails, declare graphs in the initializer itself, and name an autoloaded
479
+ schema class with a lambda** — `schema -> { Billing::Schema }`, as above.
480
+ Zeitwerk is set up *after* `config/initializers` run, so a bare
481
+ `Billing::Schema` there raises `uninitialized constant`; the lambda is resolved
482
+ when generation asks, and again after a dev reload has replaced the class
483
+ object.
484
+
485
+ A block **runs where you write it**, registrations included, so a graph whose
486
+ registrations name your own constants is declared from a `to_prepare` block for
487
+ the reason [above](#2-run-the-generator) — safely, since the name is the
488
+ identity and the second declaration replaces the first. One constraint there:
489
+ Zeitwerk reads its ignore list before `to_prepare` runs, so an `output` declared
490
+ in one can't be hidden from autoloading. Under the conventional
491
+ `app/graphql/*/generated` it already is; anywhere else is refused at boot,
492
+ naming the two fixes.
342
493
 
343
494
  ## Sorbet, with or without
344
495
 
345
496
  `sorbet-runtime` is a hard dependency, so generated `T::Struct`s and sigs
346
- enforce at runtime in every app — no Sorbet setup required on your end.
347
- The *static* layer (`srb tc` flagging a typo'd field before anything
348
- runs) applies only when your app runs Sorbet, and only to checked-in
349
- generated files — dynamic `parse` is invisible to `srb tc`. Everything
350
- works without Sorbet; codegen plus Sorbet is what moves type errors from
351
- runtime to CI.
497
+ enforce at runtime in every app — no Sorbet setup required on your end. The
498
+ *static* layer (`srb tc` flagging a typo'd field before anything runs) applies
499
+ only when your app runs Sorbet, and only to checked-in generated files — dynamic
500
+ `parse` is invisible to `srb tc`.
501
+
502
+ A misspelled field is caught either way — by `srb tc` before it runs, or by
503
+ `NoMethodError` the first time it does. Nullability is the gap:
504
+ `country.capital.upcase` is a typecheck error because `capital` is `T.nilable`,
505
+ but at runtime it only raises on the rows where `capital` really is nil — which
506
+ may be none of your dev data and plenty of production's.
352
507
 
353
508
  If your app globally injects `T::Sig` (`class Module; include T::Sig`), the
354
509
  per-struct `extend T::Sig` in generated files is redundant — rubocop's
355
- `Sorbet/RedundantExtendTSig` flags it. GraphWeaver auto-detects that at
356
- 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.)
510
+ `Sorbet/RedundantExtendTSig` flags it. GraphWeaver detects that at generation
511
+ time and skips the `extend`; override with `GraphWeaver.extend_t_sig =
512
+ true`/`false`.
359
513
 
360
514
  ## Not Rails?
361
515
 
362
- There's no generator, but what it writes is short — a few lines wherever
363
- your app boots, two directories, and the schema dump:
516
+ There's no generator, but what it writes is short — a few lines wherever your
517
+ app boots, two directories, and the schema dump:
364
518
 
365
519
  ```ruby
366
520
  GraphWeaver.client = GraphWeaver.new(
@@ -376,7 +530,29 @@ mkdir -p app/graphql/queries app/graphql/generated
376
530
  rake graph_weaver:schema:refresh URL=https://api.example.com/graphql
377
531
  ```
378
532
 
379
- Add `require "graph_weaver/tasks"` to your Rakefile for the rake tasks —
380
- 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).
533
+ Those paths are the Rails convention, not a requirement: put the files where
534
+ your project already puts things and say so with `GraphWeaver.queries_paths` and
535
+ `GraphWeaver.generated_paths`.
536
+
537
+ Add `require "graph_weaver/tasks"` to your Rakefile for the rake tasks — and,
538
+ since there's no `:environment` hook to run your registrations, require the file
539
+ that does them from the Rakefile too.
540
+
541
+ **Or skip rake too.** The tasks are a thin wrapper over public calls, so a
542
+ script of your own does the same work — and `cache: true` writes the dump on
543
+ that first introspection, so there's nothing to refresh first:
544
+
545
+ ```ruby
546
+ client = GraphWeaver.new("https://api.example.com/graphql", cache: true)
547
+ client.schema # introspects once, writing the dump
548
+
549
+ schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
550
+ GraphWeaver.generate!(schema:) # => every file the plan produces
551
+ GraphWeaver.changed_files # => only the ones whose bytes moved
552
+ ```
553
+
554
+ Pruning, the shared types module, and `verify_generated!` — the freshness guard
555
+ `rake graph_weaver:verify` runs — are in
556
+ [generated modules](generated_modules.md#generating).
557
+
558
+ `graphql.config.yml` is copy/paste from [editors](editors.md).