graph_weaver 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +8 -0
  3. data/Gemfile.lock +153 -4
  4. data/README.md +45 -79
  5. data/docs/alternatives.md +195 -0
  6. data/docs/cassettes.md +61 -50
  7. data/docs/editors.md +32 -47
  8. data/docs/errors.md +360 -103
  9. data/docs/federation.md +692 -473
  10. data/docs/generated_modules.md +441 -314
  11. data/docs/getting_started.md +370 -194
  12. data/docs/i18n.md +171 -0
  13. data/docs/logging.md +197 -50
  14. data/docs/real_world.md +42 -27
  15. data/docs/scalars.md +307 -176
  16. data/docs/testing.md +473 -220
  17. data/docs/transports.md +224 -151
  18. data/docs/upgrading.md +258 -305
  19. data/examples/README.md +38 -0
  20. data/examples/countries.rb +39 -0
  21. data/examples/federation.rb +62 -0
  22. data/examples/github/generate.rb +20 -0
  23. data/examples/github/generated/star_mutation.rb +126 -0
  24. data/examples/github/generated/stargazers_query.rb +232 -0
  25. data/examples/github/generated/starred_query.rb +151 -0
  26. data/examples/github/queries/star.graphql +8 -0
  27. data/examples/github/queries/stargazers.graphql +22 -0
  28. data/examples/github/queries/starred.graphql +11 -0
  29. data/examples/github/run.rb +43 -0
  30. data/examples/github/setup.rb +18 -0
  31. data/examples/rick_and_morty.rb +57 -0
  32. data/graph_weaver.gemspec +19 -3
  33. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  34. data/lib/graph_weaver/client.rb +69 -11
  35. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  36. data/lib/graph_weaver/codegen/emit.rb +98 -29
  37. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  38. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  39. data/lib/graph_weaver/codegen/registry.rb +175 -0
  40. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  41. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  42. data/lib/graph_weaver/codegen.rb +406 -195
  43. data/lib/graph_weaver/coerce.rb +155 -26
  44. data/lib/graph_weaver/context_seam.rb +54 -0
  45. data/lib/graph_weaver/errors.rb +284 -46
  46. data/lib/graph_weaver/federation.rb +129 -27
  47. data/lib/graph_weaver/graph.rb +315 -0
  48. data/lib/graph_weaver/hints.rb +100 -24
  49. data/lib/graph_weaver/in_process.rb +27 -15
  50. data/lib/graph_weaver/input_struct.rb +119 -32
  51. data/lib/graph_weaver/internal/endpoint.rb +80 -0
  52. data/lib/graph_weaver/internal/headers.rb +70 -0
  53. data/lib/graph_weaver/internal/overrides.rb +67 -5
  54. data/lib/graph_weaver/internal/planner.rb +45 -15
  55. data/lib/graph_weaver/internal/refusal.rb +49 -0
  56. data/lib/graph_weaver/internal/schemas.rb +23 -9
  57. data/lib/graph_weaver/internal/selection.rb +34 -0
  58. data/lib/graph_weaver/internal/server_input.rb +251 -0
  59. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  60. data/lib/graph_weaver/internal/unused.rb +287 -0
  61. data/lib/graph_weaver/internal/values.rb +40 -4
  62. data/lib/graph_weaver/internal.rb +249 -14
  63. data/lib/graph_weaver/log_subscriber.rb +74 -0
  64. data/lib/graph_weaver/logging.rb +163 -19
  65. data/lib/graph_weaver/query_module.rb +44 -3
  66. data/lib/graph_weaver/railtie.rb +237 -17
  67. data/lib/graph_weaver/representation.rb +55 -17
  68. data/lib/graph_weaver/result_struct.rb +90 -0
  69. data/lib/graph_weaver/retry.rb +45 -13
  70. data/lib/graph_weaver/rspec.rb +404 -93
  71. data/lib/graph_weaver/schema_loader.rb +266 -56
  72. data/lib/graph_weaver/tasks.rb +380 -89
  73. data/lib/graph_weaver/testing/cassette.rb +34 -10
  74. data/lib/graph_weaver/testing/endpoint.rb +107 -0
  75. data/lib/graph_weaver/testing/failure.rb +69 -12
  76. data/lib/graph_weaver/testing/fake_client.rb +164 -45
  77. data/lib/graph_weaver/testing/router.rb +64 -13
  78. data/lib/graph_weaver/testing.rb +200 -58
  79. data/lib/graph_weaver/transport/faraday.rb +41 -8
  80. data/lib/graph_weaver/transport/http.rb +48 -6
  81. data/lib/graph_weaver/transport.rb +134 -27
  82. data/lib/graph_weaver/version.rb +1 -1
  83. data/lib/graph_weaver.rb +495 -106
  84. metadata +71 -3
  85. data/CHANGELOG.md +0 -2355
