graph_weaver 0.5.1 → 0.6.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +75 -54
  5. data/docs/cassettes.md +6 -1
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +73 -16
  8. data/docs/federation.md +201 -151
  9. data/docs/generated_modules.md +222 -165
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +34 -4
  12. data/docs/real_world.md +4 -4
  13. data/docs/scalars.md +206 -46
  14. data/docs/testing.md +191 -151
  15. data/docs/transports.md +47 -19
  16. data/docs/upgrading.md +210 -11
  17. data/lib/generators/graph_weaver/install_generator.rb +16 -1
  18. data/lib/graph_weaver/client.rb +46 -13
  19. data/lib/graph_weaver/codegen/aliases.rb +5 -4
  20. data/lib/graph_weaver/codegen/emit.rb +96 -39
  21. data/lib/graph_weaver/codegen/enum_type.rb +3 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +167 -98
  24. data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
  25. data/lib/graph_weaver/codegen.rb +279 -84
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +30 -7
  28. data/lib/graph_weaver/federation.rb +6 -5
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
  38. data/lib/graph_weaver/internal/values.rb +184 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +5 -4
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +50 -11
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +96 -29
  50. data/lib/graph_weaver/tasks.rb +78 -29
  51. data/lib/graph_weaver/testing/cassette.rb +49 -65
  52. data/lib/graph_weaver/testing/coverage.rb +5 -4
  53. data/lib/graph_weaver/testing/failure.rb +10 -6
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +94 -808
  57. data/lib/graph_weaver/testing.rb +35 -84
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +203 -119
  63. metadata +10 -5
  64. data/lib/graph_weaver/schemas.rb +0 -48
  65. data/lib/graph_weaver/selection.rb +0 -120
  66. data/lib/graph_weaver/testing/values.rb +0 -98
@@ -1,8 +1,8 @@
1
1
  # Getting started: the production path (Rails)
2
2
 
