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.
- checksums.yaml +4 -4
- data/Gemfile +8 -0
- data/Gemfile.lock +153 -4
- data/README.md +45 -79
- data/docs/alternatives.md +195 -0
- data/docs/cassettes.md +61 -50
- data/docs/editors.md +32 -47
- data/docs/errors.md +360 -103
- data/docs/federation.md +692 -473
- data/docs/generated_modules.md +441 -314
- data/docs/getting_started.md +370 -194
- data/docs/i18n.md +171 -0
- data/docs/logging.md +197 -50
- data/docs/real_world.md +42 -27
- data/docs/scalars.md +307 -176
- data/docs/testing.md +473 -220
- data/docs/transports.md +224 -151
- data/docs/upgrading.md +258 -305
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +19 -3
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +69 -11
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +406 -195
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +284 -46
- data/lib/graph_weaver/federation.rb +129 -27
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +27 -15
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +80 -0
- data/lib/graph_weaver/internal/headers.rb +70 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +40 -4
- data/lib/graph_weaver/internal.rb +249 -14
- data/lib/graph_weaver/log_subscriber.rb +74 -0
- data/lib/graph_weaver/logging.rb +163 -19
- data/lib/graph_weaver/query_module.rb +44 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +45 -13
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +266 -56
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +34 -10
- data/lib/graph_weaver/testing/endpoint.rb +107 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +164 -45
- data/lib/graph_weaver/testing/router.rb +64 -13
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +48 -6
- data/lib/graph_weaver/transport.rb +134 -27
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +495 -106
- metadata +71 -3
- data/CHANGELOG.md +0 -2355
data/docs/getting_started.md
CHANGED
|
@@ -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.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
34
|
-
|
|
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
|
|
46
|
-
| `--no-schema` | skip writing the dump; `rake graph_weaver:schema:refresh
|
|
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
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
`
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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.
|
|
194
|
-
name
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
[
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
|
246
|
-
server, no
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
instead and the same example runs against your real
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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 from — a 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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
#
|
|
328
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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.
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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).
|