graph_weaver 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +4 -4
- data/README.md +40 -88
- data/docs/alternatives.md +1 -7
- data/docs/cassettes.md +54 -59
- data/docs/editors.md +32 -47
- data/docs/errors.md +261 -369
- data/docs/federation.md +650 -837
- data/docs/generated_modules.md +370 -459
- data/docs/getting_started.md +211 -428
- data/docs/i18n.md +114 -177
- data/docs/logging.md +127 -116
- data/docs/real_world.md +26 -39
- data/docs/scalars.md +277 -310
- data/docs/testing.md +340 -486
- data/docs/transports.md +191 -263
- data/docs/upgrading.md +188 -560
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +12 -3
- data/lib/graph_weaver/client.rb +22 -1
- data/lib/graph_weaver/codegen.rb +5 -1
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +23 -15
- data/lib/graph_weaver/federation.rb +11 -2
- data/lib/graph_weaver/in_process.rb +15 -9
- data/lib/graph_weaver/internal/endpoint.rb +7 -5
- data/lib/graph_weaver/internal/headers.rb +19 -0
- data/lib/graph_weaver/internal.rb +66 -13
- data/lib/graph_weaver/log_subscriber.rb +10 -2
- data/lib/graph_weaver/logging.rb +33 -13
- data/lib/graph_weaver/query_module.rb +8 -0
- data/lib/graph_weaver/retry.rb +12 -8
- data/lib/graph_weaver/schema_loader.rb +52 -14
- data/lib/graph_weaver/testing/cassette.rb +28 -5
- data/lib/graph_weaver/testing/endpoint.rb +14 -13
- data/lib/graph_weaver/testing/fake_client.rb +33 -3
- data/lib/graph_weaver/testing/router.rb +7 -3
- data/lib/graph_weaver/transport/http.rb +2 -2
- data/lib/graph_weaver/transport.rb +47 -23
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +22 -1
- metadata +16 -3
- 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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
13
|
-
The
|
|
14
|
-
|
|
15
|
-
`
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
77
|
-
register it, but never assumed.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
`
|
|
84
|
-
|
|
85
|
-
|
|
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 one — noise 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("
|
|
131
|
+
GraphWeaver.register_scalar("URL", URI::Generic, cast: ->(v) { "URI.parse(#{v})" })
|
|
104
132
|
```
|
|
105
133
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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"
|
|
141
|
-
`serialize: :to_s` writing it back
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
`
|
|
167
|
-
|
|
168
|
-
|
|
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.
|
|
174
|
+
GraphWeaver.extend_type("Money") { def to_money = ::Money.from_amount(BigDecimal(amount), currency) }
|
|
181
175
|
```
|
|
182
176
|
|
|
183
|
-
`
|
|
184
|
-
(
|
|
185
|
-
`
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
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
|
|
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).
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
[typed variables](generated_modules.md#variables-become-typed-kwargs) for why
|
|
330
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
BigDecimal)` field refuses a `Money`, in `#message` and in `#details[:type]`
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
[
|
|
396
|
-
|
|
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 there — while
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
the
|
|
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
|
-
|
|
416
|
-
`AddPetMutation::Species`). That's fine until your app has its own domain
|
|
417
|
-
|
|
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
|
-
|
|
421
|
-
#
|
|
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
|
|
438
|
-
pet.species == other_pet.species
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
- **`fallback:` for forward-compat**: `fallback: PetKind::Unknown` makes
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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).
|