3
- The setup that ships: queries live as `.graphql` files, generation writes
4
- `# typed: strict` Ruby you check in, and CI fails when anything drifts.
5
- In Rails one generator does the setup. (Exploring an API from a console
3
+ The setup that ships, end to end: queries live as `.graphql` files, generation
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
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
@@ -24,6 +24,7 @@ rails g graph_weaver:install https://api.example.com/graphql
24
24
  ```
25
25
  create config/initializers/graph_weaver.rb
26
26
  create app/graphql/queries/.keep
27
+ create app/graphql/fragments/.keep
27
28
  create app/graphql/generated/.keep
28
29
  create graphql.config.yml
29
30
  introspect app/graphql/schema.json from https://api.example.com/graphql
@@ -57,7 +58,7 @@ What it wrote:
57
58
  into generated source, so they have to run first:
58
59
 
59
60
  ```ruby
60
- GraphWeaver.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
61
+ GraphWeaver.register_scalar("Money", Money) # a scalar the registry can't know
61
62
  ```
62
63
 
63
64
  A registration that names one of your own constants — a `T::Enum` for
@@ -84,13 +85,15 @@ What it wrote:
84
85
  RubyMine schema autocomplete, hover docs, and validation as you type in
85
86
  `.graphql` files — no JS project, no `npm install`. Details and the honest
86
87
  limits in [editors](editors.md).
87
- - **`app/graphql/queries/`, `app/graphql/generated/`.** Where you write
88
- queries and where generation writes Ruby.
88
+ - **`app/graphql/queries/`, `app/graphql/fragments/`, `app/graphql/generated/`.**
89
+ Where you write queries, where shared fragments live, and where generation
90
+ writes Ruby.
89
91
 
90
92
  Rake needs no wiring either: in Rails the `graph_weaver:*` tasks register
91
93
  themselves (a Railtie) and depend on `:environment`, so your initializer —
92
- and its registrations — runs first. The generated modules load at boot the
93
- same way, after `config/initializers`.
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.
94
97
 
95
98
  ### Your app's own schema, in-process
96
99
 
@@ -122,11 +125,16 @@ client = GraphWeaver.new(MyApp::Schema, context: { current_user: })
122
125
  PetQuery.execute!(client:, id: "1").pet.owner # => the context's user
123
126
  ```
124
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
+
125
132
  **Keep the dump in step with the schema.** Codegen reads the committed
126
133
  dump at `GraphWeaver.schema_path`, never the live class — that's what
127
134
  makes `rake graph_weaver:verify` a deterministic CI check. The generator
128
135
  writes the first dump; after that it's an artifact derived from code in
129
- your own repo, so rebuild it with graphql-ruby's own rake task:
136
+ your own repo, so rebuild it with graphql-ruby's own rake task, ahead of
137
+ `verify` in CI:
130
138
 
131
139
  ```ruby
132
140
  # lib/tasks/graphql.rake
@@ -140,13 +148,16 @@ rake graphql:schema:json # rewrites app/graphql/schema.json
140
148
  rake graph_weaver:generate
141
149
  ```
142
150
 
143
- Run the dump step ahead of `rake graph_weaver:verify` in CI that check
144
- compares committed Ruby against the committed dump, so a stale dump makes
145
- it fail on a query that is fine. `rake graph_weaver:queries:check` is
146
- unaffected: when `GraphWeaver.client` runs in-process it validates
147
- against the live class, not the dump. (`graph_weaver:schema:diff` and
148
- `:refresh` are for servers you *don't* own; a dump taken from a schema
149
- class records no url, and they say so.)
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.
150
161
 
151
162
  ### A schema dump you already have
152
163
 
@@ -174,19 +185,28 @@ query($id: ID!) {
174
185
  rake graph_weaver:generate # writes app/graphql/generated/person_query.rb
175
186
  ```
176
187
 
177
- Commit the schema dump and the generated files. Generated code is
178
- reviewed like any other code — and never edited by hand.
179
-
180
188
  ```ruby
181
189
  PersonQuery.execute!(id: "1").person&.name # typed, via GraphWeaver.client
182
190
  ```
183
191
 
184
- ### Autocomplete while you write the query
192
+ 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
195
+ [generated modules](generated_modules.md#naming).
185
196
 
186
197
  `graphql.config.yml` is already there, so VS Code and RubyMine validate the
187
198
  `.graphql` files as you type, with schema autocomplete and hover docs — see
188
199
  [editors](editors.md).
189
200
 
201
+ **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.
209
+
190
210
  ### Shared fragments
191
211
 
192
212
  Define reusable fragments once and spread them from any query:
@@ -205,29 +225,10 @@ Fragment files hold only fragments (no operations), and names are unique across
205
225
  them. Point elsewhere with `GraphWeaver.fragments_paths` (an appendable list,
206
226
  default `app/graphql/fragments`).
207
227
 
208
- ### Shared unions
209
-
210
- When a shared fragment *is* the whole selection on a union field, its type is
211
- hoisted once into the shared `GraphQLTypes` module and every query that spreads
212
- it aliases the same type — so a `union` selected across many queries becomes one
213
- Ruby type family, and you write one exhaustive `case … when … T.absurd` that
214
- works everywhere:
215
-
216
- ```graphql
217
- # app/graphql/fragments/feed_item.graphql
218
- fragment FeedItemFields on FeedItem {
219
- __typename
220
- ... on Post { title }
221
- ... on Photo { url }
222
- }
223
-
224
- # any query
225
- query { feed { ...FeedItemFields } } # feed : T::Array[FeedItemFields::Type]
226
- ```
227
-
228
- There's no flag: hoisting triggers when the union field's selection is exactly
229
- that one spread. Mix in other fields, or shadow the fragment with a query-local
230
- one of the same name, and the union stays inlined in that query — see
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
231
232
  [abstract types](generated_modules.md#abstract-types).
232
233
 
233
234
  ## 4. Test against fakes
@@ -241,47 +242,74 @@ require "graph_weaver/rspec"
241
242
  it "renders the empty state", graphql: :fake do … end # or tag the describe
242
243
  ```
243
244
 
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
251
+ [testing](testing.md).
252
+
244
253
  A fresh `rails g rspec:install` leaves the `spec/support` glob commented
245
254
  out in `spec/rails_helper.rb`, so uncomment it — or put the require in
246
255
  `rails_helper.rb` itself. Nothing warns you that a support file went
247
256
  unread.
248
257
 
249
- The tag installs a seeded, schema-correct `FakeClient` for that example —
250
- no server, no stubs, and `rspec --seed 1234` reproduces the fake data along
251
- with test order. The schema it fabricates from is derived (the committed
252
- dump, or your client's), so there's nothing to configure. Tag
253
- `graphql: :in_process` instead and the same example runs against your real
254
- resolvers; pin values with `overrides:`, simulate failures with `Failure.*`
255
- — see [testing](testing.md).
256
-
257
258
  ## 5. Verify in CI
258
259
 
259
- ```sh
260
- rake graph_weaver:verify # generated code fresh? fails on any drift
261
- rake graph_weaver:schema:diff # server drifted? re-introspects and compares
262
- rake graph_weaver:queries:check # did that drift break any of your queries?
260
+ Four questions, four tasks — the last only on a federated graph:
261
+
262
+ | ask | task | needs network |
263
+ |---|---|---|
264
+ | is the checked-in Ruby fresh? | `rake graph_weaver:verify` | no |
265
+ | has the server's schema drifted from the dump? | `rake graph_weaver:schema:diff` | yes |
266
+ | did that drift break any of my queries? | `rake graph_weaver:queries:check` | yes |
267
+ | did a subgraph change without a recompose? | `rake graph_weaver:federation:diff` | no |
268
+
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).
276
+
277
+ `schema:diff` names what moved, breaking changes first — breaking meaning
278
+ a query written against your dump stops validating, or stops casting:
279
+
263
280
  ```
281
+ app/graphql/schema.json vs https://api.example.com/graphql: 8 changes, 5 breaking
282
+
283
+ breaking:
284
+ AdoptionInput.nickname String -> String!
285
+ Person.email removed
286
+ Person.pets [Pet!]! -> [Pet!]
287
+ Query.person(includeArchived:) argument added: Boolean! — required
288
+ Species.CAT enum value removed
264
289
 
265
- Three different questions — four on a federated graph, where
266
- `rake graph_weaver:federation:diff` asks whether anyone changed a subgraph
267
- without recomposing the supergraph you committed. It needs no network
268
- either, so it belongs in the same PR run; see
269
- [federation](federation.md#has-the-supergraph-been-recomposed).
290
+ other:
291
+ Person.birthday deprecated: use bornOn
292
+ Pet.nickname added: String
293
+ Species.BIRD enum value added
270
294
 
271
- `graph_weaver:verify` compares the committed generated files against what the
272
- current schema + queries + registrations would produce. No network — run it in
273
- every CI build.
295
+ app/graphql/schema.json is stale — the server's schema has drifted (rake graph_weaver:schema:refresh)
296
+ ```
274
297
 
275
- `graph_weaver:schema:diff` asks whether the *server* has moved since the
276
- dump was taken. It needs network, a dump with a recorded source url
277
- (introspected dumps have one), and `GRAPHWEAVER_AUTH` for private APIs;
278
- run it on a schedule and refresh with `rake graph_weaver:schema:refresh`.
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.
279
303
 
280
- `graph_weaver:queries:check` answers the question that actually matters
281
- when it *has* moved: **which of your queries no longer validate, and
282
- why.** It re-introspects the recorded url (without rewriting the dump) and
283
- validates every `.graphql` file against the schema as it is right now,
284
- naming each error's line and column:
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.
307
+
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:
285
313
 
286
314
  ```
287
315
  app/graphql/queries/person.graphql
@@ -290,9 +318,6 @@ app/graphql/queries/person.graphql
290
318
  1 invalid query
291
319
  ```
292
320
 
293
- It exits non-zero when anything fails, so it drops straight into CI or a
294
- scheduled job.
295
-
296
321
  The Ruby behind it returns the same thing as data, so you can wire it into
297
322
  whatever you already have (a spec, a Slack ping, an issue):
298
323
 
@@ -303,18 +328,17 @@ GraphWeaver.check_queries
303
328
  # "line" => 4, "column" => 5 }] }
304
329
  ```
305
330
 
306
- Empty means everything validates. Pass `schema:` a loaded schema and nothing
307
- touches the network — handy for checking a *proposed* schema (a subgraph about
308
- to ship) before it's live:
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:
309
334
 
310
335
  ```ruby
311
336
  GraphWeaver.check_queries(schema: GraphWeaver::SchemaLoader.load("proposed.graphql"))
312
337
  ```
313
338
 
314
- It wants the loaded schema, not the path. Left off, it re-introspects the url
315
- the dump records and when that dump is a composed supergraph, each error also
316
- names the subgraphs behind the type it points at
317
- ([federation](federation.md#the-routing-table)).
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)).
318
342
 
319
343
  ## Sorbet, with or without
320
344
 
data/docs/logging.md CHANGED
@@ -14,8 +14,9 @@ What logs at which level — pick the level, get the story:
14
14
  | Level | What you see |
15
15
  |-------|--------------|
16
16
  | `debug` | the wire: query + variables per call (long queries truncated), response status/bytes, request timing, connection open/drop, dynamically parsed modules |
17
- | `info` | schema introspection (with timing) and cache hits/misses, generated files written and any unregistered scalars, query modules loaded |
18
- | `warn` | every GraphWeaver error raised — `TransportError`, `ServerError`, `QueryError`, `ValidationError`, `TypeError` |
17
+ | `info` | schema introspection (with timing) and cache hits/misses, the transport a client built, generated files written and any unregistered scalars, query modules loaded, a retry's wait and attempt number — and in development, what's being watched and what a save regenerated |
18
+ | `warn` | every GraphWeaver error raised — `TransportError`, `ServerError`, `QueryError`, `ValidationError`, `TypeError` — registrations the schema being generated against can't match, a retry skipped because the operation was a mutation, and every fetch the test router answered with fabricated data |
19
+ | `error` | development only: a `.graphql` edit that won't compile, with its file and position — the modules already loaded keep serving |
19
20
 
20
21
  Every line carries `graph_weaver` as the progname, so formatter-based
21
22
  filtering works out of the box. Wire lines are tagged
@@ -24,8 +25,37 @@ name — so a request's lines stay paired when threads interleave.
24
25
 
25
26
  **PII note**: queries, variables, and response sizes appear at debug
26
27
  only — variables can carry user data, so keep production loggers at
27
- info or above (or scrub in your formatter). Auth headers never log at
28
- any level.
28
+ info or above. Auth headers never log at any level.
29
+
30
+ ## Filtered variables
31
+
32
+ Debug gets switched on during an incident, which is exactly when a
33
+ `login(password:)` mutation's variables must not land in the log. So the
34
+ values of sensitive keys are replaced with `[FILTERED]` before the line is
35
+ written — at any depth, including inside input objects.
36
+
37
+ In Rails you configure nothing: the railtie adopts the app's own
38
+ `config.filter_parameters`, so GraphWeaver scrubs whatever the request logs
39
+ already scrub.
40
+
41
+ Everywhere else, one list:
42
+
43
+ ```ruby
44
+ GraphWeaver.filter_parameters = [:password, /token/]
45
+ ```
46
+
47
+ Strings and Symbols match as case-insensitive substrings — `:token` covers
48
+ `apiToken` — and Regexps match themselves. The default is `[:password,
49
+ :token, :secret, :authorization]`, which covers the usual names before
50
+ anyone configures anything; assigning replaces it rather than adding to it,
51
+ and `[]` turns filtering off. Anything answering `#filter(hash)` is used
52
+ as-is, which is how the railtie hands over an
53
+ `ActiveSupport::ParameterFilter`.
54
+
55
+ The same list scrubs error messages, which reach the log at `warn` rather
56
+ than `debug`: a variable, input field, or entity key whose name is filtered
57
+ is rejected with `[FILTERED]` in place of the value — everything else keeps
58
+ quoting it, since `expected an Int, got "lots"` is the whole diagnosis.
29
59
 
30
60
  ## Instrumentation
31
61
 
data/docs/real_world.md CHANGED
@@ -19,10 +19,10 @@ require "graph_weaver"
19
19
  # in a header, so a stale dump says where it came from.
20
20
  github = GraphWeaver.new("https://api.github.com/graphql", auth: `gh auth token`.strip, cache: true)
21
21
 
22
- # map GitHub's DateTime scalar onto Time (cast inferred from Time.parse).
23
- # Registrations are global and codegen-time, so this line types your
24
- # console and your checked-in code identically (docs/scalars.md).
25
- GraphWeaver.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
22
+ # GitHub's DateTime needs no registration it is one of the names the
23
+ # registry knows (docs/scalars.md). A scalar of your own would go here;
24
+ # registrations are global and codegen-time, so one line types your
25
+ # console and your checked-in code identically.
26
26
 
27
27
  RepoQuery = github.parse(<<~GRAPHQL)
28
28
  query($owner: String!, $name: String!) {