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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +40 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +474 -106
  70. 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` | 200 body with top-level GraphQL errors — `#errors`, `#data`, `#extensions`, `#codes`, `#throttled?` |
46
- | `TypeError` | the response wouldn't cast into the generated structs — `#struct`, `#cause` |
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
- | `ValidationError` | build time: the query didn't validate against the schema |
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 200 body, and a caller
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`, and friends); pass that constant to
77
- `Retry`'s `retry_codes:` instead of hand-writing the strings.
78
-
79
- Or skip the hand-rolling: [`Retry`](transports.md#retries) wraps any client and
80
- already defaults to exactly the policy above transport failures always,
81
- `ServerError` on 5xx plus 408/429, and GraphQL error codes you name.
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]`. Error messages reach the log at `warn`,
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" => "unknown key(s) for …Input: staus (did you mean 'status'?)",
117
- # "field" => "staus", "struct" => "…Input" }
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::TypeError` naming the failing generated struct,
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"