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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1470 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +21 -7
- data/docs/alternatives.md +201 -0
- data/docs/cassettes.md +17 -1
- data/docs/errors.md +382 -17
- data/docs/federation.md +469 -63
- data/docs/generated_modules.md +231 -15
- data/docs/getting_started.md +498 -105
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +32 -4
- data/docs/scalars.md +286 -57
- data/docs/testing.md +458 -59
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +330 -5
- data/graph_weaver.gemspec +7 -0
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +47 -10
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +218 -59
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +408 -206
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/errors.rb +264 -34
- data/lib/graph_weaver/federation.rb +119 -26
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +17 -11
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +78 -0
- data/lib/graph_weaver/internal/headers.rb +51 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +43 -4
- data/lib/graph_weaver/internal.rb +183 -1
- data/lib/graph_weaver/log_subscriber.rb +66 -0
- data/lib/graph_weaver/logging.rb +136 -12
- data/lib/graph_weaver/query_module.rb +36 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +33 -5
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +221 -49
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +6 -5
- data/lib/graph_weaver/testing/endpoint.rb +106 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +133 -44
- data/lib/graph_weaver/testing/router.rb +58 -11
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +46 -4
- data/lib/graph_weaver/transport.rb +109 -26
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +490 -116
- metadata +56 -1
data/docs/scalars.md
CHANGED
|
@@ -2,28 +2,232 @@
|
|
|
2
2
|
|
|
3
3
|
Teach the generator how a GraphQL custom scalar deserializes into a rich
|
|
4
4
|
Ruby object (and serializes back when used as a variable). A field typed
|
|
5
|
-
`
|
|
6
|
-
`
|
|
5
|
+
`Decimal` then generates `const :price, T.nilable(BigDecimal)` and casts with
|
|
6
|
+
`BigDecimal(...)` inline — no runtime reflection:
|
|
7
7
|
|
|
8
8
|
```ruby
|
|
9
|
-
GraphWeaver.register_scalar("
|
|
9
|
+
GraphWeaver.register_scalar("Decimal", BigDecimal)
|
|
10
10
|
```
|
|
11
11
|
|
|
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.
|
|
16
|
+
|
|
12
17
|
Registration is global and codegen-time: `rake graph_weaver:generate` reads the
|
|
13
|
-
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.
|
|
25
|
+
|
|
26
|
+
## Already registered
|
|
27
|
+
|
|
28
|
+
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.
|
|
31
|
+
|
|
32
|
+
| scalar | Ruby type | on the wire |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `ID` | `String` | the string (an `Integer` input is accepted) |
|
|
35
|
+
| `String` | `String` | the string |
|
|
36
|
+
| `Int` | `Integer` | a JSON integer |
|
|
37
|
+
| `Float` | `Float` | any JSON number |
|
|
38
|
+
| `Boolean` | `T::Boolean` | the boolean |
|
|
39
|
+
| `Date`, `ISO8601Date` | `Date` | `"2024-01-15"` |
|
|
40
|
+
| `DateTime`, `ISO8601DateTime` | `Time` | `"2024-01-15T10:20:30Z"` |
|
|
41
|
+
| `BigInt` | `Integer` | the decimal string graphql-ruby writes; a JSON number is read too |
|
|
42
|
+
| `JSON` | `T.untyped` | whatever it is, untouched |
|
|
43
|
+
|
|
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.
|
|
59
|
+
|
|
60
|
+
## Registering a stdlib type
|
|
61
|
+
|
|
62
|
+
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.
|
|
67
|
+
|
|
68
|
+
| Ruby type | cast | serialize | require |
|
|
69
|
+
|---|---|---|---|
|
|
70
|
+
| `BigDecimal` | `BigDecimal(v)` | `v.to_s("F")` | `bigdecimal` |
|
|
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` |
|
|
75
|
+
|
|
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 3× 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.
|
|
88
|
+
|
|
89
|
+
## Registering a class of your own
|
|
90
|
+
|
|
91
|
+
Pass the class and the cast/serialize are **inferred** from it, by probing the
|
|
92
|
+
deserialize side and pairing its serializer:
|
|
93
|
+
|
|
94
|
+
| the class defines | cast | serialize |
|
|
95
|
+
|-------------------|---------------|----------------|
|
|
96
|
+
| `.parse` | `Type.parse(v)` | `v.to_s` |
|
|
97
|
+
| `.load` | `Type.load(v)` | `Type.dump(v)` |
|
|
98
|
+
| `Kernel#Type` | `Type(v)` | — |
|
|
99
|
+
|
|
100
|
+
so a value object with a `.parse` needs nothing more:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
GraphWeaver.register_scalar("Money", Money)
|
|
104
|
+
```
|
|
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
|
+
|
|
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)
|
|
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
|
+
|
|
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.
|
|
198
|
+
|
|
199
|
+
## Overriding one field
|
|
14
200
|
|
|
15
201
|
Pass a `Type.field` **coordinate** instead of a scalar name to override just
|
|
16
202
|
that one field — so the same scalar can deserialize as different Ruby types
|
|
17
203
|
across fields:
|
|
18
204
|
|
|
19
205
|
```ruby
|
|
20
|
-
GraphWeaver.register_scalar("
|
|
21
|
-
GraphWeaver.register_scalar("User.birthday", Date)
|
|
206
|
+
GraphWeaver.register_scalar("Timestamp", Time) # the default, everywhere
|
|
207
|
+
GraphWeaver.register_scalar("User.birthday", Date) # this field only
|
|
22
208
|
```
|
|
23
209
|
|
|
24
210
|
A field override wins over the scalar-name registration — which is also how two
|
|
25
211
|
servers that disagree about a `DateTime` coexist in one process.
|
|
26
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
|
+
|
|
27
231
|
Registrations are validated against the schema you generate against, and only
|
|
28
232
|
what that schema can **disprove** fails generation: a name it declares as
|
|
29
233
|
something else (`register_scalar("Species")` where `Species` is an enum), or a
|
|
@@ -32,45 +236,33 @@ only warns — one registry serves a whole graph, so that name may belong to the
|
|
|
32
236
|
subgraph next door (see
|
|
33
237
|
[federation](federation.md#generating-for-a-federated-graph)).
|
|
34
238
|
|
|
35
|
-
Pass a real class as the second argument and the cast/serialize are
|
|
36
|
-
**inferred** from it by probing the deserialize side and pairing its serializer:
|
|
37
|
-
|
|
38
|
-
| the class defines | cast | serialize |
|
|
39
|
-
|-------------------|---------------|----------------|
|
|
40
|
-
| `.parse` | `Type.parse(v)` | `v.to_s` |
|
|
41
|
-
| `.load` | `Type.load(v)` | `Type.dump(v)` |
|
|
42
|
-
|
|
43
|
-
so the common case needs nothing more. A type defining neither `.parse` nor
|
|
44
|
-
`.load` stays pass-through rather than getting wrapped. Override explicitly when
|
|
45
|
-
you need to:
|
|
46
|
-
|
|
47
|
-
- a `Symbol` method name, nothing to misspell: `cast: :load` → `Money.load(expr)`,
|
|
48
|
-
`serialize: :to_json` → `expr.to_json`
|
|
49
|
-
- a `Proc` for anything a method name can't express: `cast: ->(expr) { "Money.new(#{expr})" }`
|
|
50
|
-
- `:itself` to force pass-through, opting out of inference (rare)
|
|
51
|
-
|
|
52
|
-
The type also accepts a plain string (`"BigDecimal"`) when you'd rather not
|
|
53
|
-
reference the class. `requires:` (a string or array) names files emitted as
|
|
54
|
-
`require`s atop the generated source so the cast/type resolve. When the type is
|
|
55
|
-
a real class (so the runtime is loaded), each path is also `require`d at
|
|
56
|
-
registration — a typo fails now, not in the generated file.
|
|
57
|
-
|
|
58
239
|
The testing harness can't invent a wire value for a scalar registered as your
|
|
59
240
|
own class — only `Money.parse` knows what it accepts — so it refuses rather than
|
|
60
241
|
guess. Say it in test config, where that answer belongs: a pin for the type,
|
|
61
242
|
`GraphWeaver::Testing.config.overrides = { "Money" => "12.00" }`, or per example
|
|
62
|
-
([testing → pins](testing.md#pins)). A scalar registered as
|
|
63
|
-
`
|
|
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.
|
|
246
|
+
|
|
247
|
+
A registration whose type is a class **JSON can't parse into**, with nothing to
|
|
248
|
+
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:
|
|
64
252
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
253
|
+
```
|
|
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:
|
|
70
261
|
|
|
71
262
|
```
|
|
72
|
-
register_scalar("Money",
|
|
73
|
-
|
|
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 ...
|
|
74
266
|
```
|
|
75
267
|
|
|
76
268
|
A registration used only for a variable is untouched: nothing casts it.
|
|
@@ -91,9 +283,6 @@ stays as narrow as the schema and the conversion happens in its body (see
|
|
|
91
283
|
error — as it should be, since you have a `Money` right there — while
|
|
92
284
|
`budget: params[:budget]` typechecks and converts.
|
|
93
285
|
|
|
94
|
-
The built-in scalars (`Date`, `ID`, `Int`, …) are pre-registered through the
|
|
95
|
-
same path (`Date` even carries its own `require "date"`), so a later
|
|
96
|
-
`register_scalar` overrides them.
|
|
97
286
|
|
|
98
287
|
## What the wire carries
|
|
99
288
|
|
|
@@ -119,10 +308,11 @@ server writing a non-integer where the spec says integer, so it is refused.
|
|
|
119
308
|
| `Boolean` | `true`, `false` | `"true"`, `1`, `0` |
|
|
120
309
|
| `Date` | ISO-8601: `"2024-01-01"`, `"20240101"`, and a full timestamp (truncated) | any other spelling, an epoch integer |
|
|
121
310
|
| `DateTime`/`Time` (registered as `Time`) | RFC 3339 with `Z` or an offset, with or without fractional seconds, seconds optional; also a bare date and `Time.parse`'s looser forms | an epoch integer, an unparseable string |
|
|
311
|
+
| `BigInt` | the decimal string graphql-ruby writes, past 2⁵³ included; also a JSON integer | `1.5`, `"1.5"`, a non-numeric string, `true` |
|
|
122
312
|
| an enum | a declared value, as a string | an undeclared value, a non-string |
|
|
123
|
-
| unregistered | anything — `T.untyped`, straight through | nothing |
|
|
313
|
+
| `JSON`, or unregistered | anything — `T.untyped`, straight through | nothing |
|
|
124
314
|
|
|
125
|
-
A refusal is a [`GraphWeaver::
|
|
315
|
+
A refusal is a [`GraphWeaver::CastError`](errors.md) naming the field and the
|
|
126
316
|
generated struct (which names the query). Two refusals carry advice rather than
|
|
127
317
|
only sorbet's words: an unquoted `ID`, and a registration with no cast (above).
|
|
128
318
|
|
|
@@ -146,12 +336,23 @@ the sig is `.checked(:never)`).
|
|
|
146
336
|
| `String` | `String` | nothing | the string |
|
|
147
337
|
| `ID` | `String` | an `Integer` — `execute(id: user.id)` | the string |
|
|
148
338
|
| `Boolean` | `true`/`false` | nothing | the boolean |
|
|
149
|
-
| `Date` | `Date` | an ISO-8601 string | `
|
|
150
|
-
| `Time` | `Time` | a string `Time.parse` takes
|
|
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 |
|
|
341
|
+
| `BigInt` | `Integer` | a decimal string | the decimal string, which is what the server writes |
|
|
151
342
|
| an enum | the member **or** its wire value | — | the wire value |
|
|
152
343
|
| an input object | the struct **or** a Hash | — | the wire hash |
|
|
153
|
-
| a registered custom scalar | its Ruby type | whatever its
|
|
154
|
-
| unregistered | `T.untyped` | anything | straight through |
|
|
344
|
+
| a registered custom scalar | its Ruby type | whatever its cast takes | what its serialize writes |
|
|
345
|
+
| `JSON`, or unregistered | `T.untyped` | anything | straight through |
|
|
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`.
|
|
155
356
|
|
|
156
357
|
Two rows are judgment calls worth stating. **`ID` takes an `Integer`** because
|
|
157
358
|
the GraphQL spec says an ID serializes as a string but accepts an integer input,
|
|
@@ -159,30 +360,55 @@ and `execute(id: user.id)` off a model is the everyday call; `String` gets no
|
|
|
159
360
|
such license, since an `Integer` where a `String` belongs is more often a bug
|
|
160
361
|
than a spelling. **`Boolean` takes no string** — Ruby has no `Kernel#Boolean`,
|
|
161
362
|
so every rule for reading `"0"`, `"off"`, `"no"` is somebody's convention, and
|
|
162
|
-
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.
|
|
163
369
|
|
|
164
370
|
Anything the table refuses raises `GraphWeaver::InputError` naming the variable,
|
|
165
371
|
the operation and the value — `$count of Compute: expected an Int, got "lots"`
|
|
166
372
|
— which is the same [422 rescue point](errors.md) as a bad input-object field.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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,
|
|
173
390
|
`GraphWeaver::Codegen` has the pieces —
|
|
174
391
|
`reset_scalars!` (restore the built-ins), `clear_scalars!` (empty the registry
|
|
175
392
|
entirely), `reset_enums!`, `reset_type_helpers!`.
|
|
176
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
|
+
|
|
177
398
|
A scalar you never register is not an error — it generates as `T.untyped` and
|
|
178
399
|
the wire value passes through untouched. It is, though, the one hole in an
|
|
179
|
-
otherwise exact result type, so generation names the holes
|
|
180
|
-
|
|
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)):
|
|
181
403
|
|
|
182
404
|
```
|
|
183
405
|
3 unregistered custom scalars → T.untyped: CountryCode, FuzzyDateInt, Json (register with GraphWeaver.register_scalar)
|
|
184
406
|
```
|
|
185
407
|
|
|
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.
|
|
411
|
+
|
|
186
412
|
## Enums: map onto your own T::Enum
|
|
187
413
|
|
|
188
414
|
By default a schema enum generates one `T::Enum` per schema, shared by every
|
|
@@ -237,7 +463,10 @@ Two safety properties do the real work:
|
|
|
237
463
|
- **`fallback:` for forward-compat**: `fallback: PetKind::Unknown` makes
|
|
238
464
|
*casting* absorb wire values the server added after you generated —
|
|
239
465
|
responses keep flowing instead of raising. Inputs stay strict either
|
|
240
|
-
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)).
|
|
241
470
|
|
|
242
471
|
The translation tables are emitted into the generated source
|
|
243
472
|
(`SPECIES_FROM_WIRE` / `SPECIES_TO_WIRE`) — reviewable in the diff, no
|