graph_weaver 0.7.0 → 0.7.2

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +4 -4
  3. data/README.md +40 -88
  4. data/docs/alternatives.md +1 -7
  5. data/docs/cassettes.md +54 -59
  6. data/docs/editors.md +32 -47
  7. data/docs/errors.md +261 -369
  8. data/docs/federation.md +650 -837
  9. data/docs/generated_modules.md +380 -463
  10. data/docs/getting_started.md +211 -428
  11. data/docs/i18n.md +114 -177
  12. data/docs/logging.md +127 -116
  13. data/docs/real_world.md +26 -39
  14. data/docs/scalars.md +277 -310
  15. data/docs/testing.md +343 -486
  16. data/docs/transports.md +203 -268
  17. data/docs/upgrading.md +211 -560
  18. data/examples/README.md +38 -0
  19. data/examples/countries.rb +39 -0
  20. data/examples/federation.rb +62 -0
  21. data/examples/github/generate.rb +20 -0
  22. data/examples/github/generated/star_mutation.rb +126 -0
  23. data/examples/github/generated/stargazers_query.rb +232 -0
  24. data/examples/github/generated/starred_query.rb +151 -0
  25. data/examples/github/queries/star.graphql +8 -0
  26. data/examples/github/queries/stargazers.graphql +22 -0
  27. data/examples/github/queries/starred.graphql +11 -0
  28. data/examples/github/run.rb +43 -0
  29. data/examples/github/setup.rb +18 -0
  30. data/examples/rick_and_morty.rb +57 -0
  31. data/graph_weaver.gemspec +12 -3
  32. data/lib/graph_weaver/client.rb +30 -1
  33. data/lib/graph_weaver/codegen/emit.rb +5 -11
  34. data/lib/graph_weaver/codegen.rb +23 -55
  35. data/lib/graph_weaver/context_seam.rb +54 -0
  36. data/lib/graph_weaver/errors.rb +23 -15
  37. data/lib/graph_weaver/federation.rb +11 -2
  38. data/lib/graph_weaver/graph.rb +39 -29
  39. data/lib/graph_weaver/in_process.rb +15 -9
  40. data/lib/graph_weaver/internal/endpoint.rb +7 -5
  41. data/lib/graph_weaver/internal/headers.rb +19 -0
  42. data/lib/graph_weaver/internal/test_clients.rb +7 -11
  43. data/lib/graph_weaver/internal.rb +81 -13
  44. data/lib/graph_weaver/log_subscriber.rb +10 -2
  45. data/lib/graph_weaver/logging.rb +33 -13
  46. data/lib/graph_weaver/query_module.rb +44 -23
  47. data/lib/graph_weaver/retry.rb +12 -8
  48. data/lib/graph_weaver/rspec.rb +13 -24
  49. data/lib/graph_weaver/schema_loader.rb +52 -14
  50. data/lib/graph_weaver/tasks.rb +10 -2
  51. data/lib/graph_weaver/testing/cassette.rb +28 -5
  52. data/lib/graph_weaver/testing/endpoint.rb +14 -13
  53. data/lib/graph_weaver/testing/fake_client.rb +33 -3
  54. data/lib/graph_weaver/testing/router.rb +7 -3
  55. data/lib/graph_weaver/testing.rb +12 -4
  56. data/lib/graph_weaver/transport/http.rb +2 -2
  57. data/lib/graph_weaver/transport.rb +47 -23
  58. data/lib/graph_weaver/version.rb +1 -1
  59. data/lib/graph_weaver.rb +32 -10
  60. metadata +16 -3
  61. data/CHANGELOG.md +0 -3801
data/docs/scalars.md CHANGED
@@ -1,33 +1,34 @@
1
1
  # Custom scalars
2
2
 
3
- Teach the generator how a GraphQL custom scalar deserializes into a rich
4
- Ruby object (and serializes back when used as a variable). A field typed
5
- `Decimal` then generates `const :price, T.nilable(BigDecimal)` and casts with
6
- `BigDecimal(...)` inline — no runtime reflection:
3
+ Teach the generator how a GraphQL custom scalar deserializes into a rich Ruby
4
+ object, and serializes back when used as a variable. Three registrations cover
5
+ almost every app:
7
6
 
8
7
  ```ruby
9
- GraphWeaver.register_scalar("Decimal", BigDecimal)
8
+ GraphWeaver.register_scalar("Decimal", BigDecimal) # a stdlib class
9
+ GraphWeaver.register_scalar("Money", Money) # a value object of your own
10
+ GraphWeaver.register_enum("Species", PetKind) # a schema enum onto your T::Enum
10
11
  ```
11
12
 