data/docs/errors.md CHANGED
@@ -26,36 +26,47 @@ response.extensions # { "cost" => … } — rides on success too
26
26
  response.data! # the Result, or raise GraphWeaver::QueryError
27
27
  ```
28
28
 
29
- The envelope is a single generic `GraphWeaver::Response[Result]` `response.data`
30
- stays fully typed to *this* query's result, no per-query wrapper class.
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.
31
33
 
32
- Every `GraphQLError` exposes `#message`, `#locations`, `#path`, `#extensions`,
33
- and `#code` (`extensions["code"]`) — match on the **code**, not the message
34
- string (`response.errors.first.code == "THROTTLED"`).
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"`).
35
37
 
36
- Everything GraphWeaver *concludes* descends from `GraphWeaver::Error` — a
37
- transport failure, a rejected query, a response that wouldn't cast, a plan the
38
- [local router](federation.md) refused, a subgraph map that doesn't add up. The
39
- subclass says where it failed:
38
+ ## The three you'll meet first
40
39
 
41
- | Class | When |
42
- |-------|------|
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 |
49
- | `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
- | `ConfigurationError` | setup judged against your schema — which Ruby schema serves which subgraph (`Testing::Router`, `federation:diff`) |
51
- | `Testing::Unplannable` | the local test router won't plan this operation — `#category`, `#detail` |
52
- | `Testing::MissingRecording` | a [cassette](cassettes.md) holds no entry for this request — the message prints the variables, and the ones it did record |
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:
53
43
 
54
- An argument that is wrong *on its face* raises a plain `ArgumentError` instead
55
- (`pool_size: must be >= 1`, `cast: must be a Symbol, Proc, :itself, or nil`),
56
- like any Ruby method — a bug at the call site, not a condition to rescue. The
57
- line is whether the library had to read your schema to reach the verdict: it
58
- did for `ConfigurationError`, which is why a spec helper can rescue that one.
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:
59
70
 
60
71
  ```ruby
61
72
  begin
@@ -69,27 +80,72 @@ rescue GraphWeaver::QueryError => e
69
80
  end
70
81
  ```
71
82
 
72
- `#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
74
- shouldn't have to know which. It recognizes the codes the big graphs
75
- 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.
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`).
78
92
 
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.
93
+ ## The classes
82
94
 
83
- **A status with an obvious next step says it.** A 3xx appends "redirects are
84
- not followed" and the `Location` to repoint the client at replaying a POST,
85
- with its `Authorization` header, at a host the server named isn't the
86
- library's call. A 401 or 403 appends "check `auth:` — the token, and its
87
- scopes".
95
+ Everything GraphWeaver *concludes* descends from `GraphWeaver::Error` a
96
+ transport failure, a rejected query, a response that wouldn't cast, a plan the
97
+ [local router](federation.md) refused, a subgraph map that doesn't add up. The
98
+ subclass says where it failed:
88
99
 
