graph_weaver 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +40 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +474 -106
  70. metadata +56 -1
data/docs/scalars.md CHANGED
@@ -15,7 +15,13 @@ becomes a `BigDecimal`, how one goes back on the wire, and the
15
15
  `require "bigdecimal"` the generated file needs are all inferred.
16
16
 
17
17
  Registration is global and codegen-time: `rake graph_weaver:generate` reads the
18
- same registry an initializer writes, so register before you generate.
18
+ same registry an initializer writes, so register before you generate. A
19
+ registration that goes *missing* later — a reverted initializer line, a bad
20
+ merge — is loud at generate/verify time, which name the files it moves, and
21
+ silent forever after: code regenerated without it casts the field to the plain
22
+ wire type (a `String` where an `Email` was) and nothing raises anywhere.
23
+ [`verify_generated!`](generated_modules.md) in CI is what protects a
24
+ registration; no runtime assertion can.
19
25
 
20
26
  ## Already registered
21
27
 
@@ -35,8 +41,17 @@ schemas call an ISO 8601 timestamp.
35
41
  | `BigInt` | `Integer` | the decimal string graphql-ruby writes; a JSON number is read too |
36
42
  | `JSON` | `T.untyped` | whatever it is, untouched |
37
43
 
38
- A date stays a `Date` and a timestamp a `Time`, deliberately: casting a date to
39
- `Time` invents a midnight the server never sent. A schema that means something
44
+ A date stays a `Date` and a timestamp a `Time`, deliberately, and that holds in
45
+ both directions: casting a date to `Time` invents a midnight the server never
46
+ sent, and sending a `Time` for a date variable drops the time of day. Give one
47
+ for the other and it is refused, naming the class —
48
+ `$on of Report: expected a Date, got a Time — pass .to_date if dropping the
49
+ time of day is what you meant`. That holds when you register your own `cast:`
50
+ too: a cast says how the Ruby object is *built*, not which values are right, so
51
+ a `DateTime` — which Ruby files under `Date` — is refused for a `Date` scalar
52
+ however the codec is spelled. The refusal is about Ruby **objects**: a
53
+ timestamp *string* given for a `Date` parses and truncates to its date, which
54
+ is what graphql-ruby's own `ISO8601Date` does with it. A schema that means something
40
55
  else by one of these names fails loudly — the cast raises, naming the field —
41
56
  and one `register_scalar` overrides it, like any other entry. Names that are
42
57
  *not* a convention (`Timestamp`, `UUID`, `URL`, `Decimal`, `Money`) are left to
@@ -53,12 +68,21 @@ stands alone.
53
68
  | Ruby type | cast | serialize | require |
54
69
  |---|---|---|---|
55
70
  | `BigDecimal` | `BigDecimal(v)` | `v.to_s("F")` | `bigdecimal` |
56
- | `Date` | `Date.iso8601(v)` | `v.iso8601` | `date` |
57
- | `Time` | `Time.parse(v)` | `v.iso8601` | `time` |
58
- | `DateTime` | `DateTime.iso8601(v)` | `v.iso8601` | `date` |
71
+ | `Float` | `GraphWeaver::Coerce.float(v)` | | |
72
+ | `Date` | `Date.iso8601(v)` | `v.strftime("%F")` | `date` |
73
+ | `Time` | `Time.parse(v)` | `GraphWeaver::Coerce.timestamp(v)` | `time` |
74
+ | `DateTime` | `DateTime.iso8601(v)` | `GraphWeaver::Coerce.timestamp(v)` | `date` |
59
75
 
60
76
  For a timestamp, reach for `Time`; Ruby's own `DateTime` is accepted if you
61
- register it, but never assumed. `BigDecimal(v)` is Ruby's own reader, so
77
+ register it, but never assumed. They don't cost the same per value:
78
+ `DateTime.iso8601` measures about 1.6× `Date.iso8601`, and `Time.parse` — what
79
+ a `Time` registration infers, so what every `DateTime`/`ISO8601DateTime` field
80
+ already casts through — about 7×, since it is the tolerant reader rather than a
81
+ strict one. That is noise beside the `T::Struct` construction around it until
82
+ you're casting thousands of timestamps per response; there,
83
+ `register_scalar("Timestamp", Time, cast: :iso8601)` is about 3× cheaper than
84
+ `Time.parse` and refuses the looser forms, which is the trade.
85
+ `BigDecimal(v)` is Ruby's own reader, so
62
86
  it takes what Ruby takes — `"12.5"`, `"1e3"`, a JSON number — and refuses
