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/i18n.md CHANGED
@@ -1,104 +1,32 @@
1
1
  # Translating input errors
2
2
 
3
- The keys and the data an app translates "that input was wrong" from, without
4
- reading English sentences. See [errors](errors.md) for `InputError` itself and
5
- for what a server has to send.
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
- ## The one idea
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
- Two rows carry the headline: **`:out_of_range` and `:invalid_format` the
59
- customer's "right type but out of range" and "bad semantic format" — cannot be
60
- produced from either side without the
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
- ```ruby
82
- GraphWeaver::InputError # kind, path, coordinate, value, details, field, struct
83
- GraphWeaver::GraphQLError#input_errors # [] when the error isn't about input
84
- GraphWeaver::Response#input_errors # and QueryError#input_errors
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
- whole mapping to a Rails form field and the two calls above really are the
125
- same call. **Plan for `nil`**: nothing named a slot, which is exactly what a
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
- **`field:` can't say which row.** A list index lives in `#path` and nowhere
130
- else, so every element of `lines: [LineInput!]!` translates to the same
131
- sentence. Where a form lists rows, label it from the path:
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
- ```ruby
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
- `default:` is the load-bearing half: `:refused` is untranslatable by definition
139
- it *is* whatever the message said so every app needs the fallback, and having
140
- it means an app can translate the four kinds it cares about and leave the rest.
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
- Arrays stay arrays: `details[:members]` is `["CAT", "DOG"]`, so join it where you
143
- know the language — `members: e.details[:members].to_sentence`.
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
- `details[:type]` is the **GraphQL** name for the type `Int`, `Money` so a
146
- sentence translated from it still says "Int" in the middle of the French. Name
147
- the scalars your forms use, and translate the name before it is interpolated:
148
- `render_input_error` splats `details`, so a `type:` passed after the splat wins.
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
- ```yaml
151
- fr:
152
- types:
153
- Int: "numérique"
154
- String: "textuelle"
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
- type = I18n.t("types.#{error.details[:type]}", default: error.details[:type])
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
- ## No locale file ships
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
- Paste this into your own `config/locales/en.yml` instead:
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
- ```yaml
177
- en:
178
- graph_weaver:
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
- ## What the server has to do
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
- table leaves blank, the server states it — under one key, `extensions.input`, with
194
- the `kind` taken from the table above. The shape, the graphql-ruby recipes for
195
- it, and what a server that sends none of it gets instead are in
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
- than no key: the app will have translated it into a confident sentence. And
201
- **a key outside `type`/`members`/`min`/`max`/`pattern`/`suggestion` is dropped**
202
- before it reaches `details`, so `I18n.t(..., **details)` can never be handed an
203
- interpolation your locale file has no slot for. None of the six is a name in
204
- `I18n::RESERVED_KEYS` either `:pattern` rather than `:format` for that reason,
205
- since splatting a `:format` key into `I18n.t` raises
206
- `I18n::ReservedInterpolationKey` instead of translating.
207
-
208
- ## Transport and server failures
209
-
210
- `kind` exists for input errors and nothing else. The failures beside them the
211
- endpoint was unreachable, the server answered 502, the server answered 200 and
212
- complained — name no field and carry nothing to interpolate. Key those on the
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. The [class hierarchy](errors.md) is the only closed vocabulary on
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
- ## Where translation stops
166
+ ### Where translation stops
228
167
 
229
168
  graph_weaver's own refusals are for developers and stay English, with no keys:
230
- `QueryValidationError` and the codegen errors (a rake task's output), the schema-drift
231
- hint, `ConfigurationError`, `Testing::*`. Same for `InputError#message` it is
232
- the line in the log and the stack trace; the translated line is the one your app
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.