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.
- checksums.yaml +4 -4
- data/Gemfile +8 -0
- data/Gemfile.lock +153 -4
- data/README.md +45 -79
- data/docs/alternatives.md +195 -0
- data/docs/cassettes.md +61 -50
- data/docs/editors.md +32 -47
- data/docs/errors.md +360 -103
- data/docs/federation.md +692 -473
- data/docs/generated_modules.md +441 -314
- data/docs/getting_started.md +370 -194
- data/docs/i18n.md +171 -0
- data/docs/logging.md +197 -50
- data/docs/real_world.md +42 -27
- data/docs/scalars.md +307 -176
- data/docs/testing.md +473 -220
- data/docs/transports.md +224 -151
- data/docs/upgrading.md +258 -305
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +19 -3
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +69 -11
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +406 -195
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +284 -46
- data/lib/graph_weaver/federation.rb +129 -27
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +27 -15
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +80 -0
- data/lib/graph_weaver/internal/headers.rb +70 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +40 -4
- data/lib/graph_weaver/internal.rb +249 -14
- data/lib/graph_weaver/log_subscriber.rb +74 -0
- data/lib/graph_weaver/logging.rb +163 -19
- data/lib/graph_weaver/query_module.rb +44 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +45 -13
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +266 -56
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +34 -10
- data/lib/graph_weaver/testing/endpoint.rb +107 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +164 -45
- data/lib/graph_weaver/testing/router.rb +64 -13
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +48 -6
- data/lib/graph_weaver/transport.rb +134 -27
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +495 -106
- metadata +71 -3
- 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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
`Retry`
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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]
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
`
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
199
|
-
casting raises `GraphWeaver::
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|---|---|
|