63
87
  `"abc"` or `"$12.50"`, naming the field or the variable.
64
88
 
@@ -79,9 +103,18 @@ so a value object with a `.parse` needs nothing more:
79
103
  GraphWeaver.register_scalar("Money", Money)
80
104
  ```
81
105
 
106
+ **Give it `eql?` and `hash` too, not just `==`.** A result compares its props
107
+ with `eql?`, so that it and `#hash` agree on what "same" means — a class that
108
+ stops at `==` makes two results parsed from the same response unequal, and
109
+ useless as hash keys, while the `Money` inside them compares fine. Registration
110
+ warns when it spots one. `alias_method :eql?, :==` plus a `hash` built from the
111
+ same values is the whole fix.
112
+
82
113
  A type defining none of those stays pass-through rather than getting wrapped —
83
114
  every object has `#to_s`, so inferring a serializer off it would wrap plain
84
- types too. Override explicitly when you need to:
115
+ types too. That is about not *inventing* a codec, not about the cast being
116
+ optional: a class JSON can't parse into is still refused (below) the moment a
117
+ query reads that field. Override explicitly when you need to:
85
118
 
86
119
  - a `Symbol` method name, nothing to misspell: `cast: :load` → `Money.load(expr)`,
87
120
  `serialize: :to_json` → `expr.to_json`
@@ -89,9 +122,77 @@ types too. Override explicitly when you need to:
89
122
  - a `Proc` for anything a method name can't express: `cast: ->(expr) { "Money.new(#{expr})" }`
90
123
  - `:itself` to force pass-through, opting out of inference (rare)
91
124
 
