graph_weaver 0.7.0 → 0.7.1

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