graph_weaver 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1470 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +21 -7
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +498 -105
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +32 -4
  15. data/docs/scalars.md +286 -57
  16. data/docs/testing.md +458 -59
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +330 -5
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +218 -59
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +408 -206
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +43 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +490 -116
  70. metadata +56 -1
data/docs/transports.md CHANGED
@@ -21,11 +21,14 @@ without one. `load_queries!` is the same rule over a directory.
21
21
 
22
22
  A *transport* is the network end of that contract — GraphQL-over-HTTP. The bundled
23
23
  two — `Transport::HTTP` (net/http, zero dependencies, loaded by default)
24
- and `Transport::Faraday` (opt-in) subclass `GraphWeaver::Transport`,
24
+ and `Transport::Faraday` (opt-in: naming it loads faraday, so an app that
25
+ doesn't needs no faraday) — subclass `GraphWeaver::Transport`,
25
26
  which owns the shared flow: encode the request, reclassify network
26
27
  failures as `TransportError`, raise `ServerError` on non-2xx, parse the
27
28
  body. A subclass only implements `post(body) => [status, body]` — that's
28
- the whole recipe for bringing your own HTTP client.
29
+ the whole recipe for bringing your own HTTP client. Return the response
30
+ headers as a third element, downcased, and `ServerError#headers` carries
31
+ them; two elements is still a complete answer.
29
32
 
30
33
  ## One-shot setup: a client
31
34
 
@@ -41,20 +44,32 @@ lazily, and `parse`/`run` bound to both. A `Client` answers the client
41
44
  contract itself, so it goes anywhere a transport does — `Retry.new(client)`,
42
45
  `subgraphs:`, a cassette recorder.
43
46
 
44
- - `auth:` — a token; "Bearer" is assumed unless the string carries its own
45
- scheme (`"Basic dXNlcjpwYXNz..."`)
47
+ - `auth:` — a token, or something answering `#call` that returns one per
48
+ request; "Bearer" is assumed unless it carries its own scheme
49
+ (`"Basic dXNlcjpwYXNz..."`)
46
50
  - `transport:` — `:http` (the default) or `:faraday`
47
51
  - `headers:` — anything else (API keys, custom headers)
48
- - `retries:` — off by default; a count (`retries: 3`), or `true` for the
49
- default count. Every other [`Retry`](#retries) option sits beside it
50
- (`backoff:`, `retry_codes:`, ...)
52
+ - `retries:` — off by default; every other [`Retry`](#retries) option sits
53
+ beside it (`backoff:`, `retry_codes:`, ...)
51
54
  - `open_timeout:` / `read_timeout:` — seconds, defaulting to 10 and 30 on
52
55
  either transport
56
+ - `pool_size:` — how many sockets the bundled HTTP transport keeps open,
57
+ defaulting to `RAILS_MAX_THREADS` (else 5). Refused with
58
+ `transport: :faraday`, whose adapter owns its own connections — a ceiling
59
+ here would be a number nothing reads
53
60
  - `cache:` / `ttl:` — schema introspection caching (see
54
61
  [real world](real_world.md)); url clients only — a schema source never
55
62
  introspects, so passing them raises
56
63
  - a block customizes the Faraday connection (Faraday only — raises without it)
57
64
 
65
+ They combine, so the whole thing is still one call:
66
+
67
+ ```ruby
68
+ GraphWeaver.new(url, transport: :faraday, retries: 2) do |conn|
69
+ conn.response :logger
70
+ end
71
+ ```
72
+
58
73
  What you pass is what you get; the client logs which transport it built at
59
74
  `info`.
60
75
 
@@ -138,7 +153,55 @@ requires a conforming client to accept, with the legacy type as
138
153
  fallback), and `User-Agent: graph_weaver/<version>` so a server operator
139
154
  can attribute the traffic. Anything you pass in `headers:` wins over
140
155
  these. A prebuilt `Faraday::Connection` owns its own headers; only the
141
- ones it leaves unset are filled in.
156
+ ones it leaves unset are filled in — and Faraday's stock
157
+ `User-Agent: Faraday v…`, which it fills in for every connection whether
158
+ you asked or not, counts as unset.
159
+
160
+ **Who the graph thinks is calling.** Both transports also send
161
+ `apollographql-client-name` and `apollographql-client-version`, which is
162
+ what an Apollo Router or GraphOS keys client attribution on — per-client
163
+ SLOs and rate limits, and "who still asks for this deprecated field". The
164
+ name is your Rails application's (`Storefront`), or `graph_weaver` outside
165
+ Rails, since Apollo means the consuming *application*; the version is the
166
+ gem's, because graph_weaver can't know what your app calls its releases.
167
+ Both are plain headers, so `headers:` overrides them — which is how one app
168
+ names its several clients apart:
169
+
170
+ ```ruby
171
+ GraphWeaver::Transport::HTTP.new(url, headers: {
172
+ "apollographql-client-name" => "storefront-checkout",
173
+ "apollographql-client-version" => ENV.fetch("GIT_SHA"),
174
+ })
175
+ ```
176
+
177
+ **A header that expires.** A header *value* may be anything answering `#call`,
178
+ on either transport, resolved per request rather than captured when the
179
+ transport was built — the same way a graph's [`schema`](federation.md) takes a
180
+ lambda. A value (or a call) of `nil` sends no such header; anything else is
181
+ sent as its `to_s`, so a numeric tenant id needs no ceremony:
182
+
183
+ ```ruby
184
+ GraphWeaver::Transport::HTTP.new(url, headers: {
185
+ "Authorization" => -> { "Bearer #{Tokens.fetch}" }, # rotating token
186
+ "X-Tenant" => -> { Current.tenant&.id }, # nil ⇒ header omitted
187
+ })
188
+ ```
189
+
190
+ `auth:` is that header under a shorter name, so a rotating credential is
191
+ `GraphWeaver.new(url, auth: -> { Tokens.fetch })`. A prebuilt
192
+ `Faraday::Connection` owns its own headers, so a rotating credential there is
193
+ Faraday's middleware (`conn.request :authorization, "Bearer", -> { ... }`).
194
+
195
+ **Compression and proxies** need no configuration on either transport.
196
+ `net/http` — which both use underneath — asks for `gzip`/`deflate` on every
197
+ request and decodes what comes back, and it reads `http_proxy` / `HTTPS_PROXY`
198
+ and `no_proxy` from the environment. A proxy is never used for a loopback
199
+ address, which is Ruby's rule, not ours.
200
+
201
+ **The endpoint an error names** is the url with its userinfo and any secret
202
+ query parameter folded to `[FILTERED]` — see [errors](errors.md). `#url` on a
203
+ transport stays the real endpoint; `#safe_url` is the one that goes in a log
204
+ line, an exception or an APM payload.
142
205
 
143
206
  **Request body.** `{"query": ..., "variables": ...}`, plus
144
207
  `"operationName"` when the operation has a name — the field Apollo Studio,
@@ -148,6 +211,40 @@ module at generation, so the name is declared in the query too. A raw query
148
211
  string handed straight to a transport falls back to the name in the document,
149
212
  and a genuinely anonymous one sends no `operationName` key at all.
150
213
 
214
+ **Variables have to be JSON.** The body is one `application/json` document, so
215
+ every variable value, at any depth, must be something JSON carries: a string,
216
+ a number, a boolean, null, a list, an object — or a value with an honest
217
+ string form, which is how a `Date`, a `Time`, a `BigDecimal` or a `Symbol`
218
+ travels. A `File`, an `IO`, a `Pathname` or a plain object is refused before
219
+ the body is built, naming the variable: JSON would otherwise render it as its
220
+ `#to_s`, so `$file` reaches the server as `"#<File:0x00007f…>"` and is stored
221
+ as if it meant something. graph_weaver does not implement the [GraphQL
222
+ multipart request
223
+ spec](https://github.com/jaydenseric/graphql-multipart-request-spec), so an
224
+ `Upload!` argument needs your own transport or a separate upload endpoint —
225
+ registering a scalar can't help, because multipart restructures the whole
226
+ request rather than one value.
227
+
228
+ **No persisted-query id goes with it**, so a gateway safelist configured with
229
+ `require_id` refuses every request this client makes; automatic persisted
230
+ queries (APQ) are an optimization, so those just never kick in. Until the gem
231
+ sends one, `post` is the seam — it sees the encoded body and can put the hash
232
+ beside it:
233
+
234
+ ```ruby
235
+ class APQ < GraphWeaver::Transport::HTTP
236
+ def post(body)
237
+ request = JSON.parse(body)
238
+ sha = Digest::SHA256.hexdigest(request.fetch("query"))
239
+ extensions = { "persistedQuery" => { "version" => 1, "sha256Hash" => sha } }
240
+ status, response, headers = super(JSON.generate(request.except("query").merge("extensions" => extensions)))
241
+ return [status, response, headers] unless response.to_s.include?("PersistedQueryNotFound")
242
+
243
+ super(JSON.generate(request.merge("extensions" => extensions))) # register on miss
244
+ end
245
+ end
246
+ ```
247
+
151
248
  **Concurrency.** One transport is normally the whole app's transport
152
249
  (`GraphWeaver.client = api`), so it has to serve every thread.
153
250
  `Transport::HTTP` opens up to `pool_size:` sockets lazily and reuses the
@@ -165,6 +262,16 @@ Rails sizes its own connection pool from, because it is the same question:
165
262
  how many requests this process can have in flight at once. Lower it for a
166
263
  server that counts connections.
167
264
 
265
+ **The pool is fork-safe**, which is what a Puma or Unicorn worker under
266
+ `preload_app!` needs. A socket warmed before the fork — an initializer that
267
+ introspects the schema is enough — is otherwise inherited by every worker, and
268
+ nothing in a round trip says which process opened it, so two workers
269
+ interleaving on one fd hand each other's answers back. A child notices the pid
270
+ changed and starts over: the inherited sockets are **abandoned rather than
271
+ closed** (closing would take down the fd the parent is still using) and
272
+ reconnect on first use, and the permits are rebuilt, since any held at fork time
273
+ went with the threads that held them. There is no `after_fork` hook to write.
274
+
168
275
  Under a fiber scheduler (`async`, Falcon) everything here works unchanged —
169
276
  `SizedQueue`, `Mutex`, `net/http` and `Kernel#sleep` are all scheduler-aware,
170
277
  so requests multiplex on one thread at thread-equivalent throughput. But
@@ -181,8 +288,14 @@ takes a `Client` or any bare transport/fake):
181
288
  1. per call: `execute(client: some_client, ...)` — a kwarg like the
182
289
  variables, and a name no GraphQL variable is allowed to take
183
290
  2. per module: `MyQuery.client = something`
184
- 3. baked constant: `Codegen.generate(..., client: MyApi::CLIENT)`
185
- 4. the app default: `GraphWeaver.client=`
291
+ 3. a test mode's stand-in: under `graphql: :fake` / `:in_process` /
292
+ `:router`, built from the graph this module was generated from
293
+ 4. baked constant: `Codegen.generate(..., client: "MyApi::CLIENT")` — the
294
+ constant's *name*, not the object, because generated source spells it
295
+ 5. the app default: `GraphWeaver.client=`
296
+
297
+ The mode replaces what codegen baked in, not what your example said — 1 and 2
298
+ still win.
186
299
 
187
300
  Nothing set anywhere raises, naming the two you'd usually reach for:
188
301
  `no client configured — set GraphWeaver.client= or pass a client`.
@@ -209,12 +322,29 @@ GraphWeaver.new(
209
322
  `GraphWeaver::Retry.new(inner_transport, ...)` takes the same options and
210
323
  wraps any client/transport directly — the client just passes them along.
211
324
 
212
- Defaults: transport failures always retry; `ServerError` on 5xx plus
213
- **408 and 429** — the rest of 4xx is a bug in the request, retrying
214
- won't fix it. `retry_codes:` re-inspects response envelopes so
215
- GraphQL-level throttling can retry too (off by default pass the codes
216
- your API uses). Exhausting the retries re-raises the last error (or
217
- returns the last code-matched response).
325
+ Defaults: transport failures always retry; a response retries when its
326
+ status is 5xx or **408 or 429** — the rest of 4xx is a bug in the request,
327
+ retrying won't fix it. That's one rule for both shapes a failure arrives
328
+ in: raised as a `ServerError`, or returned in the envelope because the
329
+ server sent GraphQL errors alongside the status. Apollo Router does the
330
+ latter for everything it decides itself — rate limiting is `503` with a
331
+ `REQUEST_RATE_LIMITED` body — so a policy that read only the raised half
332
+ made exactly one attempt behind a router. `retry_codes:` adds the other
333
+ signal: error codes, at any status (off by default — pass the codes your
334
+ API uses, or `GraphWeaver::GraphQLError::THROTTLE_CODES`). Exhausting the
335
+ retries re-raises the last error (or returns the last response).
336
+
337
+ A `200` is never retried on its status, whatever it carries. A router that
338
+ gives up on a slow subgraph answers `200` with partial data and a
339
+ `GATEWAY_TIMEOUT` error: the caller already has an answer, and whether a
340
+ partial one is worth repeating is a judgment only the caller can make —
341
+ `retry_codes: ["GATEWAY_TIMEOUT"]` is how they say yes.
342
+
343
+ **Nothing else retries**, which is the half a script author needs: a `200`
344
+ the server stands behind, and an `InputError` (the variables never left the
345
+ process), are permanent by construction — the identical request gets the
346
+ identical answer. Only a failure the server itself marked transient, by
347
+ status or by code, is worth repeating.
218
348
 
219
349
  `retries:` counts the attempts *after* the first, so
220
350
  `GraphWeaver.new(url, retries: 3)` makes up to four and `retries: 0` never
@@ -229,20 +359,35 @@ it, and a second `charge` is worse than a failed one.
229
359
  `retry_mutations: true` opts an idempotent API back in; the skipped
230
360
  retry says so on the logger.
231
361
 
362
+ The cap is on **attempts**, not on a kind of failure: a mutation gets its one
363
+ attempt whatever `retry_on:` says, so a `ServerError` is not retried either —
364
+ not a 500, not a 429 that named a `Retry-After`. `retry_mutations: true` puts
365
+ the mutation back on the same budget as a query, for every one of them.
366
+
367
+ **Idempotency is the server's.** GraphWeaver never reads an `idempotencyKey`
368
+ input: it is an argument like any other, and nothing in the client
369
+ deduplicates on it. So before turning `retry_mutations: true` on for a
370
+ checkout, the *server* has to dedupe on that key. And either way a failed
371
+ response does not mean nothing happened — the request that timed out was
372
+ still delivered, so the order may exist behind the error your controller
373
+ rendered. Reconcile; don't assume.
374
+
232
375
  **`Retry-After` wins over the backoff.** When the server names a delay
233
376
  (seconds or an HTTP-date), that's the wait — the server is the only
234
377
  party that knows when its window reopens. It's clamped to `max_delay:` so a
235
378
  "come back in an hour" can't park a thread for an hour, and not
236
379
  jittered, since it's an instruction rather than a guess.
237
380
 
238
- `ServerError` carries the response `#headers` (names downcased), so the
239
- rate-limit budget and request id are in hand without monkey-patching a
240
- transport:
381
+ `ServerError` carries the response `#headers`, so the rate-limit budget and
382
+ request id are in hand without monkey-patching a transport. Look one up in
383
+ whatever casing the server used — field names are case-insensitive; iterating
384
+ them yields the downcased spelling:
241
385
 
242
386
  ```ruby
243
387
  rescue GraphWeaver::ServerError => e
244
388
  e.throttled? # 429, or 503 + Retry-After
245
389
  e.retry_after # seconds, or nil
390
+ e.headers["Retry-After"] # == e.headers["retry-after"]
246
391
  e.headers["x-ratelimit-remaining"]
247
392
  end
248
393
  ```
data/docs/upgrading.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Upgrading
2
2
 
3
+ [Regenerate](#regenerate-on-every-upgrade) whichever version you're on, then
4
+ read the one section that is yours: from [0.6.1](#upgrading-from-061), from
5
+ [0.5.1](#upgrading-from-051), or [to 0.5.0](#upgrading-to-050) from anything
6
+ older.
7
+
3
8
  ## Regenerate on every upgrade
4
9
 
5
10
  **Any release can change what codegen emits.** Patch releases included — most of
@@ -19,6 +24,326 @@ moved. That's the reminder working, not a false alarm.
19
24
  Generation is deterministic, so the diff is exactly what the new version emits
20
25
  differently and nothing else — worth reading rather than rubber-stamping.
21
26
 
27
+ ## Upgrading from 0.6.1
28
+
29
+ Mostly mechanical. Everything that wants your hands, or changes under you, is
30
+ one row below; read the left column and skip what isn't yours. A typical app
31
+ ticks two or three.
32
+
33
+ | applies if you… | what changed |
34
+ |---|---|
35
+ | run a Rails app that configures no logger or instrumenter | **you start logging one info line per GraphQL call** — a production log-volume change, [first bullet below](#behavior-that-changed-under-you) |
36
+ | wrap a gateway in `Retry` | **a 5xx/429 that arrives with an errors body retries now** — real traffic, [below](#behavior-that-changed-under-you); `retries: 0` opts out |
37
+ | commit a schema dump introspected through a url carrying a token | **refresh it, and rotate the token if that file was pushed** — the dump recorded the url verbatim |
38
+ | keep a schema dump deliberately behind your own schema class | `verify` fails on it now |
39
+ | check in a composed supergraph as your dump | `schema:refresh` refuses it rather than overwriting it with the API schema — recompose instead |
40
+ | use `@oneOf` input types and commit a `.json` dump | `@oneOf` starts being enforced client-side once you regenerate |
41
+ | call `result.to_json`, or `render json: result` | it is the wire shape now, not `#inspect` or your prop names |
42
+ | send a `File`, `IO` or `Pathname` as a variable | refused at the wire, where `JSON.generate` used to ship its `#to_s` |
43
+ | pin a lowercase type name in `graphql_fake` | it works now; a near-miss *keyword* raises `ArgumentError` |
44
+ | tag specs `graphql: false`, or set `config.default_mode = nil` | both refused — [renames](#renames) |
45
+ | `rescue GraphWeaver::TypeError` or `GraphWeaver::ValidationError` | both constants are gone, with no alias — [renames](#renames) |
46
+ | subscribe to `"graph_weaver.execute"` | the event is `"execute.graph_weaver"` — [renames](#renames) |
47
+ | index a hash by an `InputError`'s `#field` | it names the input field now, not the variable — **nothing raises** |
48
+ | read an `InputError`'s `#details[:type]` | it is the GraphQL type now, never a Ruby class — **nothing raises** |
49
+ | read `payload[:status]` in an instrumentation subscriber | it is a Symbol; the HTTP status moved to `:http_status` — **nothing raises** |
50
+ | call `respond_to?` on a result struct | it stopped answering true for props that don't exist — **nothing raises** |
51
+ | generate a module with a baked `client:` | a `graphql:` tag now reaches it |
52
+ | set `config.context`, `config.schema` or `config.router` from a `before` hook | all three refused — they are suite setup |
53
+ | pass a `DateTime` where the schema says `Date` | refused — pass `.to_date` |
54
+ | register a scalar with your own `cast:`/`serialize:` | the same guard as the built-ins, and a proc that returns a value is refused |
55
+ | have a field named `class`, `hash`, `display`, `to_json`, `each` or `supplied` | the prop takes a trailing underscore |
56
+ | have an entity `@key` that selects through a list | the kwarg is a list now, not one hash — regenerate |
57
+ | build a type helper with `extend_type("Widget") { … }` | its constant is named for its graph and its type — regenerate |
58
+ | adopt `GraphWeaver.graph` | every queries directory then needs one |
59
+ | write `config.graph_weaver.<anything but watch>` | refused at boot |
60
+ | pass `SUPERGRAPH=` to any task but `federation:*` | refused, where it was ignored — a CI step that did it goes red |
61
+ | compare a `Testing::Router` error hash whole in a spec | a subgraph error carries `extensions: {"service" => …}` now |
62
+ | pass `seed:` to `graphql_router(fake: …)` | refused |
63
+ | require `graph_weaver/rspec` from `spec/support/` | check the glob is uncommented — rspec-rails ships it commented out |
64
+ | adopt `graphql: :wire` | it needs `require "webmock/rspec"`, not just the gem |
65
+
66
+ Then five commands, in order:
67
+
68
+ ```sh
69
+ # 1. the two renames your own code holds
70
+ grep -rn "GraphWeaver::TypeError\|GraphWeaver::ValidationError" app lib spec
71
+ grep -rn "graph_weaver.execute" app lib config spec # the old event name
72
+
73
+ # 2. rewrite the dump: it drops a credential the url carried, and picks up
74
+ # isOneOf. Skip only if your dump is SDL and records no source url — and
75
+ # a composed supergraph refuses, since introspection can't rebuild one:
76
+ # `rover supergraph compose` is what rewrites that.
77
+ rake graph_weaver:schema:refresh
78
+
79
+ # 3. regenerate — also the graph name in every module, the underscored
80
+ # reserved props, as_json, and the client: and cast:/serialize: refusals
81
+ rake graph_weaver:generate
82
+
83
+ # 4. the renamed tag, the deleted nil, the seed: refusal
84
+ bundle exec rspec
85
+
86
+ # 5. the gate: red while any checked-in file is still what 0.6.1 wrote
87
+ rake graph_weaver:verify
88
+ ```
89
+
90
+ **Two kinds of file answer that first grep, and only one needs your hands.**
91
+ Hits under your generated directory (`app/graphql/generated/` by default) are the
92
+ old names in machine-written code — step 3 rewrites them. Hits anywhere else are
93
+ yours: `CastError` and `QueryValidationError`, renamed by hand.
94
+
95
+ ### Renames
96
+
97
+ | before | after |
98
+ |---|---|
99
+ | `graphql: false` (rspec tag) | `graphql: :live` — the opt-out is your own client, which is a mode like the other four; `false` is refused, naming it |
100
+ | `config.default_mode = nil` | `config.default_mode = :live`, which is now the **default** — every example has exactly one mode, and `nil` is no longer a value it reads back |
101
+ | `GraphWeaver::TypeError` | `GraphWeaver::CastError` — the response wouldn't cast into the generated structs; the old name shadowed a core class it doesn't descend from. No alias: the old constant is gone, so a stale `rescue` is a `NameError` |
102
+ | `GraphWeaver::ValidationError` | `GraphWeaver::QueryValidationError` — build time, the *query* against the schema. Your input's validation is `InputError`. No alias here either |
103
+ | `"graph_weaver.execute"` | `"execute.graph_weaver"` — `<event>.<namespace>`, the way every notification in this ecosystem is spelled, and what `LogSubscriber.attach_to` and an APM's namespace routing key on. Subscribe through `GraphWeaver::EXECUTE_EVENT` and there is nothing to rename; a hardcoded string silently stops matching |
104
+
105
+ ### Behavior that changed under you
106
+
107
+ The first one reaches every Rails app that never configured logging, and it is
108
+ the only one here that shows up in production rather than in your code.
109
+
110
+ - **A Rails app logs one line per GraphQL call, and emits one notification.**
111
+ The railtie now sets `GraphWeaver.instrumenter` to the
112
+ `ActiveSupport::Notifications` adapter and attaches
113
+ `GraphWeaver::LogSubscriber`, so an app that configured neither gets
114
+ `GraphWeaver billing/InvoicesQuery (12.3ms) ok` at **info** — one line per
115
+ operation, carrying nothing that can hold PII; the query and variables stay
116
+ at debug. An instrumenter you set yourself is never replaced, and **to opt
117
+ out, set `GraphWeaver.logger = nil` or `GraphWeaver.instrumenter = nil` in
118
+ `config/initializers`** — which now takes effect, so an app that worked
119
+ around it with `config.after_initialize { GraphWeaver.logger = nil }` can
120
+ drop that. In-process calls are in scope too: a bare schema class in a client
121
+ slot (`GraphWeaver.client = MyApp::Schema`, `execute!(client: MyApp::Schema)`,
122
+ a graph's `client "Billing::Schema"`) goes through the same wrapper
123
+ `GraphWeaver.new(MyApp::Schema)` always used, so it produces events and log
124
+ lines where it produced none. See [logging](logging.md).
125
+ - **A `Retry` in front of a gateway starts actually retrying.** It read only
126
+ the failures that *raised*, and Apollo Router answers everything it decides
127
+ itself with a GraphQL errors body — rate limiting is `503` plus
128
+ `REQUEST_RATE_LIMITED`, its own faults are `500` plus a code — so the body
129
+ won over the status and `retries: 3` made one attempt. A response retries now
130
+ when its status is one a `ServerError` retries on (5xx, 408, 429), or when
131
+ its error codes are named in `retry_codes:`. A `200` is never retried on
132
+ status, and a mutation still gets one attempt unless `retry_mutations:
133
+ true`. **This is real traffic you weren't sending** — if the inert policy was
134
+ what you wanted, `retries: 0`. `#throttled?` answers
135
+ `REQUEST_RATE_LIMITED` too.
136
+ - **A task that can't honour `SUPERGRAPH=` refuses instead of ignoring it.**
137
+ The flag reaches the `federation:*` tasks and nothing else, so
138
+ `SUPERGRAPH=public.graphql rake graph_weaver:queries:check` reported every
139
+ query valid against a supergraph missing a field they select — the wrong
140
+ answer wearing a green tick. **A CI step that passes it to `generate`,
141
+ `verify` or `queries:check` goes red**; drop the flag, or declare the
142
+ supergraph on a graph so every run finds it.
143
+ - **`InputError#field` names the input field, not the variable.** It is now
144
+ `#path`'s last *named* segment — the slot that actually held the bad value,
145
+ which is the one a form highlights — where it used to be re-branded on the
146
+ way out with the *variable* name. Nothing raises; the value just differs once
147
+ a refusal happens inside an input object. **Read `error.path.first` wherever
148
+ you wanted the variable**, and `#field` wherever you wanted the field. On a
149
+ refusal that never got past the variable the two are the same, which is why
150
+ this can pass unnoticed until the first nested input fails. An index is a
151
+ position rather than a field, so it never becomes one: `execute(ids: [1, 2,
152
+ "x"])` reports `#path` `["ids", 2]` and `#field` `"ids"`. **Self-check:**
153
+ `grep -rn "\.field" app lib` — every hit that indexes or compares an
154
+ `InputError`'s `#field` is a place to decide which of the two you meant.
155
+ - **The instrumentation payload's `:status` is a Symbol, and the HTTP status
156
+ moved to `:http_status`.** `:status` is now `:ok`, `:errors` (the response
157
+ came back carrying GraphQL errors) or `:failed` (it raised) — a 200 carrying
158
+ errors is not a success, and only a symbol says that on both sides of the
159
+ seam. Nothing raises: a subscriber comparing it to an Integer just stops
160
+ matching. **A subscriber that branched on `payload[:status] == 200`, or on a
161
+ 4xx/5xx, reads `:http_status` now** — which is nil in-process, where
162
+ `:status` used to be a fabricated 200 so one subscriber could read both
163
+ sides. The whole payload is a documented contract now; see
164
+ [logging](logging.md#the-payload). **Self-check:** nothing subscribing to
165
+ `execute.graph_weaver` means nothing to change — this reaches subscribers
166
+ only.
167
+ - **`respond_to?` on a result struct no longer answers true for a name that
168
+ doesn't exist.** It used to say true for any near miss, which broke the
169
+ standard duck-typing guard — `obj.pet if obj.respond_to?(:pet)` raised the
170
+ very `NoMethodError` the hint exists to explain. **A branch that read the old
171
+ answer now takes the other path**, and `struct.method(:nmae)` raises Ruby's
172
+ bare `NameError` rather than a hinted one; `struct.nmae` still hints.
173
+ - **A `graphql:` tag reaches a module generated with `client:`.** The baked
174
+ client used to sit above the slot a tag swaps, so a bound module ran against
175
+ its real endpoint under `graphql: :fake`. **If a spec relied on that**, it now
176
+ runs against the fake — pass `client:` on the call, set `MyQuery.client =`, or
177
+ tag the example `graphql: :live`.
178
+ - **`config.context`, `config.schema` and `config.router` are suite setup.**
179
+ Setting any of the three once an example is running refuses, naming the
180
+ per-example helper (`graphql_context`, `graphql_fake(schema:)`,
181
+ `graphql_router(fake:)`). The tag builds an example's clients in a `before`
182
+ hook of its own, which rspec runs ahead of any group `before`, so a set there
183
+ was read too late and silently changed nothing — a `config.context` that
184
+ never reached a resolver, a `config.schema` the fake never saw. The refusal
185
+ replaces a line that wasn't working. **Move it to an `around`, or to
186
+ `GraphWeaver::Testing.configure` in the spec helper**; `configure` and
187
+ `around` are unchanged.
188
+ - **`result.to_json` is real JSON, and it is the wire shape.** It used to be
189
+ Ruby's `Object#to_json` — the `#inspect` string, quoted — so a log line or a
190
+ cache write stored nothing, with no exception and no warning; under Rails
191
+ `render json: result` instead shipped the *Ruby* prop names, trailing
192
+ underscores included. Both now produce the response keys, each leaf back
193
+ through its scalar registration's `serialize:`, so
194
+ `Result.from_h(JSON.parse(result.to_json)) == result`. `#to_h` is unchanged
195
+ and still the Ruby view. **Anything that parsed the old output, or diffed a
196
+ cached copy of it, is reading something different now** — and `as_json` is
197
+ emitted code, so a struct generated by 0.6.1 raises `GraphWeaver::Error`
198
+ naming this until you regenerate.
199
+ - **A schema dump introspected through a credentialed url still holds the
200
+ token.** The provenance stamp wrote the transport's url verbatim, so a url
201
+ carrying userinfo or an `?access_token=` landed in a file that gets
202
+ committed. It records the endpoint bare now — userinfo and any query
203
+ parameter `filter_parameters` filters are dropped — and re-introspection
204
+ still authenticates from the dump's `auth_env`. **Run `rake
205
+ graph_weaver:schema:refresh` once, and rotate the token if that file was ever
206
+ pushed.**
207
+ - **`verify` fails when the dump has fallen behind the schema class it was
208
+ built from.** For an app that serves its own schema the dump is an artifact
209
+ derived from code in the same repo, and everything downstream reads it, so
210
+ `generate` and `verify` both called a tree up to date while the live
211
+ resolvers had already moved. **A dump you deliberately keep behind your own
212
+ schema is a red gate now** — `rake graph_weaver:schema:refresh`, or ask about
213
+ no dump at all with `verify_generated!(schema:)`. It costs one in-process
214
+ introspection per graph and never a network call.
215
+ - **`@oneOf` starts being enforced if your dump is `.json`.** graphql-ruby's
216
+ introspection query omits `isOneOf` unless asked, and its loader drops the
217
+ field even when it is there, so every dump this gem has written said "not
218
+ @oneOf" for every input object and the enforcing struct was never generated.
219
+ **Regenerate (`rake graph_weaver:schema:refresh && rake
220
+ graph_weaver:generate`) and the emitted `ONE_OF` starts refusing calls that
221
+ set two fields** — which your server was refusing all along, so the failure
222
+ moves from the wire into `execute`. SDL dumps, inline SDL and a live class
223
+ were always correct.
224
+ - **A fake pin is told from an option by a schema lookup, not by casing.** The
225
+ rule was "a dot or a leading capital is a pin", so a lowercase type could not
226
+ be pinned at all: `graphql_fake("pokemon_v2_pokemon" => …)` against a Hasura
227
+ API came back as `a fake doesn't take pokemon_v2_pokemon:`. Those pins work
228
+ now. The other side of it: **a keyword that is a near-miss for a pin
229
+ (`Persn: "Ada"`) raises `ArgumentError` from the fake** rather than
230
+ `GraphWeaver::Error` from the override check — the same key written in the
231
+ leading positional hash is unchanged, and is the spelling for a schema whose
232
+ vocabulary collides with an option name.
233
+ - **Regenerate**, as ever — generated modules carry a private `GRAPH` naming the
234
+ graph they were generated from, and a [multi-schema](getting_started.md#more-than-one-schema)
235
+ app whose modules predate it refuses rather than guessing which schema a
236
+ module belongs to. A generated `execute` also makes its request through the
237
+ gem now (`from_response(dispatch(variables, client:))`), which is what lets
238
+ an event name the graph; 0.6.1's modules keep working as they are, but `rake
239
+ graph_weaver:verify` reports the tree out of date until you regenerate.
240
+ Result structs also gained `==`/`eql?`/`hash`, `deconstruct_keys`, `#to_h`
241
+ and `#as_json`, and the emitted guard in front of a `cast:` changed (below).
242
+ - **Check that your `require "graph_weaver/rspec"` actually runs.** The old
243
+ setup put it in `spec/support/graph_weaver.rb`, and rspec-rails ships the
244
+ `spec/support` glob **commented out** — so if you never uncommented it, the
245
+ tag did nothing and every `graphql: :fake` example has been hitting the real
246
+ client. `rails g graph_weaver:install` now writes the line into
247
+ `spec/rails_helper.rb` instead; **move yours there** if the glob isn't live.
248
+ - **`graphql: :wire`, if you adopt it, needs webmock *enabled*** — `require
249
+ "webmock/rspec"` in the spec helper. Having it in the Gemfile is not enough:
250
+ `Bundler.require` loads webmock without installing its adapters, and the tag
251
+ refuses before the first request rather than letting it leave the suite.
252
+ - **A `DateTime` given for a `Date` variable is refused.** `DateTime` is a
253
+ `Date` to Ruby, so it used to pass the cast untouched and go on the wire as
254
+ `"2024-01-15T10:20:30+00:00"` where the schema said `ISO8601Date` — a lenient
255
+ server truncated it, a strict one refused it. Truncating it here would be the
256
+ same guess made silently, so it now raises an `InputError` naming the class
257
+ and the fix: `$d of On: expected a Date, got a DateTime — pass .to_date if
258
+ dropping the time of day is what you meant`. **Pass `.to_date` where a
259
+ `DateTime` reaches a `Date` variable.** The pairings that already raised —
260
+ a `Time` for a date, a `Date` for a timestamp — now raise that branded
261
+ `InputError` rather than Ruby's *"no implicit conversion of Time into
262
+ String"*, and a `DateTime` or `Time.zone.now` for a *timestamp* converts
263
+ losslessly where it used to raise.
264
+ - **A `cast:` of your own gets the same guard and the same verdict.** A
265
+ registration like `register_scalar("Date", Date, cast: :iso8601, serialize:
266
+ :iso8601)` emitted a bare `value.is_a?(Date)` pass-through, so a `DateTime`
267
+ went by untouched and your `serialize:` wrote a full timestamp into a date
268
+ field — **pass `.to_date` there too**. Anything else wrong used to arrive as
269
+ Ruby's own sentence about an argument you never wrote (`no implicit
270
+ conversion of Integer into String`) under `kind: :unparseable`; the verdict
271
+ is the library's now and splits the way Ruby does — a `TypeError` from a
272
+ codec reads `expected a Date, got 5` under `kind: :type_mismatch`, an
273
+ `ArgumentError` keeps the parser's words under `:unparseable`.
274
+ **`#details[:type]` is the GraphQL type now, never a Ruby class** — a
275
+ `register_scalar("Money", BigDecimal)` field reads `"Money"`, not
276
+ `"BigDecimal"`, and an input object reads its schema name rather than the
277
+ class generated for it; the *message* still names the Ruby you may pass.
278
+ **A spec matching the old
279
+ message, or branching on `:unparseable` for a wrong class, needs updating**
280
+ — and the guard is emitted into your generated files, so a checked-in one
281
+ keeps the old behavior until you regenerate.
282
+ - **A field whose name a struct already answers to now generates as `name_`.**
283
+ `class` becomes the prop `class_`, `hash` becomes `hash_`, and so on for
284
+ `display`, `to_json`, `each` and (on an input) `supplied`. Nothing that used
285
+ to work stops working: a key you aliased in the query to get past the old
286
+ *"alias it in the query"* refusal still generates from that alias — **drop
287
+ the alias and regenerate** if you want the field's own name back. Only the
288
+ Ruby name moves; the wire keeps the schema's spelling in both directions, so
289
+ `result.class` is still Ruby's `class` and `result.class_` is the field. The
290
+ prop is the field's one Ruby name, so `.coerce({ class_: … })` and a result's
291
+ `#to_h` and pattern matching all use it. An `InputError`'s structured half is
292
+ the wire's throughout, so a refusal on that field reports `#path` `["class"]`
293
+ and `#coordinate` `"Tricky.class"`. An **input** struct's `#to_h`
294
+ is the wire hash it would send, `{"class" => …}`, and input structs don't
295
+ pattern-match. Input types had no way past the old refusal at all, so a
296
+ schema with a `class` column — a Hasura `bool_exp` has one input field per
297
+ column — generates for the first time. The names that take an underscore are
298
+ a list the gem owns, rather than whatever `T::Struct` answered to in the
299
+ generating process: deriving them made generation depend on require order, so
300
+ with ActiveSupport loaded first a key named `asJson` was refused and loaded
301
+ second it became a prop that shadowed the real `#as_json`. The list is what a
302
+ struct answers — `T::Struct` and `Object`'s public instance methods, the
303
+ hooks Ruby and Rails call on an object that doesn't define one (`initialize`,
304
+ `to_ary`, `to_hash`, `to_json`, `as_json`, `to_param`, `try`, `presence`,
305
+ `each`, `deconstruct_keys`), and the methods the gem's own mixins define — so
306
+ a few more names move than 0.6.1 touched. Kernel's *private* methods are not
307
+ on it: `format`, `select`, `test`, `open`, `load` and `pp` are ordinary
308
+ column names, and the gem's mixins qualify their own calls (`Kernel.raise`)
309
+ so a prop may take one. A federation `@key` on such a field follows the same
310
+ rule instead of being refused: the kwarg takes the underscore
311
+ (`Representations.room(class_: …)`) and `"class"` still goes on the wire, so
312
+ **regenerate if a `@key` of yours names one**. Generated source marks each
313
+ rename on the line above the prop — `# wire: class — reserved as a prop
314
+ name` — so **read the regenerate diff** rather than grepping for the names
315
+ yourself.
316
+ - **If you adopt `GraphWeaver.graph`, every queries directory needs a graph.**
317
+ Declaring one replaces the implicit graph your top-level settings describe,
318
+ so an app that declares a graph beside its existing `app/graphql/queries`
319
+ leaves that directory unread — `generate` skipping it, `verify` calling the
320
+ tree up to date. `generate!`, `verify_generated!` and `check_queries` refuse
321
+ instead, naming the stray files. **Name the directory in a graph
322
+ (`queries`/`output`), declare a graph for it, or delete it.** An app that
323
+ declares no graph is unaffected.
324
+ - **A `client` that isn't a constant is refused at generation.** Its value is
325
+ spelled into every module the graph generates, so `client` given an endpoint
326
+ url emitted a file that doesn't parse, from a run that reported success.
327
+ Declare the constant and name it — `CLIENT = GraphWeaver.new(url)`, then
328
+ `client "CLIENT"` — which is what the message says.
329
+ - **A `cast:` or `serialize:` proc that returns a value is refused at
330
+ registration.** A proc there builds *source* for the generated file, so
331
+ `cast: ->(v) { v.to_sym }` interpolated to nothing and every response failed
332
+ far from the registration, blaming the codec. It is probed once when
333
+ registered now: return the source (`cast: ->(v) { "Money.parse(#{v})" }`) or
334
+ name a method instead (`cast: :parse`).
335
+ - **`config.graph_weaver` refuses a key the railtie doesn't read**, at boot. It
336
+ takes `watch`; `config.graph_weaver.queries_paths = …` was taken silently and
337
+ did nothing, so the refusal replaces a line that wasn't working — in every
338
+ spelling of that write, `config.graph_weaver[:queries_paths] = …` included.
339
+ **Move it to `GraphWeaver.queries_paths =`**, which is what the message says.
340
+ - **A router's `fake:` refuses `seed:`**, as `graphql_fake` already did. A
341
+ router is built once for the suite, so a seed inside
342
+ `graphql_router(fake: …)` would pin every example to one run — `rspec --seed
343
+ 1234` reproduces the fabricated data along with the test order, and
344
+ `GraphWeaver::Testing.config.seed` is the override for a harness that isn't
345
+ rspec.
346
+
22
347
  ## Upgrading from 0.5.1
23
348
 
24
349
  Much smaller than 0.5.0, and mostly mechanical. Three commands find most of it:
@@ -157,14 +482,14 @@ are emitted `private_constant`, so **regenerate**.
157
482
  for the type** — `Testing.config.overrides = { "Money" => "12.00" }`, or the
158
483
  same key on one example's `graphql_fake`. Without one, `FakeClient` and
159
484
  cassette anonymization refuse rather than feeding your cast a `"Money-1"`
160
- placeholder. Scalars registered as `Time`, `Date`, `Integer`, `Float`,
161
- `String` or `T::Boolean` need nothing.
485
+ placeholder. Scalars registered as `BigDecimal`, `Time`, `Date`, `Integer`,
486
+ `Float`, `String` or `T::Boolean` need nothing.
162
487
  - **Re-run `rake graph_weaver:cassettes:anonymize`** on any committed cassette
163
488
  holding a registered custom scalar: the anonymizer used to write a value the
164
489
  generated codec couldn't read back.
165
490
  - **Generation refuses four more things**, each naming its fix — a
166
- `register_scalar` whose Ruby type nothing can build out of JSON (`BigDecimal`,
167
- classically: give it a `cast:`), a result key that would shadow a constant the
491
+ `register_scalar` whose Ruby type nothing can build out of JSON (a value
492
+ object of your own: give it a `cast:`), a result key that would shadow a constant the
168
493
  file uses, an enum value that camelizes to nothing, and a narrowed fragment
169
494
  whose `__typename` sits behind `@skip`/`@include`.
170
495
 
@@ -283,7 +608,7 @@ carries it — `SearchQuery::Result::Search::Species` is `GraphQLTypes::Species`
283
608
  |---|---|
284
609
  | `Testing.config.auto_fake = true` | `Testing.config.default_mode = :fake` |
285
610
  | a mutation's `…Query` module | `…Mutation` |
286
- | `graphql: :none` (rspec tag) | `graphql: false` |
611
+ | `graphql: :none` (rspec tag) | `graphql: :live` |
287
612
 
288
613
  **The shared types module was three, and is now one.** `GraphQLInputs`,
289
614
  `GraphQLEnums` and `GraphQLUnions` are all `GraphQLTypes`, and the files move
data/graph_weaver.gemspec CHANGED
@@ -40,12 +40,19 @@ Gem::Specification.new do |s|
40
40
  s.add_development_dependency "debug"
41
41
  s.add_development_dependency "faker"
42
42
  s.add_development_dependency "faraday"
43
+ s.add_development_dependency "rack" # WebMock's to_rack needs it; webmock doesn't depend on it
43
44
  s.add_development_dependency "rake"
45
+ # spec/railtie_spec.rb boots a real Rails application: the railtie's bug of
46
+ # record was Rails' initializer TSort putting graph_weaver.logger after
47
+ # config/initializers, which a stand-in cannot model. Brings activesupport,
48
+ # which LogSubscriber is checked against for the same reason.
49
+ s.add_development_dependency "railties"
44
50
  s.add_development_dependency "redcarpet" # yard --markup markdown
45
51
  s.add_development_dependency "rspec"
46
52
  s.add_development_dependency "simplecov"
47
53
  s.add_development_dependency "sorbet"
48
54
  s.add_development_dependency "tapioca"
55
+ s.add_development_dependency "webmock" # graphql: :wire serves its Rack app through it
49
56
  s.add_development_dependency "webrick"
50
57
  s.add_development_dependency "yard"
51
58
  end