89
- **Everything you pass to `execute` is caller input**, so a value that won't
90
- convert raises `GraphWeaver::InputError` — top-level scalar variables included.
91
- They name the variable and the operation, since the value alone locates nothing
92
- in an app that runs a hundred queries:
100
+ | Class | When |
101
+ |-------|------|
102
+ | `TransportError` | no response came back — DNS, connection refused, TLS, timeout, a socket that died mid-body — `#url`, `#cause` |
103
+ | `ServerError` | reached it, non-2xx HTTP — `#status`, `#body`, `#headers`, `#retry_after`, `#throttled?`, `#url` |
104
+ | `QueryError` | a body with top-level GraphQL errors, whatever its status — `#errors`, `#data`, `#extensions`, `#codes`, `#throttled?` |
105
+ | `CastError` | the response wouldn't cast into the generated structs — `#struct`, `#cause` |
106
+ | `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` |
107
+ | `QueryValidationError` | build time: the query didn't validate against the schema |
108
+ | `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 |
109
+ | `ConfigurationError` | setup judged against your schema — which Ruby schema serves which subgraph (`Testing::Router`, `federation:diff`) |
110
+ | `Testing::Unplannable` | the local test router won't plan this operation — `#category`, `#detail` |
111
+ | `Testing::MissingRecording` | a [cassette](cassettes.md) holds no entry for this request — the message prints the variables, and the ones it did record |
112
+
113
+ An argument that is wrong *on its face* raises a plain `ArgumentError` instead
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:
93
149
 
94
150
  ```
95
151
  $count of Compute: expected an Int, got "lots"
@@ -97,64 +153,268 @@ $count of Compute: expected an Int, got "lots"
97
153
 
98
154
  The value is usually the whole diagnosis, so it is quoted — unless the key it
99
155
  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`,