12
- Two arguments: the scalar's name in your schema, and the Ruby type it means.
13
- The second is the only part the library can't work out — how a wire value
14
- becomes a `BigDecimal`, how one goes back on the wire, and the
15
- `require "bigdecimal"` the generated file needs are all inferred.
13
+ `register_scalar` takes the scalar's name in your schema and the Ruby type it
14
+ means. The type is the only part the library can't work out: a field typed
15
+ `Decimal` then generates `const :price, T.nilable(BigDecimal)` and casts with
16
+ `BigDecimal(...)` inline no runtime reflection and the wire spelling and the
17
+ `require "bigdecimal"` the generated file needs come with it.
16
18
 
17
19
  Registration is global and codegen-time: `rake graph_weaver:generate` reads the
18
20
  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.
21
+ registration that goes *missing* later — a reverted initializer line, a bad merge
22
+ — is loud at generate/verify time and silent forever after: code regenerated
23
+ without it casts the field to the plain wire type (a `String` where an `Email`
24
+ was) and nothing raises anywhere. [`verify_generated!`](generated_modules.md) in
25
+ CI is what protects a registration; no runtime assertion can.
25
26
 
26
27
  ## Already registered
27
28
 
28
29
  These names need no registration. graphql-ruby ships all but `DateTime` as its
29
- own scalars, and `DateTime` is what GitHub, Shopify and most hand-written
30
- schemas call an ISO 8601 timestamp.
30
+ own scalars, and `DateTime` is what GitHub, Shopify and most hand-written schemas
31
+ call an ISO 8601 timestamp.
31
32
 
32
33
  | scalar | Ruby type | on the wire |
33
34
  |---|---|---|
@@ -41,29 +42,26 @@ schemas call an ISO 8601 timestamp.
41
42
  | `BigInt` | `Integer` | the decimal string graphql-ruby writes; a JSON number is read too |
42
43
  | `JSON` | `T.untyped` | whatever it is, untouched |
43
44
 
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
55
- else by one of these names fails loudly the cast raises, naming the field —
56
- and one `register_scalar` overrides it, like any other entry. Names that are
57
- *not* a convention (`Timestamp`, `UUID`, `URL`, `Decimal`, `Money`) are left to
58
- you, because guessing at one would be worse than asking.
45
+ **A date stays a `Date` and a timestamp a `Time`**, in both directions: casting a
46
+ date to `Time` invents a midnight the server never sent, and sending a `Time` for
47
+ a date variable drops the time of day. Give one for the other and it is refused,
48
+ naming the class `$on of Report: expected a Date, got a Time pass .to_date if
49
+ dropping the time of day is what you meant`. A `cast:` of your own doesn't change
50
+ that: a cast says how the object is *built*, not which values are right. The
51
+ refusal is about Ruby **objects**; a timestamp *string* given for a `Date` parses
52
+ and truncates to its date, as graphql-ruby's own `ISO8601Date` does.
53
+
54
+ A schema that means something else by one of these names fails loudly, and one
55
+ `register_scalar` overrides it like any other entry. Names that are *not* a
56
+ convention (`Timestamp`, `UUID`, `URL`, `Decimal`, `Money`) are left to you,
57
+ because guessing at one would be worse than asking.
59
58
 
60
59
  ## Registering a stdlib type
61
60
 
62
61
  Name the class and stop. What the library supplies is the part inference can't
63
- reach: the wire spelling (`BigDecimal#to_s` writes `"0.125e2"`, which is not
64
- what any server means by 12.5, and `Date.parse` reads far more than the ISO
65
- 8601 a `Date` scalar carries) and the file to require, so the generated source
66
- stands alone.
62
+ reach: the wire spelling (`BigDecimal#to_s` writes `"0.125e2"`, which is not what
63
+ any server means by 12.5) and the file to require, so the generated source stands
64
+ alone.
67
65
 
68
66
  | Ruby type | cast | serialize | require |
69
67
  |---|---|---|---|
@@ -73,18 +71,16 @@ stands alone.
73
71
  | `Time` | `Time.parse(v)` | `GraphWeaver::Coerce.timestamp(v)` | `time` |
74
72
  | `DateTime` | `DateTime.iso8601(v)` | `GraphWeaver::Coerce.timestamp(v)` | `date` |
75
73
 
76
- For a timestamp, reach for `Time`; Ruby's own `DateTime` is accepted if you
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 cheaper than
84
- `Time.parse` and refuses the looser forms, which is the trade.
85
- `BigDecimal(v)` is Ruby's own reader, so
86
- it takes what Ruby takes — `"12.5"`, `"1e3"`, a JSON number — and refuses
87
- `"abc"` or `"$12.50"`, naming the field or the variable.
74
+ For a timestamp reach for `Time`; Ruby's own `DateTime` is accepted if you
75
+ register it, but never assumed. `Time.parse` is the tolerant reader, and about 7×
76
+ the cost of a strict onenoise until you are casting thousands of timestamps per
77
+ response, where `register_scalar("Timestamp", Time, cast: :iso8601)` is both
78
+ cheaper and narrower.
79
+
80
+ **A trailing zero doesn't survive the round trip.** A `BigDecimal` holds the
81
+ *number*, so `"10.00"` in comes back `"10.0"` numerically identical, textually
82
+ different, which matters only where the bytes are: diffing a request body, or
83
+ hashing one for a signature.
88
84
 
