graph_weaver 0.5.0 → 0.6.0
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/CHANGELOG.md +537 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +29 -4
- data/docs/editors.md +3 -1
- data/docs/errors.md +75 -16
- data/docs/federation.md +206 -155
- data/docs/generated_modules.md +223 -166
- data/docs/getting_started.md +106 -82
- data/docs/logging.md +35 -5
- data/docs/scalars.md +119 -24
- data/docs/testing.md +196 -155
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +243 -22
- data/graph_weaver.gemspec +16 -2
- data/lib/generators/graph_weaver/install_generator.rb +31 -16
- data/lib/graph_weaver/client.rb +52 -15
- data/lib/graph_weaver/codegen/aliases.rb +15 -8
- data/lib/graph_weaver/codegen/emit.rb +107 -42
- data/lib/graph_weaver/codegen/enum_type.rb +4 -3
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
- data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
- data/lib/graph_weaver/codegen.rb +382 -105
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +57 -13
- data/lib/graph_weaver/federation.rb +10 -22
- data/lib/graph_weaver/hints.rb +76 -2
- data/lib/graph_weaver/in_process.rb +11 -8
- data/lib/graph_weaver/inflect.rb +2 -0
- data/lib/graph_weaver/input_struct.rb +115 -12
- data/lib/graph_weaver/internal/overrides.rb +101 -0
- data/lib/graph_weaver/internal/planner.rb +868 -0
- data/lib/graph_weaver/internal/schemas.rb +50 -0
- data/lib/graph_weaver/internal/selection.rb +127 -0
- data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
- data/lib/graph_weaver/internal/values.rb +181 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +6 -13
- data/lib/graph_weaver/query_module.rb +2 -0
- data/lib/graph_weaver/railtie.rb +113 -14
- data/lib/graph_weaver/representation.rb +30 -2
- data/lib/graph_weaver/response.rb +15 -0
- data/lib/graph_weaver/retry.rb +54 -22
- data/lib/graph_weaver/rspec.rb +63 -18
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +126 -35
- data/lib/graph_weaver/tasks.rb +88 -36
- data/lib/graph_weaver/testing/cassette.rb +131 -78
- data/lib/graph_weaver/testing/coverage.rb +11 -15
- data/lib/graph_weaver/testing/failure.rb +14 -8
- data/lib/graph_weaver/testing/fake_client.rb +253 -60
- data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
- data/lib/graph_weaver/testing/router.rb +147 -840
- data/lib/graph_weaver/testing.rb +40 -83
- data/lib/graph_weaver/transport/faraday.rb +1 -1
- data/lib/graph_weaver/transport/http.rb +29 -12
- data/lib/graph_weaver/transport.rb +11 -34
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +221 -118
- metadata +17 -13
- data/CLAUDE.md +0 -161
- data/DECISIONS.md +0 -309
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -115
- data/REVIEW.md +0 -946
- data/lib/graph_weaver/schemas.rb +0 -46
- data/lib/graph_weaver/selection.rb +0 -120
- data/lib/graph_weaver/testing/values.rb +0 -98
data/README.md
CHANGED
|
@@ -42,14 +42,47 @@ result.person&.nmae
|
|
|
42
42
|
isn't defensive, it's the schema talking. A field you misspelled, or never
|
|
43
43
|
selected, is a typecheck error rather than a `NoMethodError` in production.
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
## Start here
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# Gemfile
|
|
49
|
+
gem "graph_weaver"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
In Rails, setup is one command:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
rails g graph_weaver:install https://api.example.com/graphql
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
which writes the initializer, the `app/graphql` layout, the editor config and the
|
|
59
|
+
schema dump. **[Getting started](docs/getting_started.md)** walks the production
|
|
60
|
+
setup end to end.
|
|
61
|
+
|
|
62
|
+
You then rarely type `rake graph_weaver:generate` again: while the dev server is
|
|
63
|
+
up, a `.graphql` edit regenerates and reloads before the next request, the way a
|
|
64
|
+
route change does. Run it when you're ready to commit the Ruby.
|
|
65
|
+
|
|
66
|
+
Or skip the build step and poke at an API from a console —
|
|
67
|
+
anything holding a schema parses, and the module runs on what parsed it:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
api = GraphWeaver.new("https://countries.trevorblades.com/")
|
|
71
|
+
CountryQuery = api.parse("queries/country.graphql") # a path or a raw string
|
|
72
|
+
CountryQuery.execute!(code: "JP").country&.capital # => "Tokyo"
|
|
73
|
+
|
|
74
|
+
api.run!("query { continents { name } }").continents # or no module at all
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The **[examples](examples/)** run that path for real, smallest first: a public API
|
|
78
|
+
in 30 lines, a paginated search, the production path against GitHub, and the
|
|
79
|
+
federated graph below.
|
|
47
80
|
|
|
48
81
|
## Precise types are expensive to fake, so it fakes them for you
|
|
49
82
|
|
|
50
83
|
Generation makes result types exact, which makes them tedious to build by hand —
|
|
51
84
|
and most generators stop there and leave you the fixtures. GraphWeaver ships the
|
|
52
|
-
|
|
85
|
+
fakes. One line in the spec helper:
|
|
53
86
|
|
|
54
87
|
```ruby
|
|
55
88
|
require "graph_weaver/rspec"
|
|
@@ -68,77 +101,64 @@ end
|
|
|
68
101
|
```
|
|
69
102
|
|
|
70
103
|
No fixture, no stub, no HTTP — and the values are seeded from rspec's own seed, so
|
|
71
|
-
`--seed 4242` hands back that same person and a failure reproduces.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
104
|
+
`--seed 4242` hands back that same person and a failure reproduces.
|
|
105
|
+
|
|
106
|
+
Random data answers "does this render". When the example is *about* the data, pin
|
|
107
|
+
the fields it's about and let the rest stay fabricated:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
graphql_fake("Person.name" => "Ada", "Person.pets" => [{ "name" => "Shelby" }, {}])
|
|
111
|
+
|
|
112
|
+
person.name # => "Ada"
|
|
113
|
+
person.pets.first.name # => "Shelby" — the second pet is still fabricated
|
|
114
|
+
person.pets.size # => 2 — a pinned list is as long as you write it
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Keys are schema names — a field, or a whole type: `"Person" => build(:person)`
|
|
118
|
+
reads the selected fields off your factory's object and fabricates the rest. They
|
|
119
|
+
are checked and spellchecked, so a typo raises instead of leaving the example
|
|
120
|
+
green against random data. The tag also picks a *real* client
|
|
121
|
+
when you want one: `:in_process` runs your resolvers, `:router` runs them across a
|
|
122
|
+
federated graph. Field-level failure simulation and record/replay cassettes with
|
|
123
|
+
anonymization are in [testing](docs/testing.md).
|
|
75
124
|
|
|
76
125
|
## Federation without a gateway
|
|
77
126
|
|
|
78
127
|
When your app is both a GraphQL client and a subgraph, the local router plans a
|
|
79
128
|
query across the composed supergraph and runs your **real resolvers** over the
|
|
80
129
|
boundary — no gateway process, no node, no sockets. That's
|
|
81
|
-
[`examples/federation.rb`](examples/federation.rb), the example that needs no
|
|
130
|
+
[`examples/federation.rb`](examples/federation.rb), the example that needs no
|
|
131
|
+
network. Part of what it prints:
|
|
82
132
|
|
|
83
133
|
```
|
|
84
|
-
$ bundle exec examples/federation.rb
|
|
85
|
-
#<GraphWeaver::Testing::Router subgraphs=["accounts", "products", "reviews"]>
|
|
86
|
-
|
|
87
|
-
dpep reviewed 2 products:
|
|
88
|
-
Table ($899) — Love it
|
|
89
|
-
Couch ($1299) — Too expensive
|
|
90
|
-
|
|
91
134
|
fetches:
|
|
92
135
|
→ accounts root fields
|
|
93
136
|
→ reviews _entities × 1 User
|
|
94
137
|
→ products _entities × 2 Product
|
|
95
138
|
```
|
|
96
139
|
|
|
97
|
-
The trace is the query plan: every node at a level in one `_entities` call,
|
|
98
|
-
products cost one fetch. Anything it can't answer *faithfully* it refuses
|
|
99
|
-
time rather than guessing —
|
|
100
|
-
the
|
|
140
|
+
The trace is the query plan: every node at a level goes in one `_entities` call,
|
|
141
|
+
so two products cost one fetch. Anything it can't answer *faithfully* it refuses
|
|
142
|
+
at plan time rather than guessing — the example prints one of those too, naming
|
|
143
|
+
the coordinate that stopped it and what to rename. And it's diffed against a real
|
|
144
|
+
`@apollo/gateway` over the same supergraph:
|
|
145
|
+
currently 73 queries identical, 2 refused, 0 wrong
|
|
101
146
|
([`spec/integration/router_parity_spec.rb`](spec/integration/router_parity_spec.rb)).
|
|
102
147
|
See [federation](docs/federation.md).
|
|
103
148
|
|
|
104
149
|
## The schema keeps itself honest
|
|
105
150
|
|
|
106
151
|
The lifecycle is rake tasks, not a CI pipeline you assemble yourself:
|
|
107
|
-
`schema:refresh` re-introspects the committed dump, `schema:diff`
|
|
108
|
-
server has drifted, `queries:check` names the queries that
|
|
109
|
-
and `verify` fails when the checked-in Ruby is stale.
|
|
110
|
-
— same schema and queries, byte-identical files — so
|
|
111
|
-
diff you didn't earn. See
|
|
112
|
-
|
|
113
|
-
## Start here
|
|
114
|
-
|
|
115
|
-
```ruby
|
|
116
|
-
# Gemfile
|
|
117
|
-
gem "graph_weaver"
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
In Rails, setup is then one command:
|
|
121
|
-
|
|
122
|
-
```sh
|
|
123
|
-
rails g graph_weaver:install https://api.example.com/graphql
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
which writes the initializer, the `app/graphql` layout, the editor config and the
|
|
127
|
-
schema dump. **[Getting started](docs/getting_started.md)** walks the production
|
|
128
|
-
setup end to end. Or skip the build step entirely and poke at an API from a
|
|
129
|
-
console — anything holding a schema parses, and the module runs on what parsed it:
|
|
152
|
+
`schema:refresh` re-introspects the committed dump, `schema:diff` names what
|
|
153
|
+
changed when the server has drifted, `queries:check` names the queries that
|
|
154
|
+
drift broke and where, and `verify` fails when the checked-in Ruby is stale.
|
|
155
|
+
Generation is deterministic — same schema and queries, byte-identical files — so
|
|
156
|
+
regenerating never shows a diff you didn't earn. See
|
|
157
|
+
[getting started](docs/getting_started.md#5-verify-in-ci).
|
|
130
158
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
CountryQuery.execute!(code: "JP").country&.capital # => "Tokyo"
|
|
135
|
-
|
|
136
|
-
api.run!("query { continents { name } }").continents # or no module at all
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
The **[examples](examples/)** run that path for real, smallest first: a public API
|
|
140
|
-
in 30 lines, a paginated search, the production path against GitHub, and the
|
|
141
|
-
federated graph above.
|
|
159
|
+
**Any release can change what codegen emits**, patch releases included — fixing a
|
|
160
|
+
generated type is a byte change. So `rake graph_weaver:generate` is part of every
|
|
161
|
+
upgrade, and `verify` is what tells you when you've skipped it.
|
|
142
162
|
|
|
143
163
|
#### Also in the box
|
|
144
164
|
|
|
@@ -160,6 +180,7 @@ federated graph above.
|
|
|
160
180
|
- **[Editor support](docs/editors.md)** — five lines of YAML for schema autocomplete in `.graphql` files, no JS project
|
|
161
181
|
- **[Against a real API](docs/real_world.md)** — introspecting a live endpoint, GitHub end to end
|
|
162
182
|
- **[Logging](docs/logging.md)** — point `GraphWeaver.logger` at any Logger
|
|
183
|
+
- **[Upgrading](docs/upgrading.md)** — regenerate on every bump, and what 0.5.0 moved
|
|
163
184
|
|
|
164
185
|
----
|
|
165
186
|
## Development
|
data/docs/cassettes.md
CHANGED
|
@@ -82,9 +82,29 @@ preserving everything that makes the recording faithful:
|
|
|
82
82
|
| enums, booleans, `__typename` | numbers, dates |
|
|
83
83
|
| id *relationships* (same original id → same fake id) | the id values themselves |
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
values are enums, dates, ids.
|
|
87
|
-
|
|
85
|
+
`data` is walked against the schema — which is why it needs one, to know which
|
|
86
|
+
values are enums, dates, ids. `errors` and `extensions` have none behind them,
|
|
87
|
+
so they're walked by shape instead: keys, nesting and structure survive, every
|
|
88
|
+
string and number is replaced. `path`, `locations` and an error's
|
|
89
|
+
`extensions.code` are kept, because they describe the request rather than the
|
|
90
|
+
data — and call sites branch on `code` the way they branch on an enum.
|
|
91
|
+
|
|
92
|
+
**The query and its variables are not anonymized.** They're the key replay
|
|
93
|
+
matches on, so scrubbing them would make the recording unfindable. A mutation's
|
|
94
|
+
input is often the sensitive part, so record with placeholder variables, or
|
|
95
|
+
don't record that request.
|
|
96
|
+
|
|
97
|
+
Recording says so when the bytes it wrote look like a credential:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
graph_weaver: spec/cassettes/github.yml contains a JWT, a GitHub token — a
|
|
101
|
+
cassette is committed as written, so review this one first. …
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
It recognizes tokens by shape — a JWT, `AKIA…`, `ghp_…`, `xox…`, `sk_live_…`, a
|
|
105
|
+
PEM block, a `Bearer` header — which is every credential that is unmistakable
|
|
106
|
+
and nothing else. A password like `hunter2` has no shape, so a quiet run is not
|
|
107
|
+
a clean bill of health: **read a cassette before committing it.**
|
|
88
108
|
|
|
89
109
|
For cassettes recorded before the flag was on:
|
|
90
110
|
|
|
@@ -93,7 +113,12 @@ rake graph_weaver:cassettes:anonymize # every cassette in cassette_dir, in pla
|
|
|
93
113
|
```
|
|
94
114
|
|
|
95
115
|
Anonymization preserves shape, so an anonymized cassette still passes
|
|
96
|
-
`cassettes:check
|
|
116
|
+
`cassettes:check` — including a custom scalar, whose replacement is the same
|
|
117
|
+
one [`FakeClient`](testing.md#fabricated-data--graphql-fake) would fabricate.
|
|
118
|
+
A scalar registered as *your own* class needs a pin for the type in
|
|
119
|
+
`Testing.config.overrides` (`{ "Money" => "12.00" }` — [pins](testing.md#pins)),
|
|
120
|
+
which the anonymizer reads too; without one, anonymizing refuses rather than
|
|
121
|
+
writing a value the codec can't read back.
|
|
97
122
|
|
|
98
123
|
## Cassette or FakeClient?
|
|
99
124
|
|
data/docs/editors.md
CHANGED
|
@@ -22,7 +22,9 @@ That's the whole setup. The paths are graph_weaver's conventions
|
|
|
22
22
|
(`GraphWeaver.schema_path`, `queries_paths`, `fragments_paths`) — if you moved
|
|
23
23
|
them, move these to match. Include the fragments directory: an editor
|
|
24
24
|
validating a query that spreads a shared fragment reports `Unknown fragment`
|
|
25
|
-
unless the fragment files are in `documents` too.
|
|
25
|
+
unless the fragment files are in `documents` too. The generator writes that
|
|
26
|
+
line whether or not you have fragments yet — the directory it names doesn't
|
|
27
|
+
exist until you add one, and a glob matching nothing is fine.
|
|
26
28
|
|
|
27
29
|
An SDL dump works just as well if you took one (`cache: :graphql`):
|
|
28
30
|
|
data/docs/errors.md
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
# Errors
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
What comes back when something goes wrong, and what to rescue. Read it when you
|
|
4
|
+
write the first `rescue` around a query, or when you need to tell "the network
|
|
5
|
+
broke" apart from "the server said no" apart from "the response didn't fit".
|
|
6
|
+
|
|
7
|
+
Two names travel together here, and they're close enough to trip on:
|
|
8
|
+
|
|
9
|
+
- **`Result`** — the struct holding *this* query's data, e.g. `PersonQuery::Result`.
|
|
10
|
+
- **`Response`** — the **envelope** around it, `GraphWeaver::Response[Result]`,
|
|
11
|
+
carrying `data`, `errors` and `extensions`.
|
|
12
|
+
|
|
13
|
+
`execute` returns the envelope rather than raising on GraphQL errors, so partial
|
|
14
|
+
data and top-level `extensions` (cost, throttle) survive. `execute!` is the
|
|
15
|
+
shortcut to the result:
|
|
6
16
|
|
|
7
17
|
```ruby
|
|
8
18
|
PersonQuery.execute!(id: "1") # => Result, or raises QueryError (== execute(...).data!)
|
|
@@ -30,14 +40,16 @@ subclass says where it failed:
|
|
|
30
40
|
|
|
31
41
|
| Class | When |
|
|
32
42
|
|-------|------|
|
|
33
|
-
| `TransportError` |
|
|
43
|
+
| `TransportError` | no response came back — DNS, connection refused, TLS, timeout, a socket that died mid-body |
|
|
34
44
|
| `ServerError` | reached it, non-2xx HTTP — `#status`, `#body`, `#headers`, `#retry_after`, `#throttled?` |
|
|
35
45
|
| `QueryError` | 200 body with top-level GraphQL errors — `#errors`, `#data`, `#extensions`, `#codes`, `#throttled?` |
|
|
36
46
|
| `TypeError` | the response wouldn't cast into the generated structs — `#struct`, `#cause` |
|
|
37
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` |
|
|
38
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 |
|
|
39
50
|
| `ConfigurationError` | setup judged against your schema — which Ruby schema serves which subgraph (`Testing::Router`, `federation:diff`) |
|
|
40
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 |
|
|
41
53
|
|
|
42
54
|
An argument that is wrong *on its face* raises a plain `ArgumentError` instead
|
|
43
55
|
(`pool_size: must be >= 1`, `cast: must be a Symbol, Proc, :itself, or nil`),
|
|
@@ -68,17 +80,34 @@ Or skip the hand-rolling: [`Retry`](transports.md#retries) wraps any client and
|
|
|
68
80
|
already defaults to exactly the policy above — transport failures always,
|
|
69
81
|
`ServerError` on 5xx plus 408/429, and GraphQL error codes you name.
|
|
70
82
|
|
|
71
|
-
**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
83
|
+
**A status with an obvious next step says it.** A 3xx appends "redirects are
|
|
84
|
+
not followed" and the `Location` to repoint the client at — replaying a POST,
|
|
85
|
+
with its `Authorization` header, at a host the server named isn't the
|
|
86
|
+
library's call. A 401 or 403 appends "check `auth:` — the token, and its
|
|
87
|
+
scopes".
|
|
88
|
+
|
|
89
|
+
**Everything you pass to `execute` is caller input**, so a value that won't
|
|
90
|
+
convert raises `GraphWeaver::InputError` — top-level scalar variables included.
|
|
91
|
+
They name the variable and the operation, since the value alone locates nothing
|
|
92
|
+
in an app that runs a hundred queries:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
$count of Compute: expected an Int, got "lots"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The value is usually the whole diagnosis, so it is quoted — unless the key it
|
|
99
|
+
arrived under is one your `filter_parameters` covers, in which case the message
|
|
100
|
+
reads `$password of Login: [FILTERED]`. Error messages reach the log at `warn`,
|
|
101
|
+
above the level that gates the variables line, so they are scrubbed by the same
|
|
102
|
+
list ([logging](logging.md#filtered-variables)).
|
|
103
|
+
|
|
104
|
+
A *missing* required kwarg is still a plain `ArgumentError` ("missing keyword:
|
|
105
|
+
:id") — that's Ruby's, and it is a programming bug rather than bad input.
|
|
76
106
|
|
|
77
|
-
**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
422:
|
|
107
|
+
**What's inside an input object** reports the same way. Pass one as a hash (or
|
|
108
|
+
struct) and it's built through the generated `coerce`, and anything wrong in
|
|
109
|
+
there raises `GraphWeaver::InputError` too — so one rescue point turns invalid
|
|
110
|
+
input into a 422:
|
|
82
111
|
|
|
83
112
|
```ruby
|
|
84
113
|
rescue GraphWeaver::InputError => e
|
|
@@ -90,7 +119,16 @@ end
|
|
|
90
119
|
```
|
|
91
120
|
|
|
92
121
|
A nested filter reports the innermost input type, so the error points at the
|
|
93
|
-
input that actually held the bad field.
|
|
122
|
+
input that actually held the bad field. Passing something that is neither — a
|
|
123
|
+
bare `String` where the input goes — reports the same way. A call site that
|
|
124
|
+
*spells* the wrong type is caught earlier and better, by `srb tc`: the sig is
|
|
125
|
+
as narrow as the schema, and only untyped values reach the runtime check
|
|
126
|
+
([why](generated_modules.md#variables-become-typed-kwargs)).
|
|
127
|
+
|
|
128
|
+
`#struct` is the generated input struct *class* where generation produced one,
|
|
129
|
+
and the GraphQL type *name* where it didn't — a federation representation
|
|
130
|
+
builds a plain Hash, so an entity has only its name to give. `to_h`'s
|
|
131
|
+
`"struct"` is the name either way, so branch on that.
|
|
94
132
|
|
|
95
133
|
Business/validation failures returned *as data* (Shopify-style `userErrors { field
|
|
96
134
|
message code }`) aren't errors here — they're just fields you selected, so they
|
|
@@ -137,6 +175,14 @@ response.report
|
|
|
137
175
|
`GraphQLError#field` strips list indices (`people.3.email` → `people.email`) —
|
|
138
176
|
the stable grouping key; the raw `#path` keeps indices for exact location.
|
|
139
177
|
|
|
178
|
+
`Response#to_h` decomposes the envelope the same way: `{"data" =>, "errors" =>,
|
|
179
|
+
"extensions" =>}`, with each error as its JSON-ready hash. `data` stays the
|
|
180
|
+
typed struct — it is deliberately not re-serialized, because `T::Struct#serialize`
|
|
181
|
+
would give snake_case keys where the wire is camelCase, drop null fields, and
|
|
182
|
+
leave a registered scalar as the Ruby object its codec built. That output would
|
|
183
|
+
look like the server's response without being one, so serialize the typed data
|
|
184
|
+
yourself when you need to re-emit it.
|
|
185
|
+
|
|
140
186
|
## Stale schemas
|
|
141
187
|
|
|
142
188
|
GraphQL has no schema-version signal, so a schema change surfaces as the
|
|
@@ -151,6 +197,19 @@ refresh the schema cache.
|
|
|
151
197
|
When wire data disagrees with the types the schema promised at generation time
|
|
152
198
|
(a nil where non-null was declared, a malformed scalar, an unknown enum value),
|
|
153
199
|
casting raises `GraphWeaver::TypeError` naming the failing generated struct,
|
|
154
|
-
with the original exception as `#cause`.
|
|
200
|
+
with the original exception as `#cause`.
|
|
201
|
+
|
|
202
|
+
A cast's own complaint is about the value and nothing else — "invalid date"
|
|
203
|
+
locates nothing on a struct holding four of them — so a casting leaf also
|
|
204
|
+
carries **its response key**, and three failures that keep happening say whose
|
|
205
|
+
bug it is rather than leaving you sorbet's words:
|
|
206
|
+
|
|
207
|
+
| what came back | what the message adds |
|
|
208
|
+
|---|---|
|
|
209
|
+
| an `ID` the server sent unquoted | GraphQL serializes `ID` as a JSON string, so this is the server out of spec — plus how to take it anyway (`register_scalar("ID", "T.untyped")`) |
|
|
210
|
+
| an enum value the generated enum doesn't hold | the values it does hold, and that drift is the likely cause: regenerate, or `register_enum(fallback:)` to absorb them |
|
|
211
|
+
| a field the server nulled **with a reason** | the server's own explanation, rather than only sorbet's nil complaint |
|
|
212
|
+
|
|
213
|
+
Simulate one in tests with
|
|
155
214
|
`GraphWeaver::Testing::FakeClient.new(schema:, corrupt: "Person.birthday")` — see
|
|
156
215
|
[testing](testing.md).
|