101
- above the level that gates the variables line, so they are scrubbed by the same
102
- list ([logging](logging.md#filtered-variables)).
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`
175
+ ([why](generated_modules.md#variables-become-typed-kwargs)).
103
176
 
104
- A *missing* required kwarg is still a plain `ArgumentError` ("missing keyword:
105
- :id") — that's Ruby's, and it is a programming bug rather than bad input.
177
+ The one-shot `GraphWeaver.run` / `run!` mirror `execute` / `execute!`.
106
178
 
107
- **What's inside an input object** reports the same way. Pass one as a hash (or
108
- struct) and it's built through the generated `coerce`, and anything wrong in
109
- there raises `GraphWeaver::InputError` too so one rescue point turns invalid
110
- input into a 422:
179
+ ### What an InputError says, without reading English
180
+
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:
183
+
184
+ | | |
185
+ |---|---|
186
+ | `#kind` | one of eight Symbols — `GraphWeaver::InputError::KINDS`. The key an app translates; [i18n](i18n.md) has the table of what each means |
187
+ | `#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) |
188
+ | `#coordinate` | the [schema coordinate](https://github.com/graphql/graphql-spec/pull/794) for the slot — `"PetFilter.species"`. `nil` when there isn't one |
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 |
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 |
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"` |
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 |
193
+
194
+ So a form reads `e.field` and either `e.message` or — better — its own sentence
195
+ built from `e.kind` and `e.details`.
196
+
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.
202
+
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.
209
+
210
+ #### Which spelling a path is in
211
+
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.
217
+
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**:
111
222
 
112
223
  ```ruby
113
- rescue GraphWeaver::InputError => e
114
- render json: e.to_h, status: :unprocessable_entity
115
- # { "error" => "GraphWeaver::InputError",
116
- # "message" => "unknown key(s) for …Input: staus (did you mean 'status'?)",
117
- # "field" => "staus", "struct" => "…Input" }
224
+ form.errors.add(e.field&.underscore || :base, render_input_error(e))
225
+ ```
226
+
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.
230
+
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:
233
+
234
+ | you called | `#path` | `#coordinate` |
235
+ |---|---|---|
236
+ | `execute(input: {name: "Rex", species: "LIZARD"})` | `["input", "species"]` | `"AdoptionInput.species"` |
237
+ | `execute(input: {issued_on: "x", external_id: "lots"})` — a camelCase field | `["input", "externalId"]` — the schema's spelling | `"InvoiceInput.externalId"` |
238
+ | `execute(where: {_and: [{_not: {species: "LIZARD"}}]})` | `["where", "_and", 0, "_not", "species"]` | `"PetFilter.species"` |
239
+ | `execute(ids: [1, 2, "x"])` — a list of leaves | `["ids", 2]` | `nil` — a list element is a position, not a slot |
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 |
241
+ | `execute(count: "lots")` — a top-level scalar | `["count"]` | `nil` — a variable names no schema element |
242
+
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.
246
+
247
+ ### When the *server* rejects the input
248
+
249
+ **Nothing here is portable.** The GraphQL spec reserves `extensions` for
250
+ implementors and defines no codes at all, so "this error is about the input you
251
+ sent" is a convention each server invents — or doesn't. graph_weaver reads the
252
+ three it knows by name, and claims nothing from the rest:
253
+
254
+ | server | what marks an error as being about the input | what you get |
255
+ |---|---|---|
256
+ | **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 |
257
+ | **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 |
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 |
259
+ | **anything else** | nothing | `#input_errors` is `[]` — see [the fallback](#when-your-server-marks-nothing) |
260
+
261
+ `QueryError#input_errors` asks the same question of the raised envelope, and
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.
269
+
270
+ Generated modules always send **variables**, never literals, which narrows a
271
+ graphql-ruby server to two shapes (measured against 2.6.10):
272
+
273
+ | the server's rejection | what arrives | `#input_errors` |
274
+ |---|---|---|
275
+ | 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]` |
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 |
277
+
278
+ **A `validates:` failure is not claimed.** With no `extensions` at all it is
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.
283
+
284
+ **Hasura is read off the path, not a code.** `validation-failed` is the code it
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
293
+ rejects (`{ id: { _eq: "abc" } }` on a `uuid`) gets past validation and fails at
294
+ the database, arriving as `data-exception` at path `"$"` with no argument named.
295
+
296
+ #### When your server marks nothing
297
+
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:
300
+
301
+ ```ruby
302
+ response = AdoptMutation.execute(input: params[:pet])
303
+
304
+ if response.input_errors.any?
305
+ response.input_errors.each { |e| form.errors.add(e.field&.underscore || :base, e.message) }
306
+ elsif response.errors.any?
307
+ # nothing claimed to be about the input: show what was said, and log the
308
+ # rest — #extensions is where a server you're onboarding states its own
309
+ # convention, and the next section is how to make it one this reads
310
+ flash[:alert] = response.errors.map(&:message).join(", ")
311
+ Rails.logger.warn(response.report)
118
312
  end
119
313
  ```
120
314
 
121
- A nested filter reports the innermost input type, so the error points at the
122
- input that actually held the bad field. Passing something that is neither — a
123
- bare `String` where the input goesreports the same way. A call site that
124
- *spells* the wrong type is caught earlier and better, by `srb tc`: the sig is
125
- as narrow as the schema, and only untyped values reach the runtime check
126
- ([why](generated_modules.md#variables-become-typed-kwargs)).
315
+ ### What your server can send
316
+
317
+ Two of the eight kinds`:out_of_range` and `:invalid_format`, the everyday
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:
322
+
323
+ ```json
324
+ "extensions": {
325
+ "code": "BAD_USER_INPUT",
326
+ "input": {
327
+ "kind": "out_of_range",
328
+ "path": ["input", "min"],
329
+ "coordinate": "RangeInput.min",
330
+ "value": 0,
331
+ "min": 1
332
+ }
333
+ }
334
+ ```
127
335
 
128
- `#struct` is the generated input struct *class* where generation produced one,
129
- and the GraphQL type *name* where it didn't a federation representation
130
- builds a plain Hash, so an entity has only its name to give. `to_h`'s
131
- `"struct"` is the name either way, so branch on that.
336
+ `code` is the ecosystem's coarse bucket, so a client that has never heard of
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`:
132
354
 
133
- Business/validation failures returned *as data* (Shopify-style `userErrors { field
134
- message code }`) aren't errors here — they're just fields you selected, so they
135
- deserialize onto `response.data` like anything else and you inspect them there.
355
+ ```ruby
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
+ )
364
+ ```
136
365
 
137
- The one-shot `GraphWeaver.run` / `run!` mirror this: `run` returns
138
- the envelope, `run!` the result-or-raise.
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.
139
400
 
140
401
  ## Extending TransportError
141
402
 
142
- What counts as a `TransportError` is an **extensible set** — each transport
143
- seeds its own network exceptions (`Errno::*`, `SocketError`, timeouts, TLS; the
144
- Faraday transport adds its own), and you can register more so a custom adapter's
145
- or connection pool's failure gets the same treatment:
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:
146
407
 
147
408
  ```ruby
148
409
  GraphWeaver.register_transport_error(ConnectionPool::TimeoutError)
149
410
  GraphWeaver.transport_errors << MyAdapter::ResetError # it's just a Set
150
411
  ```
151
412
 
152
-
153
413
  ## Programmatic surfacing
154
414
 
155
415
  Every error is dual-surface: `#message` for humans, `#to_h` for machines — a
156
- JSON-ready hash (error class, per-error `path`/`code`/`locations`/`extensions`)
157
- you can nest straight into a log line or an API response.
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.
158
418
 
159
419
  Field-level tooling lives on both `Response` and `QueryError`:
160
420
 
@@ -172,37 +432,34 @@ response.report
172
432
  # nil => { "codes" => ["DOWN"], ... } } # global errors under nil
173
433
  ```
174
434
 
175
- `GraphQLError#field` strips list indices (`people.3.email` → `people.email`) —
176
- the stable grouping key; the raw `#path` keeps indices for exact location.
435
+ `GraphQLError#field` strips list indices (`people.3.email` → `people.email`) — the
436
+ stable grouping key; the raw `#path` keeps indices for exact location.
177
437
 
178
- `Response#to_h` decomposes the envelope the same way: `{"data" =>, "errors" =>,
179
- "extensions" =>}`, with each error as its JSON-ready hash. `data` stays the
180
- typed struct it is deliberately not re-serialized, because `T::Struct#serialize`
181
- would give snake_case keys where the wire is camelCase, drop null fields, and
182
- leave a registered scalar as the Ruby object its codec built. That output would
183
- look like the server's response without being one, so serialize the typed data
184
- 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.
185
443
 
186
444
  ## Stale schemas
187
445
 
188
- GraphQL has no schema-version signal, so a schema change surfaces as the
189
- server rejecting your query's shape. `response.schema_stale?` /
190
- `QueryError#schema_stale?` detect validation-shaped rejections (Apollo's
191
- `GRAPHQL_VALIDATION_FAILED` code, or the message patterns graphql-ruby and
192
- GitHub use), and the raised message says what to do: regenerate modules and/or
193
- 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.
194
451
 
195
452
  ## Cast failures
196
453
 
197
- When wire data disagrees with the types the schema promised at generation time
198
- (a nil where non-null was declared, a malformed scalar, an unknown enum value),
199
- casting raises `GraphWeaver::TypeError` naming the failing generated struct,
200
- with the original exception as `#cause`.
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`.
201
458
 
202
- A cast's own complaint is about the value and nothing else — "invalid date"
203
- locates nothing on a struct holding four of them — so a casting leaf also
204
- carries **its response key**, and three failures that keep happening say whose
205
- bug it is rather than leaving you sorbet's words:
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:
206
463
 
207
464
  | what came back | what the message adds |
208
465
  |---|---|