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.
- 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 +380 -463
- 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 +343 -486
- data/docs/transports.md +203 -268
- data/docs/upgrading.md +211 -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 +30 -1
- data/lib/graph_weaver/codegen/emit.rb +5 -11
- data/lib/graph_weaver/codegen.rb +23 -55
- 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/graph.rb +39 -29
- 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/test_clients.rb +7 -11
- data/lib/graph_weaver/internal.rb +81 -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 +44 -23
- data/lib/graph_weaver/retry.rb +12 -8
- data/lib/graph_weaver/rspec.rb +13 -24
- data/lib/graph_weaver/schema_loader.rb +52 -14
- data/lib/graph_weaver/tasks.rb +10 -2
- 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/testing.rb +12 -4
- 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 +32 -10
- metadata +16 -3
- data/CHANGELOG.md +0 -3801
data/docs/errors.md
CHANGED
|
@@ -26,17 +26,71 @@ response.extensions # { "cost" => … } — rides on success too
|
|
|
26
26
|
response.data! # the Result, or raise GraphWeaver::QueryError
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
**`execute!` raises whenever `errors` is non-empty** — partial data included,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
**`execute!` raises whenever `errors` is non-empty** — partial data included, so a
|
|
30
|
+
mutation that created the order and then failed on the way out still raises, with
|
|
31
|
+
the data hanging off `QueryError#data`. Reach for `execute` when a partial answer
|
|
32
|
+
is one you can use.
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
Every `GraphQLError` exposes `#message`, `#locations`, `#path`, `#extensions`, and
|
|
35
|
+
`#code` (`extensions["code"]`) — match on the **code**, not the message string
|
|
36
|
+
(`response.errors.first.code == "THROTTLED"`).
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
## The three you'll meet first
|
|
39
|
+
|
|
40
|
+
**A variable that won't convert** raises `GraphWeaver::InputError` before the
|
|
41
|
+
request leaves — top-level scalars, input-object fields, at any depth. One rescue
|
|
42
|
+
point turns invalid input into a 422:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
rescue GraphWeaver::InputError => e
|
|
46
|
+
render json: e.to_h, status: :unprocessable_entity
|
|
47
|
+
# { "error" => "GraphWeaver::InputError",
|
|
48
|
+
# "message" => "$input of AdoptMutation: species: \"LIZARD\" is not a valid " \
|
|
49
|
+
# "GraphQLTypes::Species — expected one of: CAT, DOG",
|
|
50
|
+
# "kind" => "not_a_member", "path" => ["input", "species"],
|
|
51
|
+
# "coordinate" => "AdoptionInput.species", "field" => "species",
|
|
52
|
+
# "value" => "LIZARD", "details" => { "members" => ["CAT", "DOG"] },
|
|
53
|
+
# "struct" => "GraphQLTypes::AdoptionInput" }
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**The server rejecting the input** arrives as ordinary `GraphQLError`s, and
|
|
58
|
+
`#input_errors` reads the ones that are about your input back into the same
|
|
59
|
+
`InputError` — so one renderer serves both halves:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
response = AdoptMutation.execute(input: params[:pet])
|
|
63
|
+
|
|
64
|
+
response.input_errors # [GraphWeaver::InputError] — [] when none
|
|
65
|
+
response.errors # still every error, input or not
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Everything else is a class to rescue.** The network broke, the server answered
|
|
69
|
+
non-2xx, or it answered 200 and complained:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
begin
|
|
73
|
+
person = PersonQuery.execute!(id: "1").person
|
|
74
|
+
rescue GraphWeaver::TransportError
|
|
75
|
+
retry # network blip
|
|
76
|
+
rescue GraphWeaver::ServerError => e
|
|
77
|
+
e.throttled? || e.status >= 500 ? backoff : raise # a plain 4xx is our bug
|
|
78
|
+
rescue GraphWeaver::QueryError => e
|
|
79
|
+
e.throttled? ? backoff : raise # the same question, asked of the errors array
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Which arm catches a failure is the server's choice**, not a rule you can rely
|
|
84
|
+
on: an origin server answers 429 with no body and you get a `ServerError`, while
|
|
85
|
+
Apollo Router answers the same rate limit with `503` *and* a GraphQL errors body,
|
|
86
|
+
so it arrives as a `QueryError` — as do its 500s, 401s and 403s. So don't put the
|
|
87
|
+
retry decision in the `ServerError` arm: hand it to
|
|
88
|
+
[`Retry`](transports.md#retries), which asks the same question of both.
|
|
89
|
+
`#throttled?` spells the same on both for the same reason, and knows the codes the
|
|
90
|
+
big graphs send (`GraphWeaver::GraphQLError::THROTTLE_CODES` — Shopify's
|
|
91
|
+
`THROTTLED`, GitHub's `RATE_LIMITED`, Apollo Router's `REQUEST_RATE_LIMITED`).
|
|
92
|
+
|
|
93
|
+
## The classes
|
|
40
94
|
|
|
41
95
|
Everything GraphWeaver *concludes* descends from `GraphWeaver::Error` — a
|
|
42
96
|
transport failure, a rejected query, a response that wouldn't cast, a plan the
|
|
@@ -57,58 +111,41 @@ subclass says where it failed:
|
|
|
57
111
|
| `Testing::MissingRecording` | a [cassette](cassettes.md) holds no entry for this request — the message prints the variables, and the ones it did record |
|
|
58
112
|
|
|
59
113
|
An argument that is wrong *on its face* raises a plain `ArgumentError` instead
|
|
60
|
-
(`pool_size: must be >= 1
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
`
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
not followed" and the `Location` to repoint the client at — replaying a POST,
|
|
96
|
-
with its `Authorization` header, at a host the server named isn't the
|
|
97
|
-
library's call. A 401 or 403 appends "check `auth:` — the token, and its
|
|
98
|
-
scopes".
|
|
99
|
-
|
|
100
|
-
**And both network failures name the endpoint**, in the message and on `#url`,
|
|
101
|
-
because an app with more than one graph has more than one answer to "which
|
|
102
|
-
server did this". The url they name is the one the gem is willing to *say*:
|
|
103
|
-
userinfo, and any query parameter
|
|
104
|
-
[`filter_parameters`](logging.md) already filters, are folded to `[FILTERED]`
|
|
105
|
-
before it reaches a message, a log line or your APM. `Transport#url` stays the
|
|
106
|
-
real endpoint; `Transport#safe_url` is the sayable one.
|
|
107
|
-
|
|
108
|
-
**Everything you pass to `execute` is caller input**, so a value that won't
|
|
109
|
-
convert raises `GraphWeaver::InputError` — top-level scalar variables included.
|
|
110
|
-
They name the variable and the operation, since the value alone locates nothing
|
|
111
|
-
in an app that runs a hundred queries:
|
|
114
|
+
(`pool_size: must be >= 1`), like any Ruby method — a bug at the call site, not a
|
|
115
|
+
condition to rescue. The line is whether the library had to read your schema to
|
|
116
|
+
reach the verdict: it did for `ConfigurationError`, which is why a spec helper can
|
|
117
|
+
rescue that one. A *missing* required kwarg is Ruby's own `ArgumentError` too
|
|
118
|
+
("missing keyword: :id").
|
|
119
|
+
|
|
120
|
+
**A status with an obvious next step says it.** A 3xx appends "redirects are not
|
|
121
|
+
followed" and the `Location` to repoint the client at — replaying a POST, with its
|
|
122
|
+
`Authorization` header, at a host the server named isn't the library's call. A 401
|
|
123
|
+
or 403 appends "check `auth:` — the token, and its scopes". Both name the endpoint,
|
|
124
|
+
in the message and on `#url`, with credentials
|
|
125
|
+
[folded to `[FILTERED]`](logging.md#filtered-variables) first.
|
|
126
|
+
|
|
127
|
+
**A partial answer only survives as far as the nearest nullable field.** That is
|
|
128
|
+
GraphQL's null propagation, not this client, and the conventional Relay payload is
|
|
129
|
+
exactly where it bites: given `ChargePayload { order: Order!, receiptUrl: String! }`,
|
|
130
|
+
a resolver that raises on `receiptUrl` *after the order was charged* nulls
|
|
131
|
+
`receiptUrl`, which is non-null, so the null climbs to the payload and on to the
|
|
132
|
+
root — `response.data` and `QueryError#data` are both `nil`, and the order you just
|
|
133
|
+
created is nowhere in the response. Making the **payload field** nullable doesn't
|
|
134
|
+
help: the null stops at `{"charge" => nil}` and the order was inside it. Two things
|
|
135
|
+
do — make the field *that can fail* nullable (`receiptUrl: String`), which leaves
|
|
136
|
+
`order` on `data` beside the error; or accept that the write's outcome is not in
|
|
137
|
+
the response and read the order back.
|
|
138
|
+
|
|
139
|
+
Business/validation failures returned *as data* (Shopify-style
|
|
140
|
+
`userErrors { field message code }`) aren't errors here — they're fields you
|
|
141
|
+
selected, so they deserialize onto `response.data` like anything else.
|
|
142
|
+
|
|
143
|
+
## Input errors
|
|
144
|
+
|
|
145
|
+
`InputError` is raised for anything you pass to `execute` that won't convert,
|
|
146
|
+
including a top-level scalar variable. The message names the variable and the
|
|
147
|
+
operation, since the value alone locates nothing in an app that runs a hundred
|
|
148
|
+
queries:
|
|
112
149
|
|
|
113
150
|
```
|
|
114
151
|
$count of Compute: expected an Int, got "lots"
|
|
@@ -116,56 +153,33 @@ $count of Compute: expected an Int, got "lots"
|
|
|
116
153
|
|
|
117
154
|
The value is usually the whole diagnosis, so it is quoted — unless the key it
|
|
118
155
|
arrived under is one your `filter_parameters` covers, in which case the message
|
|
119
|
-
reads `$password of Login: [FILTERED]
|
|
120
|
-
covered
|
|
121
|
-
|
|
122
|
-
above the
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
# "GraphQLTypes::Species — expected one of: CAT, DOG",
|
|
139
|
-
# "kind" => "not_a_member", "path" => ["input", "species"],
|
|
140
|
-
# "coordinate" => "AdoptionInput.species", "field" => "species",
|
|
141
|
-
# "value" => "LIZARD", "details" => { "members" => ["CAT", "DOG"] },
|
|
142
|
-
# "struct" => "GraphQLTypes::AdoptionInput" }
|
|
143
|
-
end
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
`to_h` carries only the keys that have something to say, so a key is **absent**
|
|
147
|
-
rather than `null` — `"value"` is missing both when the value was never known
|
|
148
|
-
and when it was null, and `"kind"` tells those apart (`"missing"` has no value
|
|
149
|
-
by definition). Read it with `hash["value"]`, not `hash.key?("value")`.
|
|
150
|
-
|
|
151
|
-
**A JSON controller underscores on the way in.** Generated input structs take
|
|
152
|
-
the prop spelling, so a camelCase request body makes *every* key an unknown
|
|
153
|
-
one — `params.deep_transform_keys(&:underscore)` before `execute`.
|
|
154
|
-
`details[:suggestion]` is how you tell that from a typo: a casing problem hands
|
|
155
|
-
the same key back in snake_case (`customerEmail` → "did you mean
|
|
156
|
-
'customer_email'?"), a real typo suggests a different field.
|
|
157
|
-
|
|
158
|
-
A nested filter reports the innermost input type, so the error points at the
|
|
159
|
-
input that actually held the bad field. Passing something that is neither — a
|
|
160
|
-
bare `String` where the input goes — reports the same way. A call site that
|
|
161
|
-
*spells* the wrong type is caught earlier and better, by `srb tc`: the sig is
|
|
162
|
-
as narrow as the schema, and only untyped values reach the runtime check
|
|
156
|
+
reads `$password of Login: [FILTERED]`, and a filtered key *inside* the value is
|
|
157
|
+
covered at any depth ([logging](logging.md#filtered-variables)).
|
|
158
|
+
|
|
159
|
+
`to_h` (above) carries only the keys that have something to say, so a key is
|
|
160
|
+
**absent** rather than `null` — `"value"` is missing both when the value was never
|
|
161
|
+
known and when it was null, and `"kind"` tells those apart. Read it with
|
|
162
|
+
`hash["value"]`, not `hash.key?("value")`.
|
|
163
|
+
|
|
164
|
+
**A JSON controller underscores on the way in.** Generated input structs take the
|
|
165
|
+
prop spelling, so a camelCase request body makes *every* key an unknown one —
|
|
166
|
+
`params.deep_transform_keys(&:underscore)` before `execute`. `details[:suggestion]`
|
|
167
|
+
is how you tell that from a typo: a casing problem hands the same key back in
|
|
168
|
+
snake_case (`customerEmail` → "did you mean 'customer_email'?"), a real typo
|
|
169
|
+
suggests a different field.
|
|
170
|
+
|
|
171
|
+
A nested filter reports the innermost input type, so the error points at the input
|
|
172
|
+
that actually held the bad field; passing something that is neither — a bare
|
|
173
|
+
`String` where the input goes — reports the same way. A call site that *spells* the
|
|
174
|
+
wrong type is caught earlier and better, by `srb tc`
|
|
163
175
|
([why](generated_modules.md#variables-become-typed-kwargs)).
|
|
164
176
|
|
|
177
|
+
The one-shot `GraphWeaver.run` / `run!` mirror `execute` / `execute!`.
|
|
178
|
+
|
|
165
179
|
### What an InputError says, without reading English
|
|
166
180
|
|
|
167
|
-
`#message` is the developer's line and it will be reworded. Everything a form
|
|
168
|
-
|
|
181
|
+
`#message` is the developer's line and it will be reworded. Everything a form or
|
|
182
|
+
an API response needs is beside it, as data:
|
|
169
183
|
|
|
170
184
|
| | |
|
|
171
185
|
|---|---|
|
|
@@ -175,52 +189,47 @@ or an API response needs is beside it, as data:
|
|
|
175
189
|
| `#value` | the rejected value, through [`filter_parameters`](logging.md#filtered-variables), and always JSON-representable (a non-finite Float travels as `"NaN"`/`"Infinity"`). `nil` when it was never known — a missing field has none, and an unknown key owns no slot to hold one |
|
|
176
190
|
| `#details` | kind-specific facts, never pre-formatted — `{ members: ["CAT", "DOG"] }`, `{ type: "Int" }`, `{ suggestion: "species" }`. `type` is the **schema's** name for the type (`Money`, `AdoptionInput`), never the Ruby class it maps to |
|
|
177
191
|
| `#field` | `#path`'s last *named* segment — the one field a form highlights. A trailing list index is a position, not a field, so `["ids", 2]` is still `"ids"` |
|
|
178
|
-
| `#struct` | the input type being built |
|
|
192
|
+
| `#struct` | the input type being built — the generated struct *class* where generation produced one, and the GraphQL type *name* where it didn't (a federation representation builds a plain Hash). `to_h`'s `"struct"` is the name either way |
|
|
179
193
|
|
|
180
194
|
So a form reads `e.field` and either `e.message` or — better — its own sentence
|
|
181
195
|
built from `e.kind` and `e.details`.
|
|
182
196
|
|
|
183
|
-
**When the leaf isn't a field.** A Hasura-shaped filter puts a comparison
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
197
|
+
**When the leaf isn't a field.** A Hasura-shaped filter puts a comparison operator
|
|
198
|
+
at the bottom, so `where: { height: { _gte: "abc" } }` refuses with `#path`
|
|
199
|
+
`["where", "height", "_gte"]` and `#field` `"_gte"` — right by the rule, and
|
|
200
|
+
useless to a form. Key the form on `#path` there: the column is the segment before
|
|
201
|
+
the operator.
|
|
188
202
|
|
|
189
|
-
**Long values are cut.** An error is built for whatever a caller sent and
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
203
|
+
**Long values are cut.** An error is built for whatever a caller sent and whatever
|
|
204
|
+
a server echoed back, either of which can be megabytes, and every raised one writes
|
|
205
|
+
a `warn` line. So each String `#value` holds (at every depth), the value `#message`
|
|
206
|
+
quotes, and a sentence a server wrote are capped at
|
|
207
|
+
`GraphWeaver::InputError::VALUE_LIMIT` — 1024 bytes, with `…(N more bytes)` in
|
|
208
|
+
place of the rest.
|
|
195
209
|
|
|
196
210
|
#### Which spelling a path is in
|
|
197
211
|
|
|
198
|
-
**`#path`, `#field` and `#coordinate` are the schema's spelling**
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
212
|
+
**`#path`, `#field` and `#coordinate` are the schema's spelling** (`issuedOn`,
|
|
213
|
+
`externalId`) — one rule, whichever side refused. A server can produce no other,
|
|
214
|
+
and the client knows both, so this is the only spelling both halves can agree on:
|
|
215
|
+
a form keyed on `e.field` finds the same slot for a refusal raised before the
|
|
216
|
+
request left and for one the server sent back.
|
|
203
217
|
|
|
204
|
-
The **prop** (`issued_on`) is what you type in Ruby — `.new`, `.coerce`, the
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
In a Rails form the field names are the props, so underscore on the way in —
|
|
210
|
-
and give the nil case a home, because **`#field` is `nil` whenever nothing named
|
|
211
|
-
a slot**, which is what a `:refused` error from a server that stated no input
|
|
212
|
-
path gives you:
|
|
218
|
+
The **prop** (`issued_on`) is what you type in Ruby — `.new`, `.coerce`, the kwargs
|
|
219
|
+
of `execute` — and it is `#message`, the developer's line, that names it. In a
|
|
220
|
+
Rails form the field names are the props, so underscore on the way in — and give
|
|
221
|
+
the nil case a home, because **`#field` is `nil` whenever nothing named a slot**:
|
|
213
222
|
|
|
214
223
|
```ruby
|
|
215
224
|
form.errors.add(e.field&.underscore || :base, render_input_error(e))
|
|
216
225
|
```
|
|
217
226
|
|
|
218
|
-
The one segment that is neither is an **unknown key** — a typo names no field,
|
|
219
|
-
|
|
220
|
-
|
|
227
|
+
The one segment that is neither is an **unknown key** — a typo names no field, so
|
|
228
|
+
the schema has no spelling for it. It comes back exactly as you wrote it, and
|
|
229
|
+
`details[:suggestion]` is the prop to type instead.
|
|
221
230
|
|
|
222
|
-
**`#path` is rooted at the variable**, so its first segment is the kwarg you
|
|
223
|
-
|
|
231
|
+
**`#path` is rooted at the variable**, so its first segment is the kwarg you passed
|
|
232
|
+
and its last is the field that actually held the value:
|
|
224
233
|
|
|
225
234
|
| you called | `#path` | `#coordinate` |
|
|
226
235
|
|---|---|---|
|
|
@@ -231,21 +240,9 @@ passed and its last is the field that actually held the value:
|
|
|
231
240
|
| `AdoptionInput.coerce(name: "Rex", speceis: "DOG")` — no variable to name | `["speceis"]` — a typo names no field, so it is echoed as written | `nil` — the type defines no such field |
|
|
232
241
|
| `execute(count: "lots")` — a top-level scalar | `["count"]` | `nil` — a variable names no schema element |
|
|
233
242
|
|
|
234
|
-
`#coordinate` is `nil` wherever the schema has no name for the slot: a
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
`#struct` is the generated input struct *class* where generation produced one,
|
|
239
|
-
and the GraphQL type *name* where it didn't — a federation representation
|
|
240
|
-
builds a plain Hash, so an entity has only its name to give. `to_h`'s
|
|
241
|
-
`"struct"` is the name either way, so branch on that.
|
|
242
|
-
|
|
243
|
-
Business/validation failures returned *as data* (Shopify-style `userErrors { field
|
|
244
|
-
message code }`) aren't errors here — they're just fields you selected, so they
|
|
245
|
-
deserialize onto `response.data` like anything else and you inspect them there.
|
|
246
|
-
|
|
247
|
-
The one-shot `GraphWeaver.run` / `run!` mirror this: `run` returns
|
|
248
|
-
the envelope, `run!` the result-or-raise.
|
|
243
|
+
`#coordinate` is `nil` wherever the schema has no name for the slot: a variable, a
|
|
244
|
+
key the input type doesn't define, a nested `@key` path in a federation
|
|
245
|
+
representation, or a server that didn't say which type it meant.
|
|
249
246
|
|
|
250
247
|
### When the *server* rejects the input
|
|
251
248
|
|
|
@@ -261,28 +258,14 @@ three it knows by name, and claims nothing from the rest:
|
|
|
261
258
|
| **Hasura** | `extensions.path` naming an argument — `"$.selectionSet.<field>.args.<name>"` — under `validation-failed` or `parse-failed` | the field; `:not_a_member`, `:unknown` or `:missing` for the three sentences it always writes, `:refused` otherwise |
|
|
262
259
|
| **anything else** | nothing | `#input_errors` is `[]` — see [the fallback](#when-your-server-marks-nothing) |
|
|
263
260
|
|
|
264
|
-
`InputError` is the client-side half — graph_weaver refuses before the request
|
|
265
|
-
leaves. When the *server* is the one that says no, the rejection arrives as
|
|
266
|
-
ordinary `GraphQLError`s, and `#input_errors` reads the ones that are about
|
|
267
|
-
your input back into **the same `InputError`** — so one renderer serves both
|
|
268
|
-
halves:
|
|
269
|
-
|
|
270
|
-
```ruby
|
|
271
|
-
response = AdoptMutation.execute(input: params[:pet])
|
|
272
|
-
|
|
273
|
-
response.input_errors # [GraphWeaver::InputError] — [] when none
|
|
274
|
-
response.errors # still every error, input or not
|
|
275
|
-
```
|
|
276
|
-
|
|
277
261
|
`QueryError#input_errors` asks the same question of the raised envelope, and
|
|
278
|
-
`GraphQLError#input_errors` of one error. It is **plural on every one of them**:
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
your list covers.
|
|
262
|
+
`GraphQLError#input_errors` of one error. It is **plural on every one of them**: a
|
|
263
|
+
single variable-coercion error routinely carries several problems about different
|
|
264
|
+
fields, and keeping only the first would lose the rest silently. These are values,
|
|
265
|
+
not raises — building one writes no log line. `#message` and `#value` go through
|
|
266
|
+
`filter_parameters` here exactly as they do on the client side: a server quotes the
|
|
267
|
+
value it rejected as a matter of course (`Could not coerce value "hunter2" to
|
|
268
|
+
Int`), and that is a message about a key your list covers.
|
|
286
269
|
|
|
287
270
|
Generated modules always send **variables**, never literals, which narrows a
|
|
288
271
|
graphql-ruby server to two shapes (measured against 2.6.10):
|
|
@@ -293,32 +276,27 @@ graphql-ruby server to two shapes (measured against 2.6.10):
|
|
|
293
276
|
| a `validates:` rule failed — range, format, inclusion, length | an **execution** error: `response.data` is present with the field nulled (so `success?` is false on a response that still carries data), `path` is the **response** path (`["adopt"]` — the field, not the input field), and there is **no `extensions` key at all** | **nothing** — see below |
|
|
294
277
|
|
|
295
278
|
**A `validates:` failure is not claimed.** With no `extensions` at all it is
|
|
296
|
-
indistinguishable from "the database is down", and attaching *that* to a form
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
279
|
+
indistinguishable from "the database is down", and attaching *that* to a form field
|
|
280
|
+
is worse than missing it — so it stays an ordinary error in `response.errors`. One
|
|
281
|
+
line on the server fixes it, and [the next section](#what-your-server-can-send) is
|
|
282
|
+
that line.
|
|
300
283
|
|
|
301
284
|
**Hasura is read off the path, not a code.** `validation-failed` is the code it
|
|
302
|
-
sends for a query that doesn't parse *and* for a value it won't take, so the
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
`kind`: `limit: -5` comes back `:refused
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
And the commonest real Hasura input mistake isn't read at all: a `where:` value
|
|
312
|
-
that Hasura's comparison type accepts but the underlying Postgres column
|
|
285
|
+
sends for a query that doesn't parse *and* for a value it won't take, so the code
|
|
286
|
+
alone would attach your own `.graphql` file to a form field. The argument in
|
|
287
|
+
`extensions.path` is what settles it, and only three of its sentences earn a
|
|
288
|
+
`kind`: `limit: -5` comes back `:refused`, because the sentence Hasura writes for
|
|
289
|
+
it ("expected a non-negative 32-bit integer for type 'Int', but found a number")
|
|
290
|
+
is the same one it writes for `limit: "lots"`. The field is worth having; the guess
|
|
291
|
+
isn't. And the commonest real Hasura input mistake isn't read at all: a `where:`
|
|
292
|
+
value that Hasura's comparison type accepts but the underlying Postgres column
|
|
313
293
|
rejects (`{ id: { _eq: "abc" } }` on a `uuid`) gets past validation and fails at
|
|
314
|
-
the database,
|
|
315
|
-
read here, and no argument named. `#input_errors` is `[]`, and
|
|
316
|
-
`invalid input syntax for type uuid` is the server's sentence to render.
|
|
294
|
+
the database, arriving as `data-exception` at path `"$"` with no argument named.
|
|
317
295
|
|
|
318
296
|
#### When your server marks nothing
|
|
319
297
|
|
|
320
|
-
Then `#input_errors` is `[]` and says so — which is the signal to render what
|
|
321
|
-
|
|
298
|
+
Then `#input_errors` is `[]` and says so — which is the signal to render what the
|
|
299
|
+
server *did* send, not to parse its prose:
|
|
322
300
|
|
|
323
301
|
```ruby
|
|
324
302
|
response = AdoptMutation.execute(input: params[:pet])
|
|
@@ -337,10 +315,10 @@ end
|
|
|
337
315
|
### What your server can send
|
|
338
316
|
|
|
339
317
|
Two of the eight kinds — `:out_of_range` and `:invalid_format`, the everyday
|
|
340
|
-
"right type, wrong value" — **cannot be produced from either side on their
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
318
|
+
"right type, wrong value" — **cannot be produced from either side on their own.**
|
|
319
|
+
The client doesn't know the schema's bounds, and graphql-ruby puts a `validates:`
|
|
320
|
+
failure on the wire as a bare sentence. Only the server can say it, so there is one
|
|
321
|
+
key to say it under:
|
|
344
322
|
|
|
345
323
|
```json
|
|
346
324
|
"extensions": {
|
|
@@ -356,170 +334,87 @@ it, so there is one key to say it under:
|
|
|
356
334
|
```
|
|
357
335
|
|
|
358
336
|
`code` is the ecosystem's coarse bucket, so a client that has never heard of
|
|
359
|
-
graph_weaver still understands; `input` is the fine one. Only `kind` is
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
`path` must be an **Array of field names and list indices**. A dotted String,
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
```ruby
|
|
377
|
-
class AtLeastValidator < GraphQL::Schema::Validator
|
|
378
|
-
def initialize(min:, **rest)
|
|
379
|
-
@min = min
|
|
380
|
-
super(**rest)
|
|
381
|
-
end
|
|
382
|
-
|
|
383
|
-
def validate(_object, _context, value)
|
|
384
|
-
return if value.nil? || value >= @min
|
|
385
|
-
|
|
386
|
-
raise GraphQL::ExecutionError.new(
|
|
387
|
-
"#{validated.graphql_name} must be at least #{@min}",
|
|
388
|
-
extensions: {
|
|
389
|
-
"code" => "BAD_USER_INPUT",
|
|
390
|
-
"input" => {
|
|
391
|
-
"kind" => "out_of_range",
|
|
392
|
-
"path" => ["input", validated.graphql_name],
|
|
393
|
-
"coordinate" => "#{validated.owner.graphql_name}.#{validated.graphql_name}",
|
|
394
|
-
"value" => value,
|
|
395
|
-
"min" => @min,
|
|
396
|
-
},
|
|
397
|
-
},
|
|
398
|
-
)
|
|
399
|
-
end
|
|
400
|
-
end
|
|
401
|
-
GraphQL::Schema::Validator.install(:at_least, AtLeastValidator)
|
|
402
|
-
|
|
403
|
-
class RangeInput < GraphQL::Schema::InputObject
|
|
404
|
-
argument :min, Integer, required: true, validates: { at_least: { min: 1 } }
|
|
405
|
-
end
|
|
406
|
-
```
|
|
407
|
-
|
|
408
|
-
and for a scalar, on `GraphQL::CoercionError`, whose extensions arrive nested
|
|
409
|
-
under `problems[i].extensions`:
|
|
410
|
-
|
|
411
|
-
```ruby
|
|
412
|
-
class EmailScalar < GraphQL::Schema::Scalar
|
|
413
|
-
graphql_name "Email"
|
|
414
|
-
|
|
415
|
-
def self.coerce_input(value, _ctx)
|
|
416
|
-
return value if value.to_s.match?(/\A[^@\s]+@[^@\s]+\z/)
|
|
417
|
-
|
|
418
|
-
raise GraphQL::CoercionError.new(
|
|
419
|
-
"#{value.inspect} is not an email address",
|
|
420
|
-
extensions: { "input" => { "kind" => "invalid_format", "pattern" => "name@example.com" } },
|
|
421
|
-
)
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
def self.coerce_result(value, _ctx) = value
|
|
425
|
-
end
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
**You author that `path`, and a validator on a field cannot know its list
|
|
429
|
-
index.** `Validator#validate(object, context, value)` is handed the argument
|
|
430
|
-
*definition* (`validated`, shared by every element), the value, and a context
|
|
431
|
-
whose `current_path` is the **response** path (`["createOrder"]`) — graphql-ruby
|
|
432
|
-
coerces a list with a plain `map` and keeps no index (measured against 2.6.10).
|
|
433
|
-
Install the validator above on a `qty` inside `lines: [LineInput!]!` and it
|
|
434
|
-
writes `["input", "qty"]` for every element alike, so a 200-line order can't say
|
|
435
|
-
which line was wrong.
|
|
436
|
-
|
|
437
|
-
Where the index matters, install the validator on the **list argument** instead:
|
|
438
|
-
`value` is then the whole coerced Array, so you index it yourself.
|
|
337
|
+
graph_weaver still understands; `input` is the fine one. Only `kind` is required,
|
|
338
|
+
and it must come from [the table](i18n.md#the-vocabulary) — an unrecognized one
|
|
339
|
+
degrades to `:refused` rather than being passed through, and any key outside
|
|
340
|
+
`type`/`members`/`min`/`max`/`pattern`/`suggestion` is dropped rather than reaching
|
|
341
|
+
`#details`. None of those six is a name I18n reserves for itself, so
|
|
342
|
+
`I18n.t(key, **details)` can never raise on the splat.
|
|
343
|
+
|
|
344
|
+
`path` must be an **Array of field names and list indices**. A dotted String, or a
|
|
345
|
+
segment that is neither, is dropped whole rather than parsed — and nothing stands
|
|
346
|
+
in for it: `#path` is `[]` and `#field` is `nil`. In particular the GraphQL error's
|
|
347
|
+
own `path` is never borrowed, because it names a *selection* (`["createOrder"]`)
|
|
348
|
+
rather than an input slot, and a form that trusted `#field` there would highlight a
|
|
349
|
+
field called `create_order`. Everything else the server did state still stands.
|
|
350
|
+
|
|
351
|
+
In graphql-ruby it rides on two raises. A `GraphQL::Schema::Validator` raising
|
|
352
|
+
`GraphQL::ExecutionError` covers a `validates:` rule, with `validated` giving you
|
|
353
|
+
the argument's own name and owner for the `path` and `coordinate`:
|
|
439
354
|
|
|
440
355
|
```ruby
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
"kind" => "out_of_range", "path" => ["input", "lines", index, "qty"],
|
|
450
|
-
"coordinate" => "LineInput.qty", "value" => line[:qty], "min" => 1,
|
|
451
|
-
} },
|
|
452
|
-
)
|
|
453
|
-
end
|
|
454
|
-
end
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
field :create_order, OrderType do
|
|
458
|
-
argument :lines, [LineInput], required: true, validates: { LinesValidator => {} }
|
|
459
|
-
end
|
|
356
|
+
raise GraphQL::ExecutionError.new(
|
|
357
|
+
"#{validated.graphql_name} must be at least #{@min}",
|
|
358
|
+
extensions: { "code" => "BAD_USER_INPUT", "input" => {
|
|
359
|
+
"kind" => "out_of_range", "path" => ["input", validated.graphql_name],
|
|
360
|
+
"coordinate" => "#{validated.owner.graphql_name}.#{validated.graphql_name}",
|
|
361
|
+
"value" => value, "min" => @min,
|
|
362
|
+
} },
|
|
363
|
+
)
|
|
460
364
|
```
|
|
461
365
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
**One problem per call.** `#input_errors` is plural, but
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
validation at the first `GraphQL::ExecutionError` a `validates:` rule raises,
|
|
497
|
-
across the whole input tree — a bad `coupon` and an out-of-range `qty` in one
|
|
498
|
-
submit come back as one error, and so do two bad elements of one list.
|
|
499
|
-
|
|
500
|
-
Build the form expecting to iterate, in other words, rather than to show every
|
|
501
|
-
problem after one round trip. The exception is a graphql-ruby **coercion**
|
|
502
|
-
failure, which carries a `problems` array and really does report several fields
|
|
503
|
-
at once.
|
|
366
|
+
A custom scalar's `coerce_input` does the same with `GraphQL::CoercionError`
|
|
367
|
+
(`extensions: { "input" => { "kind" => "invalid_format", "pattern" => … } }`),
|
|
368
|
+
whose extensions arrive nested under `problems[i].extensions`.
|
|
369
|
+
|
|
370
|
+
The client then reads `{ "kind" => "out_of_range", "path" => ["input", "min"],
|
|
371
|
+
"coordinate" => "RangeInput.min", "field" => "min", "value" => 0, "details" =>
|
|
372
|
+
{ "min" => 1 }, "message" => "min must be at least 1" }` — a number your form can
|
|
373
|
+
compare against, where without the convention that range failure is `:refused` at
|
|
374
|
+
best, and only if the server stamped `BAD_USER_INPUT`. A server that follows none
|
|
375
|
+
of this degrades; it does not guess.
|
|
376
|
+
|
|
377
|
+
**You author that `path`, and a validator on a field cannot know its list index.**
|
|
378
|
+
`Validator#validate(object, context, value)` is handed the argument *definition*
|
|
379
|
+
(`validated`, shared by every element), the value, and a context whose
|
|
380
|
+
`current_path` is the **response** path — graphql-ruby coerces a list with a plain
|
|
381
|
+
`map` and keeps no index (measured against 2.6.10). Install the validator above on
|
|
382
|
+
a `qty` inside `lines: [LineInput!]!` and it writes `["input", "qty"]` for every
|
|
383
|
+
element alike, so a 200-line order can't say which line was wrong. Where the index
|
|
384
|
+
matters, install the validator on the **list argument** instead: `value` is then the
|
|
385
|
+
whole coerced Array, so you index it yourself and write
|
|
386
|
+
`["input", "lines", index, "qty"]`. Per-element validators run first, so by then
|
|
387
|
+
every element is fully coerced.
|
|
388
|
+
|
|
389
|
+
**One problem per call.** `#input_errors` is plural, but neither side collects the
|
|
390
|
+
way `ActiveModel::Errors` does: `coerce` raises on the first field whose value
|
|
391
|
+
won't convert, and graphql-ruby aborts variable validation at the first
|
|
392
|
+
`GraphQL::ExecutionError` a `validates:` rule raises, across the whole input tree.
|
|
393
|
+
The two cases the client does gather are the ones it can see without walking
|
|
394
|
+
further — **unknown keys** and **absent required fields** are listed in full in the
|
|
395
|
+
message (`missing required key(s) for AdoptionInput: name, species`), though
|
|
396
|
+
`#path` names only the first, because a path that points at two fields points at
|
|
397
|
+
neither. So build the form expecting to iterate. The exception is a graphql-ruby
|
|
398
|
+
**coercion** failure, which carries a `problems` array and really does report
|
|
399
|
+
several fields at once.
|
|
504
400
|
|
|
505
401
|
## Extending TransportError
|
|
506
402
|
|
|
507
|
-
What counts as a `TransportError` is an **extensible set** — each transport
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
403
|
+
What counts as a `TransportError` is an **extensible set** — each transport seeds
|
|
404
|
+
its own network exceptions (`Errno::*`, `SocketError`, timeouts, TLS; the Faraday
|
|
405
|
+
transport adds its own), and you can register more so a custom adapter's or
|
|
406
|
+
connection pool's failure gets the same treatment:
|
|
511
407
|
|
|
512
408
|
```ruby
|
|
513
409
|
GraphWeaver.register_transport_error(ConnectionPool::TimeoutError)
|
|
514
410
|
GraphWeaver.transport_errors << MyAdapter::ResetError # it's just a Set
|
|
515
411
|
```
|
|
516
412
|
|
|
517
|
-
|
|
518
413
|
## Programmatic surfacing
|
|
519
414
|
|
|
520
415
|
Every error is dual-surface: `#message` for humans, `#to_h` for machines — a
|
|
521
|
-
JSON-ready hash (error class, per-error `path`/`code`/`locations`/`extensions`)
|
|
522
|
-
|
|
416
|
+
JSON-ready hash (error class, per-error `path`/`code`/`locations`/`extensions`) you
|
|
417
|
+
can nest straight into a log line or an API response.
|
|
523
418
|
|
|
524
419
|
Field-level tooling lives on both `Response` and `QueryError`:
|
|
525
420
|
|
|
@@ -537,37 +432,34 @@ response.report
|
|
|
537
432
|
# nil => { "codes" => ["DOWN"], ... } } # global errors under nil
|
|
538
433
|
```
|
|
539
434
|
|
|
540
|
-
`GraphQLError#field` strips list indices (`people.3.email` → `people.email`) —
|
|
541
|
-
|
|
435
|
+
`GraphQLError#field` strips list indices (`people.3.email` → `people.email`) — the
|
|
436
|
+
stable grouping key; the raw `#path` keeps indices for exact location.
|
|
542
437
|
|
|
543
|
-
`Response#to_h` decomposes the envelope the same way
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
look like the server's response without being one, so serialize the typed data
|
|
549
|
-
yourself when you need to re-emit it.
|
|
438
|
+
`Response#to_h` decomposes the envelope the same way, with each error as its
|
|
439
|
+
JSON-ready hash. `data` stays the typed struct, deliberately: `T::Struct#serialize`
|
|
440
|
+
would give snake_case keys where the wire is camelCase, drop null fields, and leave
|
|
441
|
+
a registered scalar as the Ruby object its codec built — output that would look
|
|
442
|
+
like the server's response without being one.
|
|
550
443
|
|
|
551
444
|
## Stale schemas
|
|
552
445
|
|
|
553
|
-
GraphQL has no schema-version signal, so a schema change surfaces as the
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
refresh the schema cache.
|
|
446
|
+
GraphQL has no schema-version signal, so a schema change surfaces as the server
|
|
447
|
+
rejecting your query's shape. `response.schema_stale?` / `QueryError#schema_stale?`
|
|
448
|
+
detect validation-shaped rejections (Apollo's `GRAPHQL_VALIDATION_FAILED` code, or
|
|
449
|
+
the message patterns graphql-ruby and GitHub use), and the raised message says what
|
|
450
|
+
to do: regenerate modules and/or refresh the schema cache.
|
|
559
451
|
|
|
560
452
|
## Cast failures
|
|
561
453
|
|
|
562
|
-
When wire data disagrees with the types the schema promised at generation time
|
|
563
|
-
|
|
564
|
-
casting raises `GraphWeaver::CastError` naming the failing generated struct,
|
|
565
|
-
|
|
454
|
+
When wire data disagrees with the types the schema promised at generation time (a
|
|
455
|
+
nil where non-null was declared, a malformed scalar, an unknown enum value),
|
|
456
|
+
casting raises `GraphWeaver::CastError` naming the failing generated struct, with
|
|
457
|
+
the original exception as `#cause`.
|
|
566
458
|
|
|
567
|
-
A cast's own complaint is about the value and nothing else — "invalid date"
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
459
|
+
A cast's own complaint is about the value and nothing else — "invalid date" locates
|
|
460
|
+
nothing on a struct holding four of them — so a casting leaf also carries **its
|
|
461
|
+
response key**, and three failures that keep happening say whose bug it is rather
|
|
462
|
+
than leaving you sorbet's words:
|
|
571
463
|
|
|
572
464
|
| what came back | what the message adds |
|
|
573
465
|
|---|---|
|