graph_weaver 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1447 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +20 -6
- 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 +497 -104
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +28 -0
- data/docs/scalars.md +190 -26
- data/docs/testing.md +457 -58
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +328 -3
- 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 +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +404 -197
- 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 +40 -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 +474 -106
- metadata +56 -1
data/docs/errors.md
CHANGED
|
@@ -26,6 +26,11 @@ 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
|
+
so a mutation that created the order and then failed on the way out still
|
|
31
|
+
raises, with the data hanging off `QueryError#data`. Reach for `execute` when
|
|
32
|
+
a partial answer is one you can use.
|
|
33
|
+
|
|
29
34
|
The envelope is a single generic `GraphWeaver::Response[Result]` — `response.data`
|
|
30
35
|
stays fully typed to *this* query's result, no per-query wrapper class.
|
|
31
36
|
|
|
@@ -40,12 +45,12 @@ subclass says where it failed:
|
|
|
40
45
|
|
|
41
46
|
| Class | When |
|
|
42
47
|
|-------|------|
|
|
43
|
-
| `TransportError` | no response came back — DNS, connection refused, TLS, timeout, a socket that died mid-body |
|
|
44
|
-
| `ServerError` | reached it, non-2xx HTTP — `#status`, `#body`, `#headers`, `#retry_after`, `#throttled
|
|
45
|
-
| `QueryError` |
|
|
46
|
-
| `
|
|
47
|
-
| `InputError` | the variables wouldn't build into the generated input structs — unknown/typo'd key, missing required field, out-of-range enum, wrong-typed field, wrong number of @oneOf fields — `#field`, `#struct` |
|
|
48
|
-
| `
|
|
48
|
+
| `TransportError` | no response came back — DNS, connection refused, TLS, timeout, a socket that died mid-body — `#url`, `#cause` |
|
|
49
|
+
| `ServerError` | reached it, non-2xx HTTP — `#status`, `#body`, `#headers`, `#retry_after`, `#throttled?`, `#url` |
|
|
50
|
+
| `QueryError` | a body with top-level GraphQL errors, whatever its status — `#errors`, `#data`, `#extensions`, `#codes`, `#throttled?` |
|
|
51
|
+
| `CastError` | the response wouldn't cast into the generated structs — `#struct`, `#cause` |
|
|
52
|
+
| `InputError` | the variables wouldn't build into the generated input structs — unknown/typo'd key, missing required field, out-of-range enum, wrong-typed field, wrong number of @oneOf fields — `#kind`, `#path`, `#coordinate`, `#value`, `#details`, `#field`, `#struct` |
|
|
53
|
+
| `QueryValidationError` | build time: the query didn't validate against the schema |
|
|
49
54
|
| `Codegen::Aliases::UnknownSegment` | build time: an [`alias:`](generated_modules.md#flat-accessors-with-alias) path names a field no type here has — a typo, so `optional: true` won't skip it |
|
|
50
55
|
| `ConfigurationError` | setup judged against your schema — which Ruby schema serves which subgraph (`Testing::Router`, `federation:diff`) |
|
|
51
56
|
| `Testing::Unplannable` | the local test router won't plan this operation — `#category`, `#detail` |
|
|
@@ -70,15 +75,21 @@ end
|
|
|
70
75
|
```
|
|
71
76
|
|
|
72
77
|
`#throttled?` deliberately spells the same on both: an API may say "slow
|
|
73
|
-
down" with a 429 or with a `THROTTLED` error in a
|
|
78
|
+
down" with a 429 or with a `THROTTLED` error in a body, and a caller
|
|
74
79
|
shouldn't have to know which. It recognizes the codes the big graphs
|
|
75
80
|
actually send (`GraphWeaver::GraphQLError::THROTTLE_CODES` — Shopify's
|
|
76
|
-
`THROTTLED`, GitHub's `RATE_LIMITED`,
|
|
77
|
-
`Retry`'s `retry_codes:`
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
`THROTTLED`, GitHub's `RATE_LIMITED`, Apollo Router's
|
|
82
|
+
`REQUEST_RATE_LIMITED`); pass that constant to `Retry`'s `retry_codes:`
|
|
83
|
+
instead of hand-writing the strings.
|
|
84
|
+
|
|
85
|
+
**Which arm catches a failure is the server's choice, not a rule you can
|
|
86
|
+
rely on.** An origin server answers 429 with no body and you get a
|
|
87
|
+
`ServerError`; Apollo Router answers the same rate limit with `503` *and* a
|
|
88
|
+
GraphQL errors body, so the same failure arrives as a `QueryError` — and
|
|
89
|
+
its 500s, 401s and 403s come the same way. So don't put the retry decision
|
|
90
|
+
in the `ServerError` arm: hand it to [`Retry`](transports.md#retries), which
|
|
91
|
+
asks the same question of both, and keep these arms for what you do with a
|
|
92
|
+
failure you aren't retrying.
|
|
82
93
|
|
|
83
94
|
**A status with an obvious next step says it.** A 3xx appends "redirects are
|
|
84
95
|
not followed" and the `Location` to repoint the client at — replaying a POST,
|
|
@@ -86,6 +97,14 @@ with its `Authorization` header, at a host the server named isn't the
|
|
|
86
97
|
library's call. A 401 or 403 appends "check `auth:` — the token, and its
|
|
87
98
|
scopes".
|
|
88
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
|
+
|
|
89
108
|
**Everything you pass to `execute` is caller input**, so a value that won't
|
|
90
109
|
convert raises `GraphWeaver::InputError` — top-level scalar variables included.
|
|
91
110
|
They name the variable and the operation, since the value alone locates nothing
|
|
@@ -97,7 +116,9 @@ $count of Compute: expected an Int, got "lots"
|
|
|
97
116
|
|
|
98
117
|
The value is usually the whole diagnosis, so it is quoted — unless the key it
|
|
99
118
|
arrived under is one your `filter_parameters` covers, in which case the message
|
|
100
|
-
reads `$password of Login: [FILTERED]`.
|
|
119
|
+
reads `$password of Login: [FILTERED]`. A filtered key *inside* the value is
|
|
120
|
+
covered too, at any depth: `got {"user" => "d", "token" => "[FILTERED]"}`, the
|
|
121
|
+
same scrubbing `#value` gets. Error messages reach the log at `warn`,
|
|
101
122
|
above the level that gates the variables line, so they are scrubbed by the same
|
|
102
123
|
list ([logging](logging.md#filtered-variables)).
|
|
103
124
|
|
|
@@ -113,11 +134,27 @@ input into a 422:
|
|
|
113
134
|
rescue GraphWeaver::InputError => e
|
|
114
135
|
render json: e.to_h, status: :unprocessable_entity
|
|
115
136
|
# { "error" => "GraphWeaver::InputError",
|
|
116
|
-
# "message" => "
|
|
117
|
-
#
|
|
137
|
+
# "message" => "$input of AdoptMutation: species: \"LIZARD\" is not a valid " \
|
|
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" }
|
|
118
143
|
end
|
|
119
144
|
```
|
|
120
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
|
+
|
|
121
158
|
A nested filter reports the innermost input type, so the error points at the
|
|
122
159
|
input that actually held the bad field. Passing something that is neither — a
|
|
123
160
|
bare `String` where the input goes — reports the same way. A call site that
|
|
@@ -125,6 +162,79 @@ bare `String` where the input goes — reports the same way. A call site that
|
|
|
125
162
|
as narrow as the schema, and only untyped values reach the runtime check
|
|
126
163
|
([why](generated_modules.md#variables-become-typed-kwargs)).
|
|
127
164
|
|
|
165
|
+
### What an InputError says, without reading English
|
|
166
|
+
|
|
167
|
+
`#message` is the developer's line and it will be reworded. Everything a form
|
|
168
|
+
or an API response needs is beside it, as data:
|
|
169
|
+
|
|
170
|
+
| | |
|
|
171
|
+
|---|---|
|
|
172
|
+
| `#kind` | one of eight Symbols — `GraphWeaver::InputError::KINDS`. The key an app translates; [i18n](i18n.md) has the table of what each means |
|
|
173
|
+
| `#path` | the route from the variable down, Strings and list indices: `["where", "_and", 0, "_not", "species"]`. Every named segment is the **schema's** spelling — see [which spelling](#which-spelling-a-path-is-in) |
|
|
174
|
+
| `#coordinate` | the [schema coordinate](https://github.com/graphql/graphql-spec/pull/794) for the slot — `"PetFilter.species"`. `nil` when there isn't one |
|
|
175
|
+
| `#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
|
+
| `#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
|
+
| `#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 |
|
|
179
|
+
|
|
180
|
+
So a form reads `e.field` and either `e.message` or — better — its own sentence
|
|
181
|
+
built from `e.kind` and `e.details`.
|
|
182
|
+
|
|
183
|
+
**When the leaf isn't a field.** A Hasura-shaped filter puts a comparison
|
|
184
|
+
operator at the bottom, so `where: { height: { _gte: "abc" } }` refuses with
|
|
185
|
+
`#path` `["where", "height", "_gte"]` and `#field` `"_gte"` — right by the rule,
|
|
186
|
+
and useless to a form. Key the form on `#path` there: the column is the segment
|
|
187
|
+
before the operator.
|
|
188
|
+
|
|
189
|
+
**Long values are cut.** An error is built for whatever a caller sent and
|
|
190
|
+
whatever a server echoed back, either of which can be megabytes — and every
|
|
191
|
+
raised one writes a `warn` line as well as landing in `to_h`. So each String
|
|
192
|
+
`#value` holds (at every depth), the value `#message` quotes, and a sentence a
|
|
193
|
+
server wrote are capped at `GraphWeaver::InputError::VALUE_LIMIT` — 1024 bytes,
|
|
194
|
+
with `…(N more bytes)` in place of the rest.
|
|
195
|
+
|
|
196
|
+
#### Which spelling a path is in
|
|
197
|
+
|
|
198
|
+
**`#path`, `#field` and `#coordinate` are the schema's spelling**
|
|
199
|
+
(`issuedOn`, `externalId`) — one rule, whichever side refused. A server can
|
|
200
|
+
produce no other, and the client knows both, so this is the only spelling both
|
|
201
|
+
halves can agree on: a form keyed on `e.field` finds the same slot for a
|
|
202
|
+
refusal raised before the request left and for one the server sent back.
|
|
203
|
+
|
|
204
|
+
The **prop** (`issued_on`) is what you type in Ruby — `.new`, `.coerce`, the
|
|
205
|
+
kwargs of `execute` — and it is `#message`, the developer's line, that names
|
|
206
|
+
it: `"external_id: expected an Int, got \"lots\""`. Two names for one field,
|
|
207
|
+
each where it helps.
|
|
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:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
form.errors.add(e.field&.underscore || :base, render_input_error(e))
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The one segment that is neither is an **unknown key** — a typo names no field,
|
|
219
|
+
so the schema has no spelling for it. It comes back exactly as you wrote it,
|
|
220
|
+
and `details[:suggestion]` is the prop to type instead.
|
|
221
|
+
|
|
222
|
+
**`#path` is rooted at the variable**, so its first segment is the kwarg you
|
|
223
|
+
passed and its last is the field that actually held the value:
|
|
224
|
+
|
|
225
|
+
| you called | `#path` | `#coordinate` |
|
|
226
|
+
|---|---|---|
|
|
227
|
+
| `execute(input: {name: "Rex", species: "LIZARD"})` | `["input", "species"]` | `"AdoptionInput.species"` |
|
|
228
|
+
| `execute(input: {issued_on: "x", external_id: "lots"})` — a camelCase field | `["input", "externalId"]` — the schema's spelling | `"InvoiceInput.externalId"` |
|
|
229
|
+
| `execute(where: {_and: [{_not: {species: "LIZARD"}}]})` | `["where", "_and", 0, "_not", "species"]` | `"PetFilter.species"` |
|
|
230
|
+
| `execute(ids: [1, 2, "x"])` — a list of leaves | `["ids", 2]` | `nil` — a list element is a position, not a slot |
|
|
231
|
+
| `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
|
+
| `execute(count: "lots")` — a top-level scalar | `["count"]` | `nil` — a variable names no schema element |
|
|
233
|
+
|
|
234
|
+
`#coordinate` is `nil` wherever the schema has no name for the slot: a
|
|
235
|
+
variable, a key the input type doesn't define, a nested `@key` path in a
|
|
236
|
+
federation representation, or a server that didn't say which type it meant.
|
|
237
|
+
|
|
128
238
|
`#struct` is the generated input struct *class* where generation produced one,
|
|
129
239
|
and the GraphQL type *name* where it didn't — a federation representation
|
|
130
240
|
builds a plain Hash, so an entity has only its name to give. `to_h`'s
|
|
@@ -137,6 +247,261 @@ deserialize onto `response.data` like anything else and you inspect them there.
|
|
|
137
247
|
The one-shot `GraphWeaver.run` / `run!` mirror this: `run` returns
|
|
138
248
|
the envelope, `run!` the result-or-raise.
|
|
139
249
|
|
|
250
|
+
### When the *server* rejects the input
|
|
251
|
+
|
|
252
|
+
**Nothing here is portable.** The GraphQL spec reserves `extensions` for
|
|
253
|
+
implementors and defines no codes at all, so "this error is about the input you
|
|
254
|
+
sent" is a convention each server invents — or doesn't. graph_weaver reads the
|
|
255
|
+
three it knows by name, and claims nothing from the rest:
|
|
256
|
+
|
|
257
|
+
| server | what marks an error as being about the input | what you get |
|
|
258
|
+
|---|---|---|
|
|
259
|
+
| **graphql-ruby** | the variable-coercion `problems` array, or one of four rule names in `extensions.code` (`GraphWeaver::GraphQLError::INPUT_CODES`) | the field, and a `kind` read off a closed table of its explanations |
|
|
260
|
+
| **Apollo** | `extensions.code` = `BAD_USER_INPUT` (Apollo Router's `VALIDATION_INVALID_TYPE_VARIABLE` is not read; graphql-js sends no `extensions` at all) | `:refused` with the server's sentence, and the field only where `argumentName` is stated |
|
|
261
|
+
| **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
|
+
| **anything else** | nothing | `#input_errors` is `[]` — see [the fallback](#when-your-server-marks-nothing) |
|
|
263
|
+
|
|
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
|
+
`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
|
+
a single variable-coercion error routinely carries several problems about
|
|
280
|
+
different fields, and keeping only the first would lose the rest silently.
|
|
281
|
+
These are values, not raises — building one writes no log line. `#message` and
|
|
282
|
+
`#value` go through `filter_parameters` here exactly as they do on the client
|
|
283
|
+
side: a server quotes the value it rejected as a matter of course
|
|
284
|
+
(`Could not coerce value "hunter2" to Int`), and that is a message about a key
|
|
285
|
+
your list covers.
|
|
286
|
+
|
|
287
|
+
Generated modules always send **variables**, never literals, which narrows a
|
|
288
|
+
graphql-ruby server to two shapes (measured against 2.6.10):
|
|
289
|
+
|
|
290
|
+
| the server's rejection | what arrives | `#input_errors` |
|
|
291
|
+
|---|---|---|
|
|
292
|
+
| the variable didn't coerce — wrong type, not an enum member, a required field null, a key the input type doesn't define, a custom scalar's `GraphQL::CoercionError` | a **request** error: no `data` key at all, one error with no `path`, and `extensions` = `{"value" => «the whole variable», "problems" => [{"path" => ["level2","count"], "explanation" => "Could not coerce value \"nope\" to Int"}]}` — but **no `code`** | one per problem, `kind` from a table over `explanation`, `path` = `[variable, *problem.path]` |
|
|
293
|
+
| 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
|
+
|
|
295
|
+
**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
|
+
field is worse than missing it — so it stays an ordinary error in
|
|
298
|
+
`response.errors` and `#input_errors` says nothing it can't know. One line on
|
|
299
|
+
the server fixes it, and the next section is that line.
|
|
300
|
+
|
|
301
|
+
**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
|
+
code alone would attach your own `.graphql` file to a form field. The argument
|
|
304
|
+
in `extensions.path` is what settles it, and only three of its sentences earn a
|
|
305
|
+
`kind`: `limit: -5` comes back `:refused` on `path: ["limit"]`, because the
|
|
306
|
+
sentence Hasura writes for it ("expected a non-negative 32-bit integer for type
|
|
307
|
+
'Int', but found a number") is the same one it writes for `limit: "lots"` —
|
|
308
|
+
a wrong type, not a value out of range. The field is worth having; the guess
|
|
309
|
+
isn't.
|
|
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
|
|
313
|
+
rejects (`{ id: { _eq: "abc" } }` on a `uuid`) gets past validation and fails at
|
|
314
|
+
the database, which comes back as `data-exception` at path `"$"` — neither code
|
|
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.
|
|
317
|
+
|
|
318
|
+
#### When your server marks nothing
|
|
319
|
+
|
|
320
|
+
Then `#input_errors` is `[]` and says so — which is the signal to render what
|
|
321
|
+
the server *did* send, not to parse its prose:
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
response = AdoptMutation.execute(input: params[:pet])
|
|
325
|
+
|
|
326
|
+
if response.input_errors.any?
|
|
327
|
+
response.input_errors.each { |e| form.errors.add(e.field&.underscore || :base, e.message) }
|
|
328
|
+
elsif response.errors.any?
|
|
329
|
+
# nothing claimed to be about the input: show what was said, and log the
|
|
330
|
+
# rest — #extensions is where a server you're onboarding states its own
|
|
331
|
+
# convention, and the next section is how to make it one this reads
|
|
332
|
+
flash[:alert] = response.errors.map(&:message).join(", ")
|
|
333
|
+
Rails.logger.warn(response.report)
|
|
334
|
+
end
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### What your server can send
|
|
338
|
+
|
|
339
|
+
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
|
+
own.** The client doesn't know the schema's bounds, and graphql-ruby puts a
|
|
342
|
+
`validates:` failure on the wire as a bare sentence. Only the server can say
|
|
343
|
+
it, so there is one key to say it under:
|
|
344
|
+
|
|
345
|
+
```json
|
|
346
|
+
"extensions": {
|
|
347
|
+
"code": "BAD_USER_INPUT",
|
|
348
|
+
"input": {
|
|
349
|
+
"kind": "out_of_range",
|
|
350
|
+
"path": ["input", "min"],
|
|
351
|
+
"coordinate": "RangeInput.min",
|
|
352
|
+
"value": 0,
|
|
353
|
+
"min": 1
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
`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
|
+
required, and it must come from [the table](i18n.md#the-vocabulary) — an
|
|
361
|
+
unrecognized one degrades to `:refused` rather than being passed through, and
|
|
362
|
+
any key outside `type`/`members`/`min`/`max`/`pattern`/`suggestion` is dropped
|
|
363
|
+
rather than reaching `#details`. None of those six is a name I18n reserves for
|
|
364
|
+
itself, so `I18n.t(key, **details)` can never raise on the splat.
|
|
365
|
+
|
|
366
|
+
`path` must be an **Array of field names and list indices**. A dotted String,
|
|
367
|
+
or a segment that is neither, is dropped whole rather than parsed — and nothing
|
|
368
|
+
stands in for it: `#path` is `[]` and `#field` is `nil`. In particular the
|
|
369
|
+
GraphQL error's own `path` is never borrowed, because it names a *selection*
|
|
370
|
+
(`["createOrder"]`) rather than an input slot, and a form that trusted `#field`
|
|
371
|
+
there would highlight a field called `create_order`. Everything else the server
|
|
372
|
+
did state — the `kind`, the `details` — still stands.
|
|
373
|
+
|
|
374
|
+
In graphql-ruby this rides on a `Validator` raising `GraphQL::ExecutionError`:
|
|
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.
|
|
439
|
+
|
|
440
|
+
```ruby
|
|
441
|
+
class LinesValidator < GraphQL::Schema::Validator
|
|
442
|
+
def validate(_object, _context, lines)
|
|
443
|
+
lines.each_with_index do |line, index|
|
|
444
|
+
next if line[:qty] >= 1
|
|
445
|
+
|
|
446
|
+
raise GraphQL::ExecutionError.new(
|
|
447
|
+
"qty must be at least 1",
|
|
448
|
+
extensions: { "code" => "BAD_USER_INPUT", "input" => {
|
|
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
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Per-element validators run first and the list's own runs after, so by the time
|
|
463
|
+
this one sees `lines` every element is fully coerced.
|
|
464
|
+
|
|
465
|
+
What the client then reads:
|
|
466
|
+
|
|
467
|
+
```ruby
|
|
468
|
+
# min: 0 into the validator above
|
|
469
|
+
{ "kind" => "out_of_range", "path" => ["input", "min"], "coordinate" => "RangeInput.min",
|
|
470
|
+
"field" => "min", "value" => 0, "details" => { "min" => 1 },
|
|
471
|
+
"message" => "min must be at least 1" }
|
|
472
|
+
|
|
473
|
+
# email: "nope" into the scalar above
|
|
474
|
+
{ "kind" => "invalid_format", "path" => ["email"], "field" => "email", "value" => "nope",
|
|
475
|
+
"details" => { "pattern" => "name@example.com" },
|
|
476
|
+
"message" => "\"nope\" is not an email address" }
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
Say it plainly: **without the convention**, that range failure is
|
|
480
|
+
`:refused` at best — the message and nothing else, and only if the server
|
|
481
|
+
stamped `BAD_USER_INPUT`. **With it**, it is `:out_of_range` with `min` as a
|
|
482
|
+
number your form can compare against. A server that follows none of this
|
|
483
|
+
degrades; it does not guess.
|
|
484
|
+
|
|
485
|
+
**One problem per call.** `#input_errors` is plural, but a single call rarely
|
|
486
|
+
fills it — neither side collects the way `ActiveModel::Errors` does:
|
|
487
|
+
|
|
488
|
+
- *Client side*, `coerce` walks the input type's fields in declaration order
|
|
489
|
+
and raises on the first one whose value won't convert; the rest are never
|
|
490
|
+
looked at. The two cases it does gather are the ones it can see all of
|
|
491
|
+
without walking further — **unknown keys** and **absent required fields** are
|
|
492
|
+
both listed in full in the message (`missing required key(s) for
|
|
493
|
+
AdoptionInput: name, species`), though `#path` names only the first, because
|
|
494
|
+
a path that points at two fields points at neither.
|
|
495
|
+
- *Server side*, graphql-ruby (measured against 2.6.10) aborts variable
|
|
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.
|
|
504
|
+
|
|
140
505
|
## Extending TransportError
|
|
141
506
|
|
|
142
507
|
What counts as a `TransportError` is an **extensible set** — each transport
|
|
@@ -196,7 +561,7 @@ refresh the schema cache.
|
|
|
196
561
|
|
|
197
562
|
When wire data disagrees with the types the schema promised at generation time
|
|
198
563
|
(a nil where non-null was declared, a malformed scalar, an unknown enum value),
|
|
199
|
-
casting raises `GraphWeaver::
|
|
564
|
+
casting raises `GraphWeaver::CastError` naming the failing generated struct,
|
|
200
565
|
with the original exception as `#cause`.
|
|
201
566
|
|
|
202
567
|
A cast's own complaint is about the value and nothing else — "invalid date"
|