125
+ Not every class is so obliging, and the money gem's `Money` is the honest hard
126
+ case: it defines none of those probes, so you say how one is built. **A cast can
127
+ only use what the wire carries**, and `Money.from_amount` needs a currency no
128
+ amount of Ruby recovers if the response didn't send one. So the scalar's shape
129
+ decides the registration, and three shapes carry it.
130
+
131
+ **An object** — `{"amount": "12.50", "currency": "EUR"}`. The cast reads both
132
+ out of it, and `serialize:` writes the same hash back:
133
+
134
+ ```ruby
135
+ GraphWeaver.register_scalar("Money", Money,
136
+ cast: ->(v) { "Money.from_amount(BigDecimal(#{v}[\"amount\"]), #{v}[\"currency\"])" },
137
+ serialize: ->(v) { "{ \"amount\" => #{v}.amount.to_s(\"F\"), \"currency\" => #{v}.currency }" })
138
+ ```
139
+
140
+ **One string carrying both** — `"12.50 EUR"`, split in the cast, with
141
+ `serialize: :to_s` writing it back when that is the spelling `Money#to_s` gives.
142
+
143
+ **An object type rather than a scalar** — `Money { amount currency }` — which
144
+ needs no `register_scalar` at all: codegen types both fields, and
145
+ [`extend_type`](generated_modules.md#type-helpers) adds the conversion. Ask for
146
+ this shape if you get a vote; the currency is then in the schema, where a reader
147
+ finds it.
148
+
149
+ ```ruby
150
+ GraphWeaver.extend_type("Money") { def to_money = ::Money.from_amount(BigDecimal(amount), currency) }
151
+ ```
152
+
153
+ **A bare decimal string** — `"12.50"` — carries no currency, so the cast has to
154
+ supply one. Reach for this only when the API really is single-currency, and say
155
+ so where the next reader will look:
156
+
157
+ ```ruby
158
+ # single-currency API: a Money in any other currency comes back mislabelled
159
+ GraphWeaver.register_scalar("Money", Money,
160
+ cast: ->(v) { "Money.from_amount(BigDecimal(#{v}), \"USD\")" },
161
+ serialize: :to_s)
162
+ ```
163
+
164
+ The `cast:` proc returns **source, not a value** — generated code is static, so
165
+ what comes back is the expression inlined into `from_h`, here
166
+ `Money.from_amount(BigDecimal(data.fetch("price")), "USD")`. `serialize: :to_s`
167
+ is the inverse, and it's the right one of three near-identical candidates:
168
+ `Money#to_s` writes a plain `"12.50"` — no symbol, no thousands separator, and
169
+ it ignores your app's `default_formatting_rules` — while `#to_d` and its alias
170
+ `#amount` hand back a `BigDecimal`, which reaches the wire as `"0.125e2"`.
171
+
172
+ **Register what your cast returns, not where the factory method lives.**
173
+ `register_scalar("URL", URI)` looks right and runs fine — `URI.parse` is a
174
+ probe hit — but `URI` is a *module*, and Sorbet's payload for it doesn't
175
+ `include Kernel`, so every call site that touches the prop fails `srb tc` with
176
+ "Method `nil?` does not exist on `URI`". The value is a `URI::Generic`, so
177
+ register that and say where it comes from:
178
+
179
+ ```ruby
180
+ GraphWeaver.register_scalar("URL", URI::Generic, cast: ->(v) { "URI.parse(#{v})" })
181
+ ```
182
+
183
+ `URI.parse` is ASCII-only, so a server that writes an un-escaped unicode path
184
+ (`https://example.com/café`) raises `URI must be ascii only` — a clean
185
+ `CastError` naming the field, but a refusal of a URL that is fine. Escape
186
+ before parsing (`URI::DEFAULT_PARSER.escape(#{v})`), or register
187
+ [Addressable](https://github.com/sporkmonger/addressable), which takes unicode
188
+ as it comes.
189
+
92
190
  The type also accepts a plain string (`"Money"`) when you'd rather not
93
- reference the class. `requires:` (a string or array) names files emitted as
94
- `require`s atop the generated source so the cast/type resolve. When the type is
191
+ reference the class which **skips inference entirely**, since there is no
192
+ class in hand to probe: a string-registered type with no `cast:` of its own has
193
+ none, and the refusal below says so rather than pretending it was probed.
194
+ `requires:` (a string or array) names files emitted as `require`s atop the
195
+ generated source so the cast/type resolve. When the type is
95
196
  a real class (so the runtime is loaded), each path is also `require`d at
96
197
  registration — a typo fails now, not in the generated file.
97
198
 
@@ -109,6 +210,24 @@ GraphWeaver.register_scalar("User.birthday", Date) # this field only
109
210
  A field override wins over the scalar-name registration — which is also how two
110
211
  servers that disagree about a `DateTime` coexist in one process.
111
212
 
213
+ A coordinate takes a **type string** too, which is how you narrow `JSON`. A
214
+ `JSON` scalar can legally be any JSON value — an object, an array, a string, a
215
+ number — so the registry's answer for the whole scalar has to stay `T.untyped`.
216
+ Where *you* know one field's shape, say it there:
217
+
218
+ ```ruby
219
+ GraphWeaver.register_scalar("Settings.meta", "T::Hash[String, T.untyped]")
220
+ ```
221
+
222
+ The prop becomes `T.nilable(T::Hash[String, T.untyped])`, so `srb tc` sees a
223
+ Hash at every call site, and a response carrying something else is refused
224
+ naming the struct instead of surfacing as a `NoMethodError` three layers on.
225
+ That's a trade rather than a free win: an array the scalar allowed is now a
226
+ hard failure — you asserted the shape, so being right about it is on you. It
227
+ also opts that field out of `:fake` fabrication, for the same reason any
228
+ non-stdlib type is: only you know which hashes the field really carries, so pin
229
+ it (`overrides: { "Settings.meta" => { ... } }`).
230
+
112
231
  Registrations are validated against the schema you generate against, and only
113
232
  what that schema can **disprove** fails generation: a name it declares as
114
233
  something else (`register_scalar("Species")` where `Species` is an enum), or a
@@ -128,11 +247,22 @@ above — `BigDecimal`, `Time`, `Date`, `Integer`, `Float`, `String`,
128
247
  A registration whose type is a class **JSON can't parse into**, with nothing to
129
248
  build one, is refused where a query reads that scalar back: the prop would be
130
249
  unsatisfiable for every response, and finding that out at runtime is worse.
131
- Generation names the field:
250
+ Generation names the field, and which of the two mistakes you made — a class
251
+ the probes missed:
132
252
 
133
253
  ```
134
- register_scalar("Money", Wallet) has no cast, so nothing builds a Wallet
135
- out of the JSON at Product.price — give it one ...
254
+ register_scalar("Money", Wallet) has no cast, so nothing builds a Wallet out of
255
+ the JSON at Product.price — Wallet defines no .parse and no .load, and Kernel
256
+ has no Wallet conversion function, so there was nothing to infer. Give it a
257
+ cast ...
258
+ ```
259
+
260
+ or a type given by name, which is never probed:
261
+
262
+ ```
263
+ register_scalar("Money", "Wallet") has no cast, so nothing builds a Wallet out
264
+ of the JSON at Product.price — a type: given by name is never probed, since
265
+ there is no class in hand. Pass the class ...
136
266
  ```
137
267
 
138
268
  A registration used only for a variable is untouched: nothing casts it.
@@ -182,7 +312,7 @@ server writing a non-integer where the spec says integer, so it is refused.
182
312
  | an enum | a declared value, as a string | an undeclared value, a non-string |
183
313
  | `JSON`, or unregistered | anything — `T.untyped`, straight through | nothing |
184
314
 
185
- A refusal is a [`GraphWeaver::TypeError`](errors.md) naming the field and the
315
+ A refusal is a [`GraphWeaver::CastError`](errors.md) naming the field and the
186
316
  generated struct (which names the query). Two refusals carry advice rather than
187
317
  only sorbet's words: an unquoted `ID`, and a registration with no cast (above).
188
318
 
@@ -206,39 +336,70 @@ the sig is `.checked(:never)`).
206
336
  | `String` | `String` | nothing | the string |
207
337
  | `ID` | `String` | an `Integer` — `execute(id: user.id)` | the string |
208
338
  | `Boolean` | `true`/`false` | nothing | the boolean |
209
- | `Date` | `Date` | an ISO-8601 string | `iso8601` |
210
- | `Time` | `Time` | a string `Time.parse` takes | `iso8601` |
339
+ | `Date` | `Date` | an ISO-8601 string | `"2024-01-15"` |
340
+ | `Time` | `Time` | a string `Time.parse` takes, a `DateTime`, `Time.zone.now` | ISO 8601, with microseconds when the value carries a fraction |
211
341
  | `BigInt` | `Integer` | a decimal string | the decimal string, which is what the server writes |
212
342
  | an enum | the member **or** its wire value | — | the wire value |
213
343
  | an input object | the struct **or** a Hash | — | the wire hash |
214
344
  | a registered custom scalar | its Ruby type | whatever its cast takes | what its serialize writes |
215
345
  | `JSON`, or unregistered | `T.untyped` | anything | straight through |
216
346
 
347
+ The **on the wire** column is also what a result's
348
+ [`#as_json`/`#to_json`](generated_modules.md#anatomy)
349
+ writes, so a result read back with `from_h` equals the one you rendered.
350
+
351
+ **Writing the scalar on the server too?** graphql-ruby calls a *nullable*
352
+ scalar argument's `coerce_input` with `nil` for an explicit `null` — only
353
+ `NonNull` short-circuits — so a coercer written the way the examples above are
354
+ (`value.upcase`, `Money.parse(value)`) raises `NoMethodError` on nil. Guard it,
355
+ or `:in_process` will show it to you as a `ServerError`.
356
+
217
357
  Two rows are judgment calls worth stating. **`ID` takes an `Integer`** because
218
358
  the GraphQL spec says an ID serializes as a string but accepts an integer input,
219
359
  and `execute(id: user.id)` off a model is the everyday call; `String` gets no
220
360
  such license, since an `Integer` where a `String` belongs is more often a bug
221
361
  than a spelling. **`Boolean` takes no string** — Ruby has no `Kernel#Boolean`,
222
362
  so every rule for reading `"0"`, `"off"`, `"no"` is somebody's convention, and
223
- the library will not pick one for you; convert at the call site.
363
+ the library will not pick one for you; convert at the call site. **A `Date` and
364
+ a `Time` are not each other** — one converts to the other only by dropping the
365
+ time of day or inventing a midnight, so a cross-type Ruby **object** is refused
366
+ rather than truncated. A timestamp *string* is a different question, answered
367
+ by the wire table above: it truncates. What *is* accepted for a `Time` is anything that already is one:
368
+ a `DateTime`, or the `ActiveSupport::TimeWithZone` that `Time.zone.now` returns.
224
369
 
225
370
  Anything the table refuses raises `GraphWeaver::InputError` naming the variable,
226
371
  the operation and the value — `$count of Compute: expected an Int, got "lots"`
227
372
  — which is the same [422 rescue point](errors.md) as a bad input-object field.
228
- Input-object fields go through this table too, so `{first: "20"}` inside a
229
- filter hash reads the same as `first: "20"` as a kwarg.
230
-
231
- `GraphWeaver.reset_registrations!` is the clean slate between tests, or between
232
- generations for different schemas: built-in scalars restored, enum mappings and
233
- type helpers dropped. To reset one registry rather than all of them,
373
+ It is named the way the **schema** names it, so a `register_scalar("Money",
374
+ BigDecimal)` field refuses a `Money`, in `#message` and in `#details[:type]`
375
+ alike. Input-object fields go through this table too, so `{first: "20"}` inside
376
+ a filter hash reads the same as `first: "20"` as a kwarg.
377
+
378
+ When it is the **server's** custom scalar that refuses, its
379
+ `GraphQL::CoercionError` earns a specific
380
+ [`kind`](errors.md#what-an-inputerror-says-without-reading-english) only where
381
+ its message matches one of graphql-ruby's own explanations, or the scalar
382
+ raises with `extensions: { "input" => … }`
383
+ ([the convention](errors.md#what-your-server-can-send)) itself — a scalar's own
384
+ wording arrives `:refused`, with that wording.
385
+
386
+ `GraphWeaver.reset_registrations!` is the clean slate between tests: built-in
387
+ scalars restored, enum mappings and type helpers dropped. `GraphWeaver.reset_graphs!`
388
+ is its twin for graphs declared with `GraphWeaver.graph`. To reset one registry
389
+ rather than all of them,
234
390
  `GraphWeaver::Codegen` has the pieces —
235
391
  `reset_scalars!` (restore the built-ins), `clear_scalars!` (empty the registry
236
392
  entirely), `reset_enums!`, `reset_type_helpers!`.
237
393
 
394
+ Scoping registrations to one of several schemas is not what this is for — a
395
+ [graph](getting_started.md#more-than-one-schema) block does that, and holds both
396
+ sets at once instead of resetting between them.
397
+
238
398
  A scalar you never register is not an error — it generates as `T.untyped` and
239
399
  the wire value passes through untouched. It is, though, the one hole in an
240
- otherwise exact result type, so generation names the holes at `info` (see
241
- [logging](logging.md)):
400
+ otherwise exact result type, so generation names the holes. `rake
401
+ graph_weaver:generate` and `:verify` print them once for the run, and a
402
+ `GraphWeaver.parse` says the same thing at `info` (see [logging](logging.md)):
242
403
 
243
404
  ```
244
405
  3 unregistered custom scalars → T.untyped: CountryCode, FuzzyDateInt, Json (register with GraphWeaver.register_scalar)
@@ -302,7 +463,10 @@ Two safety properties do the real work:
302
463
  - **`fallback:` for forward-compat**: `fallback: PetKind::Unknown` makes
303
464
  *casting* absorb wire values the server added after you generated —
304
465
  responses keep flowing instead of raising. Inputs stay strict either
305
- way: a typo'd input is your bug, not drift.
466
+ way: a typo'd input is your bug, not drift. A union or interface absorbs
467
+ the same drift without a registration: a member added upstream lands in the
468
+ catch-all `Other` its dispatch always carries
469
+ ([generated modules](generated_modules.md#abstract-types)).
306
470
 
307
471
  The translation tables are emitted into the generated source
308
472
  (`SPECIES_FROM_WIRE` / `SPECIES_TO_WIRE`) — reviewable in the diff, no