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.
- checksums.yaml +4 -4
- data/Gemfile.lock +4 -4
- data/README.md +40 -88
- data/docs/alternatives.md +1 -7
- data/docs/cassettes.md +54 -59
- data/docs/editors.md +32 -47
- data/docs/errors.md +261 -369
- data/docs/federation.md +650 -837
- data/docs/generated_modules.md +370 -459
- data/docs/getting_started.md +211 -428
- data/docs/i18n.md +114 -177
- data/docs/logging.md +127 -116
- data/docs/real_world.md +26 -39
- data/docs/scalars.md +277 -310
- data/docs/testing.md +340 -486
- data/docs/transports.md +191 -263
- data/docs/upgrading.md +188 -560
- 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 +12 -3
- data/lib/graph_weaver/client.rb +22 -1
- data/lib/graph_weaver/codegen.rb +5 -1
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +23 -15
- data/lib/graph_weaver/federation.rb +11 -2
- data/lib/graph_weaver/in_process.rb +15 -9
- data/lib/graph_weaver/internal/endpoint.rb +7 -5
- data/lib/graph_weaver/internal/headers.rb +19 -0
- data/lib/graph_weaver/internal.rb +66 -13
- data/lib/graph_weaver/log_subscriber.rb +10 -2
- data/lib/graph_weaver/logging.rb +33 -13
- data/lib/graph_weaver/query_module.rb +8 -0
- data/lib/graph_weaver/retry.rb +12 -8
- data/lib/graph_weaver/schema_loader.rb +52 -14
- data/lib/graph_weaver/testing/cassette.rb +28 -5
- data/lib/graph_weaver/testing/endpoint.rb +14 -13
- data/lib/graph_weaver/testing/fake_client.rb +33 -3
- data/lib/graph_weaver/testing/router.rb +7 -3
- data/lib/graph_weaver/transport/http.rb +2 -2
- data/lib/graph_weaver/transport.rb +47 -23
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +22 -1
- metadata +16 -3
- data/CHANGELOG.md +0 -3801
data/docs/i18n.md
CHANGED
|
@@ -1,104 +1,32 @@
|
|
|
1
1
|
# Translating input errors
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
graph_weaver hands your app **a stable key and the facts**, and lets the app own
|
|
4
|
+
the sentence. `kind` — a Symbol from a short closed vocabulary — and the key
|
|
5
|
+
derived from it, `graph_weaver.input.<kind>`, are API: they change only with a
|
|
6
|
+
MAJOR bump, and a new one arrives only as an addition. `#message` is the
|
|
7
|
+
developer's line, in English, and it will be reworded. See
|
|
8
|
+
[errors](errors.md) for `InputError` itself and for what a server has to send.
|
|
6
9
|
|
|
7
|
-
##
|
|
8
|
-
|
|
9
|
-
A library that hands you a sentence has already decided what your user reads, in
|
|
10
|
-
one language, in its own voice. So graph_weaver should hand you **a stable key
|
|
11
|
-
and the facts**, and let the app own the sentence:
|
|
12
|
-
|
|
13
|
-
- **stable** — a `kind` from a short closed vocabulary, and the key derived from
|
|
14
|
-
it, `graph_weaver.input.<kind>`. These are API: they change only with a MAJOR
|
|
15
|
-
bump, and a new one arrives only as an addition.
|
|
16
|
-
- **data** — the field, the value, the enum's members, the bounds. Never baked
|
|
17
|
-
into the message only, and never pre-formatted (`members` is an Array, not
|
|
18
|
-
"CAT or DOG" — joining it is a language decision).
|
|
19
|
-
- **not stable** — `#message`. It is the developer's line, in English, and it
|
|
20
|
-
will be reworded.
|
|
21
|
-
|
|
22
|
-
## The vocabulary
|
|
23
|
-
|
|
24
|
-
Eight kinds, each one sentence. A ninth arrives only when a real input failure
|
|
25
|
-
fits none of them — `:refused` is the honest home for everything else.
|
|
26
|
-
|
|
27
|
-
The last four columns are **who can say it**, which is the other half of the
|
|
28
|
-
story — a `kind` is worth only as much as the producer that can state it. The
|
|
29
|
-
last of them is any server at all, through the
|
|
30
|
-
[convention](#what-the-server-has-to-do).
|
|
31
|
-
|
|
32
|
-
| `kind` | means | `details` carries | client | graphql-ruby | Hasura | convention |
|
|
33
|
-
|---|---|---|---|---|---|---|
|
|
34
|
-
| `:type_mismatch` | the value is not the type the schema declares, and no conversion applies | `type` | yes | yes | **no** | yes |
|
|
35
|
-
| `:unparseable` | the right kind of thing, but the text doesn't parse as that scalar | `type` | yes | yes | **no** | yes |
|
|
36
|
-
| `:not_a_member` | not one of the values an enum (or an inclusion rule) allows | `members` | yes | yes | yes | yes |
|
|
37
|
-
| `:missing` | a required field or argument wasn't supplied, or was null | — | yes | yes | yes | yes |
|
|
38
|
-
| `:unknown` | a key the input type doesn't define — a typo | `suggestion` | yes | yes | yes | yes |
|
|
39
|
-
| `:out_of_range` | a number, length or date outside the bounds the schema states | `min`, `max` | **no** | **no** | **no** | yes |
|
|
40
|
-
| `:invalid_format` | parses, right type, but fails a stated semantic rule — a regex, an email | `pattern` | **no** | **no** | **no** | yes |
|
|
41
|
-
| `:refused` | rejected, with nothing more than a message — the fallback, never a guess | — | yes | yes | yes | yes |
|
|
42
|
-
|
|
43
|
-
Those are the servers graph_weaver reads by name. **Apollo** is `:refused` and
|
|
44
|
-
the server's own sentence, nothing finer — `BAD_USER_INPUT` says the input was
|
|
45
|
-
bad and not what about it. **Anything else** says nothing graph_weaver will
|
|
46
|
-
claim: `#input_errors` comes back `[]`, and the rejection stays an ordinary
|
|
47
|
-
error to render as the server wrote it. Hasura's two extra blanks are one
|
|
48
|
-
sentence doing two jobs: the same "expected a non-negative 32-bit integer" answers
|
|
49
|
-
`limit: -5` and `limit: "lots"`, so both arrive `:refused` on the right field
|
|
50
|
-
rather than one of them arriving confidently wrong. Which shape each server is
|
|
51
|
-
read off is in [errors](errors.md#when-the-server-rejects-the-input).
|
|
52
|
-
|
|
53
|
-
`field` and `value` ride on every kind. `value` is the offending value passed
|
|
54
|
-
through [`filter_parameters`](logging.md#filtered-variables), so it reads
|
|
55
|
-
`"[FILTERED]"` under a sensitive key and `nil` when the producer never said what
|
|
56
|
-
the value was.
|
|
10
|
+
## Translating it
|
|
57
11
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
[server convention](errors.md#what-your-server-can-send).** The client doesn't
|
|
62
|
-
know the schema's bounds, and a graphql-ruby `validates:` failure reaches the
|
|
63
|
-
wire as a bare sentence with no `extensions` at all.
|
|
64
|
-
|
|
65
|
-
`@oneOf` violations map to `:refused`, with one exception that isn't really
|
|
66
|
-
one: exactly one field, explicitly null, is `:missing` on that field — the
|
|
67
|
-
count is right and the value isn't, which is the same thing `:missing` means
|
|
68
|
-
everywhere else, and it gives a form the one slot to highlight. The wrong
|
|
69
|
-
*count* has no single field to name, so it stays a message ("supply exactly one
|
|
70
|
-
field, non-null, got a, b"); one rule in twenty schemas doesn't earn a ninth
|
|
71
|
-
kind.
|
|
72
|
-
|
|
73
|
-
## The shape
|
|
74
|
-
|
|
75
|
-
One class, carried by both halves. `GraphWeaver::InputError` is *raised* before
|
|
76
|
-
the request leaves — rescuing it is a control-flow decision (render a 422) — and
|
|
77
|
-
it is also the **value** a server's rejection becomes, which has precedent:
|
|
78
|
-
`Response#errors` already holds unraised `GraphQLError`s. One class, zero new
|
|
79
|
-
nouns, one renderer for both halves.
|
|
12
|
+
No locale file ships — the strings are app-voice, not library-voice, and owning
|
|
13
|
+
a user-visible English string would make every reword a breaking change. Paste
|
|
14
|
+
this into your own `config/locales/en.yml`:
|
|
80
15
|
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
16
|
+
```yaml
|
|
17
|
+
en:
|
|
18
|
+
graph_weaver:
|
|
19
|
+
input:
|
|
20
|
+
type_mismatch: "%{field} must be a %{type}."
|
|
21
|
+
unparseable: "%{field} isn't a valid %{type}."
|
|
22
|
+
not_a_member: "%{field} must be one of: %{members}."
|
|
23
|
+
missing: "%{field} is required."
|
|
24
|
+
unknown: "%{field} isn't a field we recognize."
|
|
25
|
+
out_of_range: "%{field} is out of range."
|
|
26
|
+
invalid_format: "%{field} isn't in the right format."
|
|
27
|
+
refused: "%{field} was rejected."
|
|
85
28
|
```
|
|
86
29
|
|
|
87
|
-
Plural on all three: one variable-coercion error routinely carries several
|
|
88
|
-
problems about different fields, and keeping only the first would lose the rest
|
|
89
|
-
silently.
|
|
90
|
-
|
|
91
|
-
- `path` — rooted at the **variable**: `["where", "_and", 0, "_not", "species"]`,
|
|
92
|
-
so `$where._and.0._not.species`. Every named segment is the **schema's**
|
|
93
|
-
spelling (`issuedOn`, not the `issued_on` you type in Ruby), whichever side
|
|
94
|
-
refused — [why](errors.md#which-spelling-a-path-is-in). `[]` when the
|
|
95
|
-
producer named no slot; `#field` is its last named segment.
|
|
96
|
-
- `coordinate` — the GraphQL [schema coordinate](https://github.com/graphql/graphql-spec/pull/794):
|
|
97
|
-
`"PetFilter.species"`. `nil` when unknown — a variable names no schema
|
|
98
|
-
element, and a graphql-ruby coercion error rarely names the nested input type.
|
|
99
|
-
|
|
100
|
-
## Translating it
|
|
101
|
-
|
|
102
30
|
```ruby
|
|
103
31
|
def render_input_error(error)
|
|
104
32
|
I18n.t(
|
|
@@ -111,6 +39,9 @@ def render_input_error(error)
|
|
|
111
39
|
end
|
|
112
40
|
```
|
|
113
41
|
|
|
42
|
+
One call serves both halves — a refusal raised before the request left, and one
|
|
43
|
+
the server sent back:
|
|
44
|
+
|
|
114
45
|
```ruby
|
|
115
46
|
# before the wire
|
|
116
47
|
rescue GraphWeaver::InputError => e
|
|
@@ -120,97 +51,108 @@ rescue GraphWeaver::InputError => e
|
|
|
120
51
|
response.input_errors.each { |e| form.errors.add(e.field&.underscore || :base, render_input_error(e)) }
|
|
121
52
|
```
|
|
122
53
|
|
|
123
|
-
`#field` is the schema's spelling on both sides, so one `underscore` is the
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
server that states no input path gives you, and that is the default until your
|
|
54
|
+
`#field` is the schema's spelling on both sides, so one `underscore` is the whole
|
|
55
|
+
mapping to a Rails form field. **Plan for `nil`** — nothing named a slot, which
|
|
56
|
+
is what a server that states no input path gives you, and the default until your
|
|
127
57
|
server adopts [the convention](errors.md#what-your-server-can-send).
|
|
128
58
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
59
|
+
`default:` is load-bearing: `:refused` is untranslatable by definition, so every
|
|
60
|
+
app needs the fallback — and having it means you can translate the four kinds you
|
|
61
|
+
care about and leave the rest.
|
|
132
62
|
|
|
133
|
-
|
|
134
|
-
# "input.lines.0.qty", where #field alone is "qty" for every line at once
|
|
135
|
-
I18n.t("graph_weaver.input.#{error.kind}", field: error.path.join("."), **error.details)
|
|
136
|
-
```
|
|
63
|
+
## The vocabulary
|
|
137
64
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
65
|
+
Eight kinds; a ninth arrives only when a real input failure fits none of them.
|
|
66
|
+
The last four columns are **who can say it**, which is the other half of the
|
|
67
|
+
story — a `kind` is worth only as much as the producer that can state it, and the
|
|
68
|
+
last of them is any server at all, through the
|
|
69
|
+
[convention](#what-the-server-has-to-do).
|
|
141
70
|
|
|
142
|
-
|
|
143
|
-
|
|
71
|
+
| `kind` | means | `details` carries | client | graphql-ruby | Hasura | convention |
|
|
72
|
+
|---|---|---|---|---|---|---|
|
|
73
|
+
| `:type_mismatch` | the value is not the type the schema declares, and no conversion applies | `type` | yes | yes | **no** | yes |
|
|
74
|
+
| `:unparseable` | the right kind of thing, but the text doesn't parse as that scalar | `type` | yes | yes | **no** | yes |
|
|
75
|
+
| `:not_a_member` | not one of the values an enum (or an inclusion rule) allows | `members` | yes | yes | yes | yes |
|
|
76
|
+
| `:missing` | a required field or argument wasn't supplied, or was null | — | yes | yes | yes | yes |
|
|
77
|
+
| `:unknown` | a key the input type doesn't define — a typo | `suggestion` | yes | yes | yes | yes |
|
|
78
|
+
| `:out_of_range` | a number, length or date outside the bounds the schema states | `min`, `max` | **no** | **no** | **no** | yes |
|
|
79
|
+
| `:invalid_format` | parses, right type, but fails a stated semantic rule — a regex, an email | `pattern` | **no** | **no** | **no** | yes |
|
|
80
|
+
| `:refused` | rejected, with nothing more than a message — the fallback, never a guess | — | yes | yes | yes | yes |
|
|
144
81
|
|
|
145
|
-
`
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
82
|
+
`field` and `value` ride on every kind. `value` is the offending value passed
|
|
83
|
+
through [`filter_parameters`](logging.md#filtered-variables), so it reads
|
|
84
|
+
`"[FILTERED]"` under a sensitive key and `nil` when the producer never said what
|
|
85
|
+
the value was.
|
|
149
86
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
87
|
+
Two rows carry the headline: **`:out_of_range` and `:invalid_format` — the
|
|
88
|
+
everyday "right type, wrong value" — cannot be produced from either side without
|
|
89
|
+
the [server convention](errors.md#what-your-server-can-send).** The client
|
|
90
|
+
doesn't know the schema's bounds, and a graphql-ruby `validates:` failure reaches
|
|
91
|
+
the wire as a bare sentence with no `extensions` at all.
|
|
92
|
+
|
|
93
|
+
Those three are the servers graph_weaver reads by name. **Apollo** is `:refused`
|
|
94
|
+
and the server's own sentence, nothing finer. **Anything else** says nothing
|
|
95
|
+
graph_weaver will claim: `#input_errors` comes back `[]`, and the rejection stays
|
|
96
|
+
an ordinary error to render as the server wrote it. Which shape each server is
|
|
97
|
+
read off, and why Hasura's blanks are blank, is in
|
|
98
|
+
[errors](errors.md#when-the-server-rejects-the-input).
|
|
99
|
+
|
|
100
|
+
## Details
|
|
101
|
+
|
|
102
|
+
One class carries both halves: `GraphWeaver::InputError` is *raised* before the
|
|
103
|
+
request leaves, and it is also the **value** a server's rejection becomes — read
|
|
104
|
+
back by `Response#input_errors`, `QueryError#input_errors` and
|
|
105
|
+
`GraphQLError#input_errors`, all plural, since one variable-coercion error
|
|
106
|
+
routinely carries problems about several fields. What it holds is the table in
|
|
107
|
+
[errors](errors.md#what-an-inputerror-says-without-reading-english); the one
|
|
108
|
+
thing a translator needs from it is that every named segment of `#path` — and so
|
|
109
|
+
`#field` — is the **schema's** spelling, whichever side refused
|
|
110
|
+
([why](errors.md#which-spelling-a-path-is-in)).
|
|
111
|
+
|
|
112
|
+
**`field:` can't say which row.** A list index lives in `#path` and nowhere else,
|
|
113
|
+
so every element of `lines: [LineInput!]!` translates to the same sentence. Where
|
|
114
|
+
a form lists rows, label it from the path:
|
|
156
115
|
|
|
157
116
|
```ruby
|
|
158
|
-
|
|
117
|
+
# "input.lines.0.qty", where #field alone is "qty" for every line at once
|
|
118
|
+
I18n.t("graph_weaver.input.#{error.kind}", field: error.path.join("."), **error.details)
|
|
159
119
|
```
|
|
160
120
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
graph_weaver ships the **keys and the data, and no `en.yml`**. Three
|
|
164
|
-
reasons, in order:
|
|
165
|
-
|
|
166
|
-
1. The strings are app-voice, not library-voice. "Species must be one of: cat,
|
|
167
|
-
dog" belongs to whoever owns the form.
|
|
168
|
-
2. A shipped default applies by merely adding the gem — behavior that follows
|
|
169
|
-
from the Gemfile rather than from the code in front of you, which is the one
|
|
170
|
-
thing this library refuses to do everywhere else.
|
|
171
|
-
3. Owning a user-visible English string means every reword is a breaking change
|
|
172
|
-
for apps that translated around it.
|
|
121
|
+
Arrays stay arrays: `details[:members]` is `["CAT", "DOG"]`, so join it where you
|
|
122
|
+
know the language — `members: e.details[:members].to_sentence`.
|
|
173
123
|
|
|
174
|
-
|
|
124
|
+
`details[:type]` is the **GraphQL** name for the type — `Int`, `Money` — so a
|
|
125
|
+
sentence built from it still says "Int" in the middle of the French. Translate
|
|
126
|
+
the name yourself under a key of your own and pass it *after* the splat, which
|
|
127
|
+
wins: `I18n.t(key, **e.details, type: I18n.t("types.#{e.details[:type]}"))`.
|
|
175
128
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
input:
|
|
180
|
-
type_mismatch: "%{field} must be a %{type}."
|
|
181
|
-
unparseable: "%{field} isn't a valid %{type}."
|
|
182
|
-
not_a_member: "%{field} must be one of: %{members}."
|
|
183
|
-
missing: "%{field} is required."
|
|
184
|
-
unknown: "%{field} isn't a field we recognize."
|
|
185
|
-
out_of_range: "%{field} is out of range."
|
|
186
|
-
invalid_format: "%{field} isn't in the right format."
|
|
187
|
-
refused: "%{field} was rejected."
|
|
188
|
-
```
|
|
129
|
+
`@oneOf` violations are `:refused`, except exactly one field explicitly null,
|
|
130
|
+
which is `:missing` on that field — a form gets the one slot to highlight. The
|
|
131
|
+
wrong *count* names no single field, so it stays a message.
|
|
189
132
|
|
|
190
|
-
|
|
133
|
+
### What the server has to do
|
|
191
134
|
|
|
192
|
-
For the two kinds nothing can produce on its own, and for every column the
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
135
|
+
For the two kinds nothing can produce on its own, and for every column the table
|
|
136
|
+
leaves blank, the server states it — under one key, `extensions.input`, with the
|
|
137
|
+
`kind` taken from the table above. The shape, the graphql-ruby recipes for it,
|
|
138
|
+
and what a server that sends none of it gets instead are in
|
|
196
139
|
[errors](errors.md#what-your-server-can-send).
|
|
197
140
|
|
|
198
141
|
Two rules matter to a translator. **A `kind` outside the table becomes
|
|
199
|
-
`:refused`** rather than being passed through, because a wrong key is worse
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
interpolation your locale file has no slot for
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
**exception class**:
|
|
142
|
+
`:refused`** rather than being passed through, because a wrong key is worse than
|
|
143
|
+
no key: the app will have translated it into a confident sentence. And **a key
|
|
144
|
+
outside `type`/`members`/`min`/`max`/`pattern`/`suggestion` is dropped** before it
|
|
145
|
+
reaches `details`, so `I18n.t(..., **details)` can never be handed an
|
|
146
|
+
interpolation your locale file has no slot for — nor one I18n reserves, which is
|
|
147
|
+
why it is `:pattern` and not `:format`.
|
|
148
|
+
|
|
149
|
+
### Transport and server failures
|
|
150
|
+
|
|
151
|
+
`kind` exists for input errors and nothing else. The failures beside them name no
|
|
152
|
+
field and carry nothing to interpolate, so key those on the **exception class**,
|
|
153
|
+
which is the only closed vocabulary on that side — a `code` is a per-server
|
|
154
|
+
invention, so a translation keyed on one degrades to English against the next
|
|
155
|
+
server:
|
|
214
156
|
|
|
215
157
|
| what you rescued | what the user is told |
|
|
216
158
|
|---|---|
|
|
@@ -219,16 +161,11 @@ complained — name no field and carry nothing to interpolate. Key those on the
|
|
|
219
161
|
| `GraphWeaver::QueryError` whose `#input_errors` is empty | something went wrong with that request |
|
|
220
162
|
|
|
221
163
|
One static translated sentence each, and log what the server said rather than
|
|
222
|
-
showing it.
|
|
223
|
-
this side: a `code` is a per-server invention — Apollo sends `BAD_USER_INPUT`,
|
|
224
|
-
graphql-js sends none — so a translation keyed on one degrades to English
|
|
225
|
-
against the next server, and `#message` is the developer's line, which moves.
|
|
164
|
+
showing it.
|
|
226
165
|
|
|
227
|
-
|
|
166
|
+
### Where translation stops
|
|
228
167
|
|
|
229
168
|
graph_weaver's own refusals are for developers and stay English, with no keys:
|
|
230
|
-
`QueryValidationError
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
builds from `kind` and `details`. And a message the *server* wrote arrives in
|
|
234
|
-
whatever language the server chose, passed through untouched.
|
|
169
|
+
`QueryValidationError`, the codegen errors, the schema-drift hint,
|
|
170
|
+
`ConfigurationError`, `Testing::*`, and `InputError#message`. A message the
|
|
171
|
+
*server* wrote arrives in whatever language the server chose, untouched.
|