89
85
  ## Registering a class of your own
90
86
 
@@ -97,39 +93,58 @@ deserialize side and pairing its serializer:
97
93
  | `.load` | `Type.load(v)` | `Type.dump(v)` |
98
94
  | `Kernel#Type` | `Type(v)` | — |
99
95
 
100
- so a value object with a `.parse` needs nothing more:
96
+ so a value object with a `.parse` needs nothing more than
97
+ `GraphWeaver.register_scalar("Money", Money)`.
98
+
99
+ **Give it `eql?` and `hash` too, not just `==`.** A result compares its props with
100
+ `eql?`, so a class that stops at `==` makes two results parsed from the same
101
+ response unequal, and useless as hash keys, while the `Money` inside them compares
102
+ fine. Registration warns when it spots one; `alias_method :eql?, :==` plus a
103
+ `hash` built from the same values is the whole fix.
104
+
105
+ A type defining none of those probes stays pass-through rather than getting
106
+ wrapped — every object has `#to_s`, so inferring a serializer off it would wrap
107
+ plain types too. Override explicitly when you need to:
108
+
109
+ - a `Symbol` method name: `cast: :load` → `Money.load(expr)`, `serialize: :to_json`
110
+ - an `Array`, for a method with arguments: `serialize: [:to_s, "F"]` →
111
+ `expr.to_s("F")`
112
+ - a `Proc` for anything a method name can't express:
113
+ `cast: ->(expr) { "Money.new(#{expr})" }` — it returns **source, not a value**,
114
+ since what comes back is inlined into `from_h`
115
+ - `:itself` to force pass-through, opting out of inference (rare)
116
+
117
+ The type also accepts a plain string (`"Money"`) when you'd rather not reference
118
+ the class, which **skips inference entirely** — there is no class in hand to
119
+ probe. `requires:` (a string or array) names files emitted as `require`s atop the
120
+ generated source so the cast and type resolve; where the type is a real class each
121
+ path is also `require`d at registration, so a typo fails now rather than in the
122
+ generated file.
123
+
124
+ **Register what your cast returns, not where the factory method lives.**
125
+ `register_scalar("URL", URI)` looks right and runs fine — `URI.parse` is a probe
126
+ hit — but `URI` is a *module*, and Sorbet's payload for it doesn't `include
127
+ Kernel`, so every call site fails `srb tc` with "Method `nil?` does not exist on
128
+ `URI`". The value is a `URI::Generic`:
101
129
 
102
130
  ```ruby
103
- GraphWeaver.register_scalar("Money", Money)
131
+ GraphWeaver.register_scalar("URL", URI::Generic, cast: ->(v) { "URI.parse(#{v})" })
104
132
  ```
105
133
 
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
-
113
- A type defining none of those stays pass-through rather than getting wrapped —
114
- every object has `#to_s`, so inferring a serializer off it would wrap plain
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:
118
-
119
- - a `Symbol` method name, nothing to misspell: `cast: :load` → `Money.load(expr)`,
120
- `serialize: :to_json` → `expr.to_json`
121
- - an `Array`, for a method with arguments: `serialize: [:to_s, "F"]` → `expr.to_s("F")`
122
- - a `Proc` for anything a method name can't express: `cast: ->(expr) { "Money.new(#{expr})" }`
123
- - `:itself` to force pass-through, opting out of inference (rare)
134
+ `URI.parse` is ASCII-only, so a server writing an un-escaped unicode path raises
135
+ `URI must be ascii only` a clean `CastError`, but a refusal of a URL that is
136
+ fine. Escape in the cast (`URI::DEFAULT_PARSER.escape(#{v})`), or register
137
+ [Addressable](https://github.com/sporkmonger/addressable) instead.
124
138
 
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.
139
+ ### When the wire shape decides the registration
130
140
 
131
- **An object** `{"amount": "12.50", "currency": "EUR"}`. The cast reads both
132
- out of it, and `serialize:` writes the same hash back:
141
+ The money gem's `Money` is the honest hard case: it defines none of the probes,
142
+ and `Money.from_amount` needs a currency no amount of Ruby recovers if the
143
+ response didn't send one. **A cast can only use what the wire carries**, so the
144
+ scalar's shape decides what you write.
145
+
146
+ An **object** — `{"amount": "12.50", "currency": "EUR"}` — is read out in the cast
147
+ and written back by `serialize:`:
133
148
 
134
149
  ```ruby
135
150
  GraphWeaver.register_scalar("Money", Money,
@@ -137,22 +152,10 @@ GraphWeaver.register_scalar("Money", Money,
137
152
  serialize: ->(v) { "{ \"amount\" => #{v}.amount.to_s(\"F\"), \"currency\" => #{v}.currency }" })
138
153
  ```
139
154
 
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:
155
+ **One string carrying both** — `"12.50 EUR"` splits in the cast, with
156
+ `serialize: :to_s` writing it back. A **bare decimal string** carries no currency,
157
+ so the cast supplies one; reach for that only when the API really is
158
+ single-currency, and say so where the next reader will look:
156
159
 
157
160
  ```ruby
158
161
  # single-currency API: a Money in any other currency comes back mislabelled
@@ -161,140 +164,106 @@ GraphWeaver.register_scalar("Money", Money,
161
164
  serialize: :to_s)
162
165
  ```
163
166
 
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:
167
+ **An object type rather than a scalar** — `Money { amount currency }` — needs no
168
+ `register_scalar` at all: codegen types both fields, and
169
+ [`extend_type`](generated_modules.md#type-helpers) adds the conversion. Ask for
170
+ this shape if you get a vote; the currency is then in the schema, where a reader
171
+ finds it.
178
172
 
179
173
  ```ruby
180
- GraphWeaver.register_scalar("URL", URI::Generic, cast: ->(v) { "URI.parse(#{v})" })
174
+ GraphWeaver.extend_type("Money") { def to_money = ::Money.from_amount(BigDecimal(amount), currency) }
181
175
  ```
182
176
 
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
-
190
- The type also accepts a plain string (`"Money"`) when you'd rather not
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
196
- a real class (so the runtime is loaded), each path is also `require`d at
197
- registration — a typo fails now, not in the generated file.
177
+ **One `serialize:` serves both directions** the outbound variable *and* a
178
+ result's [`as_json`](generated_modules.md#anatomy), which is what makes
179
+ `from_h(JSON.parse(x.to_json)) == x` hold. So a scalar that sends an object and
180
+ accepts a string can't have both: `as_json` writes the string the cast can't read,
181
+ and the JSON round trip raises a `CastError` coming back (the wire itself is fine
182
+ in both directions). The same asymmetry decides the [`:fake` pin](testing.md#pins):
183
+ a pin stands in for a *result*, so pin what the server **sends**.
184
+
185
+ Any of this can also be flatly wrong: the *format* a `Money` string has to match
186
+ lives in the server's `coerce_input`, which no schema carries, so nothing before a
187
+ real request says whether the server wants `"12.50"`, `"12.50 USD"` or the object.
188
+ [Send one for real](#what-no-check-can-see).
198
189
 
199
190
  ## Overriding one field
200
191
 
201
- Pass a `Type.field` **coordinate** instead of a scalar name to override just
202
- that one field so the same scalar can deserialize as different Ruby types
203
- across fields:
192
+ Pass a `Type.field` **coordinate** instead of a scalar name to override just that
193
+ one field, so the same scalar can deserialize as different Ruby types across
194
+ fields — and so two servers that disagree about a `DateTime` can coexist in one
195
+ process:
204
196
 
205
197
  ```ruby
206
198
  GraphWeaver.register_scalar("Timestamp", Time) # the default, everywhere
207
199
  GraphWeaver.register_scalar("User.birthday", Date) # this field only
208
200
  ```
209
201
 
210
- A field override wins over the scalar-name registration — which is also how two
211
- servers that disagree about a `DateTime` coexist in one process.
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
-
231
- Registrations are validated against the schema you generate against, and only
232
- what that schema can **disprove** fails generation: a name it declares as
233
- something else (`register_scalar("Species")` where `Species` is an enum), or a
234
- coordinate whose field it declares as a composite. A name it simply can't match
235
- only warns — one registry serves a whole graph, so that name may belong to the
236
- subgraph next door (see
237
- [federation](federation.md#generating-for-a-federated-graph)).
238
-
239
- The testing harness can't invent a wire value for a scalar registered as your
240
- own class — only `Money.parse` knows what it accepts — so it refuses rather than
241
- guess. Say it in test config, where that answer belongs: a pin for the type,
242
- `GraphWeaver::Testing.config.overrides = { "Money" => "12.00" }`, or per example
243
- ([testing → pins](testing.md#pins)). A scalar registered as one of the types
244
- above — `BigDecimal`, `Time`, `Date`, `Integer`, `Float`, `String`,
245
- `T::Boolean` — needs nothing.
202
+ A coordinate takes a **type string** too, which is how you narrow `JSON`: the
203
+ scalar can legally be any JSON value, so the registry's answer for the whole scalar
204
+ stays `T.untyped`, but where *you* know one field's shape, say it there —
205
+ `register_scalar("Settings.meta", "T::Hash[String, T.untyped]")`. `srb tc` then
206
+ sees a Hash at every call site, and a response carrying something else is refused
207
+ naming the struct rather than surfacing as a `NoMethodError` three layers on.
208
+ That's a trade: an array the scalar allowed becomes a hard failure, and the field
209
+ opts out of `:fake` fabrication, so pin it
210
+ (`overrides: { "Settings.meta" => { ... } }`).
211
+
212
+ ## What generation refuses, and what it only warns about
213
+
214
+ Registrations are validated against the schema you generate against, and only what
215
+ that schema can **disprove** fails generation: a name it declares as something else
216
+ (`register_scalar("Species")` where `Species` is an enum), or a coordinate whose
217
+ field it declares as a composite. A name it simply can't match only warns — one
218
+ registry serves a whole graph, so that name may belong to the subgraph next door
219
+ (see [federation](federation.md#generating-for-a-federated-graph)).
246
220
 
247
221
  A registration whose type is a class **JSON can't parse into**, with nothing to
248
222
  build one, is refused where a query reads that scalar back: the prop would be
249
- unsatisfiable for every response, and finding that out at runtime is worse.
250
- Generation names the field, and which of the two mistakes you made a class
251
- the probes missed:
223
+ unsatisfiable for every response. The message names the field and which of the two
224
+ mistakes you made `Wallet defines no .parse and no .load, and Kernel has no
225
+ Wallet conversion function, so there was nothing to infer`, or, for a type given
226
+ by name, that a name is never probed. A registration used only for a variable is
227
+ untouched: nothing casts it.
228
+
229
+ A scalar you never register is not an error — it generates as `T.untyped` and the
230
+ wire value passes through untouched. It is the one hole in an otherwise exact
231
+ result type, so generation names the holes; `rake graph_weaver:generate` and
232
+ `:verify` print them once for the run, and `GraphWeaver.parse` says the same at
233
+ `info`:
252
234
 
253
235
  ```
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 ...
266
- ```
267
-
268
- A registration used only for a variable is untouched: nothing casts it.
269
-
270
- `cast:` is also what a *variable* of this scalar coerces through, so the same
271
- registration gets you both directions with nothing to switch on:
272
-
273
- ```ruby
274
- GraphWeaver.register_scalar("Money", Money)
275
- StoreQuery.execute(budget: "12.00") # Money.parse("12.00") under the hood
276
- StoreQuery.execute(budget: Money.new(1200)) # already a Money — passed straight through
236
+ 3 unregistered custom scalars T.untyped: CountryCode, FuzzyDateInt, Json (register with GraphWeaver.register_scalar)
277
237
  ```
278
238
 
279
- The kwarg is still typed `Money`, not `T.any(Money, String)`: `execute`'s sig
280
- stays as narrow as the schema and the conversion happens in its body (see
281
- [typed variables](generated_modules.md#variables-become-typed-kwargs)). So
282
- `budget: "12.00"` written literally in a `# typed:` file is still an `srb tc`
283
- error as it should be, since you have a `Money` right there while
284
- `budget: params[:budget]` typechecks and converts.
285
-
239
+ A scalar that is *meant* to be untyped belongs in the registry too —
240
+ `GraphWeaver.register_scalar("Json", "T.untyped")` says so once and leaves the
241
+ report. `JSON` is registered that way already.
242
+
243
+ The testing harness can't invent a wire value for a scalar registered as your own
244
+ class — only `Money.parse` knows what it accepts — so it refuses rather than
245
+ guess. Say it in test config: `Testing.config.overrides = { "Money" => "12.00" }`,
246
+ or per example ([testing → pins](testing.md#pins)). A scalar registered as one of
247
+ the stdlib types above needs nothing.
248
+
249
+ `GraphWeaver.reset_registrations!` is the clean slate between tests (built-in
250
+ scalars restored, enum mappings and type helpers dropped);
251
+ `GraphWeaver.reset_graphs!` is its twin for declared graphs, and
252
+ `GraphWeaver::Codegen` has the pieces for one registry rather than all of them —
253
+ `reset_scalars!`, `clear_scalars!`, `reset_enums!`, `reset_type_helpers!`. Scoping
254
+ registrations to one of several schemas is not what any of that is for: a
255
+ [graph](getting_started.md#more-than-one-schema) block does that, and holds both
256
+ sets at once instead of resetting between them.
286
257
 
287
258
  ## What the wire carries
288
259
 
289
- The rule is one sentence: **generated code takes every JSON spelling a
290
- spec-compliant server may write, and refuses the rest.** The tables below are
291
- the whole of it, and [`bin/round-trip`](../bin/round-trip) fuzzes both
292
- directions against them — the accepted spellings as real values, the refused
293
- ones under `--hostile`, where generated code has to name what it turned down.
260
+ One sentence: **generated code takes every JSON spelling a spec-compliant server
261
+ may write, and refuses the rest.** The tables below are the whole of it, and
262
+ [`bin/round-trip`](../bin/round-trip) fuzzes both directions against them.
294
263
 
295
- The one place "spec-compliant" is doing real work is `Float`. JSON has a single
264
+ The one place "spec-compliant" is doing real work is `Float`: JSON has a single
296
265
  number type and encoders write the shortest form, so `1.0` reaches Ruby as `1`
297
- from graphql-js and from Go. Nothing does the reverse: `2.0` for an `Int` is the
266
+ from graphql-js and from Go. Nothing does the reverse `2.0` for an `Int` is the
298
267
  server writing a non-integer where the spec says integer, so it is refused.
299
268
 
300
269
  ### Coming back — what `from_h` accepts
@@ -313,21 +282,18 @@ server writing a non-integer where the spec says integer, so it is refused.
313
282
  | `JSON`, or unregistered | anything — `T.untyped`, straight through | nothing |
314
283
 
315
284
  A refusal is a [`GraphWeaver::CastError`](errors.md) naming the field and the
316
- generated struct (which names the query). Two refusals carry advice rather than
317
- only sorbet's words: an unquoted `ID`, and a registration with no cast (above).
318
-
319
- Numeric strings — here, and in the going-out table below are read as a wire
320
- format, not as Ruby source: `"010"` is ten, and `"0x1f"` and `"1_0"` are
321
- refused. `Kernel#Integer` and `Kernel#Float` accept all three as literals,
322
- which would let a zero-padded form field silently mean something else.
285
+ generated struct (which names the query). Numeric strings here and in the table
286
+ below are read as a wire format, not as Ruby source: `"010"` is ten, and `"0x1f"`
287
+ and `"1_0"` are refused, where `Kernel#Integer` would take all three and let a
288
+ zero-padded form field silently mean something else.
323
289
 
324
290
  ### Going out — what a variable kwarg accepts
325
291
 
326
- The kwarg's **type** is what `srb tc` holds a call site to, and it is exactly
327
- what the schema says. The **value** reaching `execute` at runtime is coerced,
328
- because a Rails param is a String whatever the sig says (see
329
- [typed variables](generated_modules.md#variables-become-typed-kwargs) for why
330
- the sig is `.checked(:never)`).
292
+ The kwarg's **type** is what `srb tc` holds a call site to, and it is exactly what
293
+ the schema says. The **value** reaching `execute` at runtime is coerced, because a
294
+ Rails param is a String whatever the sig says (see
295
+ [typed variables](generated_modules.md#variables-become-typed-kwargs) for why the
296
+ sig is `.checked(:never)`).
331
297
 
332
298
  | scalar | kwarg is typed | also accepts, at runtime | on the wire |
333
299
  |---|---|---|---|
@@ -345,87 +311,95 @@ the sig is `.checked(:never)`).
345
311
  | `JSON`, or unregistered | `T.untyped` | anything | straight through |
346
312
 
347
313
  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
-
357
- Two rows are judgment calls worth stating. **`ID` takes an `Integer`** because
358
- the GraphQL spec says an ID serializes as a string but accepts an integer input,
359
- and `execute(id: user.id)` off a model is the everyday call; `String` gets no
360
- such license, since an `Integer` where a `String` belongs is more often a bug
361
- than a spelling. **`Boolean` takes no string** — Ruby has no `Kernel#Boolean`,
362
- so every rule for reading `"0"`, `"off"`, `"no"` is somebody's convention, and
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.
314
+ [`#as_json`/`#to_json`](generated_modules.md#anatomy) writes, so a result read back
315
+ with `from_h` equals the one you rendered.
316
+
317
+ Three rows are judgment calls. **`ID` takes an `Integer`** because the spec says an
318
+ ID serializes as a string but accepts an integer input, and `execute(id: user.id)`
319
+ off a model is the everyday call; `String` gets no such license. **`Boolean` takes
320
+ no string**, because every rule for reading `"0"`, `"off"`, `"no"` is somebody's
321
+ convention. **A `Date` and a `Time` are not each other**, as above; what *is*
322
+ accepted for a `Time` is anything that already is one.
369
323
 
370
324
  Anything the table refuses raises `GraphWeaver::InputError` naming the variable,
371
- the operation and the value — `$count of Compute: expected an Int, got "lots"`
372
- which is the same [422 rescue point](errors.md) as a bad input-object field.
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,
390
- `GraphWeaver::Codegen` has the pieces —
391
- `reset_scalars!` (restore the built-ins), `clear_scalars!` (empty the registry
392
- entirely), `reset_enums!`, `reset_type_helpers!`.
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.
325
+ the operation and the value — `$count of Compute: expected an Int, got "lots"`
326
+ which is the same [422 rescue point](errors.md) as a bad input-object field. The
327
+ type is named the way the **schema** names it, so a `register_scalar("Money",
328
+ BigDecimal)` field refuses a `Money`, in `#message` and in `#details[:type]` alike.
329
+ Input-object fields go through this table too, so `{first: "20"}` inside a filter
330
+ hash reads the same as `first: "20"` as a kwarg. When it is the **server's**
331
+ scalar that refuses, its `GraphQL::CoercionError` earns a specific
332
+ [`kind`](errors.md#what-an-inputerror-says-without-reading-english) only where its
333
+ message matches one of graphql-ruby's own explanations, or it raises with
334
+ `extensions: { "input" => … }`
335
+ ([the convention](errors.md#what-your-server-can-send)).
336
+
337
+ A custom scalar's `cast:` is what a *variable* of that scalar coerces through, so
338
+ one registration gets you both directions:
339
+
340
+ ```ruby
341
+ StoreQuery.execute(budget: "12.00") # Money.parse("12.00") under the hood
342
+ StoreQuery.execute(budget: Money.new(1200)) # already a Money passed straight through
343
+ ```
344
+
345
+ The kwarg is still typed `Money`, not `T.any(Money, String)`: the sig stays as
346
+ narrow as the schema and the conversion happens in `execute`'s body. So
347
+ `budget: "12.00"` written literally in a `# typed:` file is still an `srb tc` error
348
+ as it should be, since you have a `Money` right therewhile
349
+ `budget: params[:budget]` typechecks and converts.
350
+
351
+ **Writing the scalar on the server too?** graphql-ruby calls a *nullable* scalar
352
+ argument's `coerce_input` with `nil` for an explicit `null` — only `NonNull`
353
+ short-circuits — so a coercer written like the examples above raises
354
+ `NoMethodError` on nil. Guard it, or `:in_process` will show it to you as a
355
+ `ServerError`.
397
356
 
398
- A scalar you never register is not an error — it generates as `T.untyped` and
399
- the wire value passes through untouched. It is, though, the one hole in an
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)):
357
+ ## What no check can see
403
358
 
359
+ A custom scalar has two definitions that have to agree: the server's
360
+ `coerce_input`/`coerce_result`, and your `register_scalar`. **No schema carries the
361
+ first one.** A scalar's SDL is its name, a description and a `@specifiedBy` url —
362
+ the coercers are Ruby method bodies that never reach a dump — so a server
363
+ switching `coerce_result` from a decimal string to a JSON number, same scalar,
364
+ same name, moves nothing `verify`, `schema:diff` or `generate` reads. All three
365
+ stay green, and `BigDecimal` then takes the Float without complaint:
366
+
367
+ ```ruby
368
+ BigDecimal(BigDecimal("123456789.123456789").to_f).to_s("F") # => "123456789.1234567"
404
369
  ```
405
- 3 unregistered custom scalars → T.untyped: CountryCode, FuzzyDateInt, Json (register with GraphWeaver.register_scalar)
370
+
371
+ No `CastError`, no warning — just totals quietly wrong past the seventh
372
+ significant figure, which is the precision a string-valued `Decimal` exists to
373
+ protect.
374
+
375
+ The check is a request that runs the real coercers: one `graphql: :in_process`
376
+ example per registered scalar, round-tripping a value through the schema class.
377
+
378
+ ```ruby
379
+ it "round-trips a Money through the real server", graphql: :in_process do
380
+ price = Money.from_amount(BigDecimal("12.50"), "EUR")
381
+ expect(EchoPriceQuery.execute!(price:).echo_price).to eq price
382
+ end
406
383
  ```
407
384
 
408
- A scalar that is *meant* to be untyped belongs in the registry too —
409
- `GraphWeaver.register_scalar("Json", "T.untyped")` says so once, and it leaves
410
- the report. `JSON` is registered that way already.
385
+ Four lines, and it fails the moment either side moves. **`graphql: :fake` cannot
386
+ stand in for it**: a fake fabricates from your *client* registration alone, so it
387
+ hands back a value the real server would never send and accepts one the real
388
+ server would reject. It is shape-correct, never rule-correct. A
389
+ [cassette](cassettes.md) recorded against `:in_process` carries the rules to a
390
+ suite that can't boot the schema class.
411
391
 
412
392
  ## Enums: map onto your own T::Enum
413
393
 
414
- By default a schema enum generates one `T::Enum` per schema, shared by every
415
- query module that touches it (`GraphQLTypes::Species`, aliased as
416
- `AddPetMutation::Species`). That's fine until your app has its own domain
417
- enum, and then the boundary shuffle starts:
394
+ By default a schema enum generates one `T::Enum` per schema, shared by every query
395
+ module that touches it (`GraphQLTypes::Species`, aliased as
396
+ `AddPetMutation::Species`). That's fine until your app has its own domain enum —
397
+ one that is persisted, or matched in business logic — and then every call site
398
+ converts by hand, in both directions:
418
399
 
419
400
  ```ruby
420
- # your domain already speaks PetKind it's in your models, your
421
- # ActiveRecord enum column, your case statements
422
- class PetKind < T::Enum
423
- enums { Cat = new("cat"); Dog = new("dog") }
424
- end
425
-
426
- # without a mapping, every call site converts by hand, in both directions
427
- kind = PetKind.deserialize(pet.species.serialize.downcase) # response -> domain
428
- AddPetMutation.execute!(species: kind.serialize.upcase) # domain -> wire
401
+ kind = PetKind.deserialize(pet.species.serialize.downcase) # response -> domain
402
+ AddPetMutation.execute!(species: kind.serialize.upcase) # domain -> wire
429
403
  ```
430
404
 
431
405
  Register the mapping once and the seam disappears — generated code speaks your
@@ -434,43 +408,36 @@ enum everywhere, casting wire values in and serializing members out:
434
408
  ```ruby
435
409
  GraphWeaver.register_enum("Species", PetKind)
436
410
 
437
- pet.species # => PetKind::Dog — compare, case, persist directly
438
- pet.species == other_pet.species # same type across every query
411
+ pet.species # => PetKind::Dog — compare, case, persist directly
412
+ pet.species == other_pet.species # same type across every query
439
413
  AddPetMutation.execute!(species: PetKind::Cat) # or "CAT" — members and wire values both work
440
414
  ```
441
415
 
442
- **When to reach for it**: the enum has a life outside the API — it's
443
- persisted or matched in business logic. **When not to bother**: values you
444
- only read back out of responses; the generated enum is already one type
445
- across every query and needs zero setup.
416
+ For values you only ever read back out of responses, don't bother: the generated
417
+ enum is already one type across every query and needs zero setup.
446
418
 
447
419
  The mapping is inferred by name (`"CAT"` ↔ `PetKind::Cat`,
448
- case/underscore-insensitive against each member's serialized value), so
449
- aligned enums need only the one line. When names diverge, `map:` pins the
450
- exceptions and merges over inference:
451
-
452
- ```ruby
453
- GraphWeaver.register_enum("Species", PetKind, map: { "FELINE" => PetKind::Cat })
454
- ```
420
+ case/underscore-insensitive against each member's serialized value), so aligned
421
+ enums need only the one line. When names diverge, `map:` pins the exceptions and
422
+ merges over inference:
423
+ `GraphWeaver.register_enum("Species", PetKind, map: { "FELINE" => PetKind::Cat })`.
455
424
 
456
425
  Two safety properties do the real work:
457
426
 
458
- - **Exhaustiveness at generation**: every value the schema declares must
459
- resolve to a member, or generation fails naming the gaps
460
- (`PetKind has no member for Species value(s) DOG — add them, pin with
461
- map:, or absorb with fallback:`). Your enum drifting from the server's
462
- is caught at `rake graph_weaver:generate`, not in production.
463
- - **`fallback:` for forward-compat**: `fallback: PetKind::Unknown` makes
464
- *casting* absorb wire values the server added after you generated
465
- responses keep flowing instead of raising. Inputs stay strict either
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)).
470
-
471
- The translation tables are emitted into the generated source
472
- (`SPECIES_FROM_WIRE` / `SPECIES_TO_WIRE`) — reviewable in the diff, no
473
- runtime registry.
427
+ - **Exhaustiveness at generation**: every value the schema declares must resolve to
428
+ a member, or generation fails naming the gaps (`PetKind has no member for
429
+ Species value(s) DOG — add them, pin with map:, or absorb with fallback:`), so
430
+ your enum drifting from the server's is caught by `rake graph_weaver:generate`,
431
+ not in production.
432
+ - **`fallback:` for forward-compat**: `fallback: PetKind::Unknown` makes *casting*
433
+ absorb wire values the server added after you generated, so responses keep
434
+ flowing instead of raising. Inputs stay strict either way: a typo'd input is your
435
+ bug, not drift. A union or interface absorbs the same drift with no registration
436
+ a member added upstream lands in the catch-all `Other` its dispatch always
437
+ carries ([generated modules](generated_modules.md#abstract-types)).
438
+
439
+ The translation tables are emitted into the generated source (`SPECIES_FROM_WIRE` /
440
+ `SPECIES_TO_WIRE`) reviewable in the diff, no runtime registry.
474
441
 
475
442
  Decorating a generated *struct* with your own methods is the sibling API —
476
443
  `extend_type`, in [generated modules](generated_modules.md#type-helpers).