graph_weaver 0.6.0 → 0.7.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 +1470 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +21 -7
- data/docs/alternatives.md +201 -0
- data/docs/cassettes.md +17 -1
- data/docs/errors.md +382 -17
- data/docs/federation.md +469 -63
- data/docs/generated_modules.md +231 -15
- data/docs/getting_started.md +498 -105
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +32 -4
- data/docs/scalars.md +286 -57
- data/docs/testing.md +458 -59
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +330 -5
- data/graph_weaver.gemspec +7 -0
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +47 -10
- 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 +218 -59
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +408 -206
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/errors.rb +264 -34
- data/lib/graph_weaver/federation.rb +119 -26
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +17 -11
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +78 -0
- data/lib/graph_weaver/internal/headers.rb +51 -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 +43 -4
- data/lib/graph_weaver/internal.rb +183 -1
- data/lib/graph_weaver/log_subscriber.rb +66 -0
- data/lib/graph_weaver/logging.rb +136 -12
- data/lib/graph_weaver/query_module.rb +36 -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 +33 -5
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +221 -49
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +6 -5
- data/lib/graph_weaver/testing/endpoint.rb +106 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +133 -44
- data/lib/graph_weaver/testing/router.rb +58 -11
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +46 -4
- data/lib/graph_weaver/transport.rb +109 -26
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +490 -116
- metadata +56 -1
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,1472 @@
|
|
|
1
|
+
### v0.7.0 (2026-09-13)
|
|
2
|
+
|
|
3
|
+
**What you must do.** Every change here is 0.6.1 → 0.7.0, and a typical app
|
|
4
|
+
ticks two or three. [upgrading](docs/upgrading.md#upgrading-from-061) has the
|
|
5
|
+
same list with the greps that find each one.
|
|
6
|
+
|
|
7
|
+
- **Regenerate** — `rake graph_weaver:generate`. Every generated file moved:
|
|
8
|
+
reserved props take a trailing underscore, `as_json` is emitted beside
|
|
9
|
+
`from_h`, a `cast:` of your own gets the library's own guard, each module
|
|
10
|
+
carries the graph it came from, a `@key` that selects through a list types
|
|
11
|
+
its kwarg as a list, a block-built type helper is named for its graph and
|
|
12
|
+
its type, and `execute` makes its request through the
|
|
13
|
+
gem. 0.6.1's modules keep running, but `rake graph_weaver:verify` is red
|
|
14
|
+
until you regenerate, and one of them raises `GraphWeaver::Error` naming this
|
|
15
|
+
the moment anything asks it for `as_json`.
|
|
16
|
+
- **Rename two error classes.** `GraphWeaver::TypeError` → **`CastError`**,
|
|
17
|
+
`GraphWeaver::ValidationError` → **`QueryValidationError`**, at every rescue
|
|
18
|
+
site and in any `to_h["error"]` string you match on. No aliases.
|
|
19
|
+
- **Rename the rspec tag `graphql: false` to `graphql: :live`**, and
|
|
20
|
+
`config.default_mode = nil` to `:live`, which is now the default.
|
|
21
|
+
- **Rename the notification if you hardcoded it**: `"graph_weaver.execute"` →
|
|
22
|
+
`"execute.graph_weaver"`. Subscribing through `GraphWeaver::EXECUTE_EVENT`
|
|
23
|
+
needs nothing.
|
|
24
|
+
- **Read `payload[:http_status]`** wherever a subscriber branched on
|
|
25
|
+
`payload[:status] == 200` or on a 4xx/5xx — `:status` is a Symbol now.
|
|
26
|
+
- **Read `InputError#path.first`** wherever you read `#field` for the
|
|
27
|
+
*variable* name; `#field` is the input field that held the value now.
|
|
28
|
+
- **Refresh a schema dump introspected through a url carrying a credential**,
|
|
29
|
+
and rotate the token if that file was ever pushed — earlier versions recorded
|
|
30
|
+
the url verbatim in the dump's provenance.
|
|
31
|
+
- **Refresh a dump you deliberately keep behind your own schema class**, which
|
|
32
|
+
`verify` now fails on — or ask about no dump at all with
|
|
33
|
+
`verify_generated!(schema:)`.
|
|
34
|
+
- **Regenerate `@oneOf` inputs whose dump is `.json`**
|
|
35
|
+
(`rake graph_weaver:schema:refresh && rake graph_weaver:generate`): the newly
|
|
36
|
+
emitted `ONE_OF` starts refusing calls that set two fields, which your server
|
|
37
|
+
was refusing all along.
|
|
38
|
+
- **Move a `config.schema`, `config.router` or `config.context` set in a plain
|
|
39
|
+
`before` hook** into `GraphWeaver::Testing.configure` or an `around` — all
|
|
40
|
+
three are refused there now, where they used to silently change nothing.
|
|
41
|
+
- **Move `require "graph_weaver/rspec"` to `spec/rails_helper.rb`** if yours is
|
|
42
|
+
in `spec/support/` and you never uncommented rspec-rails' `spec/support`
|
|
43
|
+
glob: the require never ran, so every `graphql: :fake` example has been
|
|
44
|
+
hitting the real client.
|
|
45
|
+
- **Set `retries: 0`** if you wrapped a gateway in `Retry` and relied on it not
|
|
46
|
+
actually retrying: a 5xx/429 that arrives with a GraphQL errors body — which
|
|
47
|
+
is how Apollo Router answers — retries now, where it used to make one
|
|
48
|
+
attempt.
|
|
49
|
+
- **Update a spec that compares a `Testing::Router` error hash whole.** A
|
|
50
|
+
subgraph's error carries `extensions: {"service" => "<subgraph>"}` now, as
|
|
51
|
+
every real transport stamps it.
|
|
52
|
+
- **Drop `SUPERGRAPH=` from any task but `federation:*`**, which refuse it now
|
|
53
|
+
where they used to ignore it — a `SUPERGRAPH=… rake graph_weaver:queries:check`
|
|
54
|
+
in CI goes red instead of passing every query against the wrong schema.
|
|
55
|
+
- **Send the value the server expects** wherever a variable was a `File`, `IO`,
|
|
56
|
+
`Pathname` or plain object: refused at the wire now, where `JSON.generate`
|
|
57
|
+
used to ship its `#to_s`.
|
|
58
|
+
|
|
59
|
+
- **BREAKING: two error classes renamed, with no alias.**
|
|
60
|
+
`GraphWeaver::TypeError` is now **`GraphWeaver::CastError`** — it means the
|
|
61
|
+
response wouldn't cast into the generated structs, and the old name shadowed
|
|
62
|
+
a core class it doesn't descend from, so `rescue TypeError` inside the gem
|
|
63
|
+
read as Ruby's and `rescue GraphWeaver::TypeError` outside it read as a type
|
|
64
|
+
error in the caller's own code. `GraphWeaver::ValidationError` is now
|
|
65
|
+
**`GraphWeaver::QueryValidationError`** — it means the *query* failed schema
|
|
66
|
+
validation at build time, which a Rails reader would not guess from
|
|
67
|
+
"validation": their input's validation is `InputError`. The old names are
|
|
68
|
+
**gone**, not deprecated — rescuing one is a `NameError`, which is the loud
|
|
69
|
+
failure, and a constant alias would carry the confusion forward forever.
|
|
70
|
+
`GraphWeaver::Error` still catches both, as it always did.
|
|
71
|
+
- **Breaking: `graphql: false` is now `graphql: :live`.** The opt-out is the
|
|
72
|
+
app's own client, untouched — which is a mode like the other four, so it is
|
|
73
|
+
spelled like one. `false` is refused, and the refusal names `:live`.
|
|
74
|
+
`config.default_mode` is the suite-level spelling of the same thing, and it
|
|
75
|
+
now **defaults to `:live`** rather than to `nil`, which is no longer a value:
|
|
76
|
+
every example has exactly one mode, an untagged one takes
|
|
77
|
+
`config.default_mode`, and `graphql: :live` steps a single example back out
|
|
78
|
+
of a default the suite set. Behavior is unchanged — `:live` leaves
|
|
79
|
+
`GraphWeaver.client` exactly as it is, and it is still restored after the
|
|
80
|
+
example — but a helper now contradicts `graphql: :live` the way it
|
|
81
|
+
contradicts any other tag rather than quietly winning.
|
|
82
|
+
- **Breaking: `GraphWeaver::EXECUTE_EVENT` is now `"execute.graph_weaver"`.**
|
|
83
|
+
`<event>.<namespace>` is how every notification in this ecosystem is spelled
|
|
84
|
+
(`sql.active_record`, `execute_multiplex.graphql`), and it is what
|
|
85
|
+
`LogSubscriber.attach_to` and an APM's namespace routing key on — backwards,
|
|
86
|
+
the gem could not attach its own log subscriber without a puzzle.
|
|
87
|
+
**Subscribe through the constant and nothing changes**; a hardcoded
|
|
88
|
+
`"graph_weaver.execute"` silently stops matching, so grep for it.
|
|
89
|
+
- **Breaking: the payload's `:status` is a Symbol, and the HTTP status moved to
|
|
90
|
+
`:http_status`.** `:status` is now `:ok`, `:errors` (the response carried
|
|
91
|
+
GraphQL errors) or `:failed` (it raised) — a 200 carrying errors is not a
|
|
92
|
+
success, and only a symbol says that on both sides of the seam. In-process
|
|
93
|
+
`:http_status` is nil, where `:status` used to be a fabricated 200 so one
|
|
94
|
+
subscriber could read both sides of the seam.
|
|
95
|
+
- **A form can highlight the input field that was wrong, without reading
|
|
96
|
+
English — and `InputError#field` changed meaning (breaking).** `InputError`
|
|
97
|
+
now carries `#kind` (one of eight — `KINDS`), `#path` rooted at the variable
|
|
98
|
+
(`["where", "_and", 0, "_not", "species"]`, list indices included, so
|
|
99
|
+
`execute(ids: [1, 2, "x"])` reports `["ids", 2]`), `#coordinate`
|
|
100
|
+
(`"PetFilter.species"`), `#value` and `#details` (`members:`, `type:`,
|
|
101
|
+
`suggestion:`, `min:`…), so an app translates a stable key instead of parsing
|
|
102
|
+
a sentence ([i18n](docs/i18n.md)). **`#field` is now `#path`'s last *named*
|
|
103
|
+
segment** — the input field that actually held the value, which is the one a
|
|
104
|
+
form highlights, never an index — where it used to be re-branded on the way
|
|
105
|
+
out with the *variable* name; read `#path.first` for that. Nothing raises, so
|
|
106
|
+
the change passes unnoticed until the first refusal inside an input object.
|
|
107
|
+
|
|
108
|
+
**The structured half is spelled the way the schema is**, whichever side
|
|
109
|
+
refused: `#path`, `#field` and `#coordinate` are wire names — `["input",
|
|
110
|
+
"issuedOn"]`, `"externalId"`. It could not honestly be otherwise, since a
|
|
111
|
+
server can produce no spelling but its own, and a client-side refusal that
|
|
112
|
+
used the prop made `errors[e.field]` silently miss every server-detected
|
|
113
|
+
error, which is what a real Rails form did. The **prop** is what you type in
|
|
114
|
+
Ruby, and `#message` — the developer's line — names it (`external_id:
|
|
115
|
+
expected an Int`), so a form is `errors.add(e.field.underscore, …)` and
|
|
116
|
+
nothing else. An **unknown key** names no field, so the path echoes it
|
|
117
|
+
exactly as you wrote it and `details[:suggestion]` is the prop to type
|
|
118
|
+
instead. `filter_parameters` still judges by the prop, that list being
|
|
119
|
+
written in Ruby spelling — `api_key` has to keep matching what `apiKey`
|
|
120
|
+
holds.
|
|
121
|
+
|
|
122
|
+
**`#details[:type]` is the GraphQL type, never a Ruby class.** A String where
|
|
123
|
+
an input object belongs says `AdoptionInput`, not
|
|
124
|
+
`GraphQLTypes::AdoptionInput`, and a `register_scalar("Money", BigDecimal)`
|
|
125
|
+
field says `Money`, not `BigDecimal` — both used to render straight into
|
|
126
|
+
`"%{field} must be a %{type}."` on somebody's form. The messages still name
|
|
127
|
+
the Ruby you may pass, which is the developer's half. One gap left: the
|
|
128
|
+
sorbet fallback for a field no coercer covers still reports its Ruby type
|
|
129
|
+
(`metadata: expected T::Hash[…]`), which needs the field's GraphQL type
|
|
130
|
+
threaded through codegen's node tree. The format key is **`:pattern`**, not
|
|
131
|
+
`:format`, which is one of `I18n::RESERVED_KEYS` and raised
|
|
132
|
+
`I18n::ReservedInterpolationKey` out of [i18n](docs/i18n.md)'s own recipe the
|
|
133
|
+
moment an `:invalid_format` error reached it; a spec holds
|
|
134
|
+
`InputError::DETAILS` disjoint from that list so the next detail key can't
|
|
135
|
+
reintroduce the collision, and a server stating `extensions.input` sends
|
|
136
|
+
`"pattern"`.
|
|
137
|
+
|
|
138
|
+
`#value` goes through `filter_parameters`, and an unknown key carries no
|
|
139
|
+
`#value` at all — the key is what was wrong, and a typo'd key is by
|
|
140
|
+
definition not the filtered one it was meant to be. Both `#value` and
|
|
141
|
+
`#details` are always JSON-representable, at any depth (a non-finite Float
|
|
142
|
+
travels as `"NaN"` / `"Infinity"`), so `render json: e.to_h` can't turn a 422
|
|
143
|
+
into a 500 over exactly the values `Coerce` exists to refuse.
|
|
144
|
+
`InputError::VALUE_LIMIT` bounds what one can carry: an error is built for
|
|
145
|
+
whatever a caller sent and whatever a server echoed back, either of which can
|
|
146
|
+
be megabytes, and every raised one writes a `warn` line as well as landing in
|
|
147
|
+
`#to_h` — so each String `#value` holds at every depth, the value `#message`
|
|
148
|
+
quotes, and a sentence a server wrote are cut to 1024 bytes with `…(N more
|
|
149
|
+
bytes)` in place of the rest. A `@oneOf` input given one explicitly null
|
|
150
|
+
field is `kind: :missing` on that field rather than being told to "supply
|
|
151
|
+
exactly one field", which is what it had done, and a list element that isn't
|
|
152
|
+
what the list holds is a branded refusal naming the element rather than a raw
|
|
153
|
+
`NoMethodError`.
|
|
154
|
+
|
|
155
|
+
**And the other half of the question is answered too**:
|
|
156
|
+
`Response#input_errors` / `QueryError#input_errors` /
|
|
157
|
+
`GraphQLError#input_errors` read a **server's** rejection back into the same
|
|
158
|
+
`InputError` — from the `extensions.input` convention, graphql-ruby's
|
|
159
|
+
variable-coercion `problems`, a recognized `extensions.code`, or the argument
|
|
160
|
+
Hasura names in `extensions.path` (`"$.selectionSet.<field>.args.<name>"`,
|
|
161
|
+
list indices and nested input keys included), so `limit: -5` against anything
|
|
162
|
+
on that engine — PokeAPI, Nhost — comes back as an `InputError` on `path:
|
|
163
|
+
["limit"]`, the field a form highlights. With Hasura the **path is the test,
|
|
164
|
+
not the code**: `validation-failed` is also what it sends for a query that
|
|
165
|
+
doesn't parse, and attaching your own `.graphql` file to a form field would
|
|
166
|
+
be worse than saying nothing. **`#path` is the input path the server stated,
|
|
167
|
+
or empty** — a GraphQL error's own `path` names a *selection* rather than an
|
|
168
|
+
input slot, so it is never stood in for one: a server that sends
|
|
169
|
+
`extensions.input.path` as a dotted String, or with a segment that is neither
|
|
170
|
+
a field name nor an index, gets `#path` `[]` and `#field` `nil` instead of a
|
|
171
|
+
plausible-looking field name for a slot the input hasn't got, which a form
|
|
172
|
+
doing `errors.add(e.field.underscore, …)` would have highlighted. The same
|
|
173
|
+
goes for a recognized `extensions.code` that names no `argumentName`; the
|
|
174
|
+
`kind` and the `details` the server did state still stand. A message goes
|
|
175
|
+
through the same filter, since graphql-ruby quotes the rejected value as a
|
|
176
|
+
matter of course; `#details` is checked for type as well as key; no
|
|
177
|
+
`#coordinate` is invented for a problem that states no path of its own; and a
|
|
178
|
+
server's sentence earns a `kind` only where one is honest (`:not_a_member`
|
|
179
|
+
with the enum's members, `:unknown` with the input coordinate, `:missing` for
|
|
180
|
+
a null it won't take), everything else staying `:refused` with the server's
|
|
181
|
+
own words — one Hasura sentence covers both `limit: -5` and `limit: "lots"`,
|
|
182
|
+
so `:out_of_range` would be a guess. A rejection that says nothing
|
|
183
|
+
identifying it as input — a bare `validates:` failure — is left alone rather
|
|
184
|
+
than guessed at; the convention is one `Validator` away
|
|
185
|
+
([errors](docs/errors.md#when-the-server-rejects-the-input)).
|
|
186
|
+
- **A result now has real JSON, and it round-trips.** `result.to_json` was
|
|
187
|
+
Ruby's `Object#to_json` — the `#inspect` string, quoted
|
|
188
|
+
(`"\"#<PersonQuery::Result:0x000...>\""`) — with no exception and no
|
|
189
|
+
warning, so the first production log line or cache write that reached for it
|
|
190
|
+
silently stored nothing. Under Rails it was worse in a quieter way:
|
|
191
|
+
ActiveSupport's `Object#as_json` reflects over the ivars, so `render json:
|
|
192
|
+
result` shipped the *Ruby* prop names, trailing underscores included
|
|
193
|
+
(`{"when_":"2024-01-15"}` for a field the schema calls `when`). The rule now
|
|
194
|
+
is one sentence: **a result's JSON is the wire shape** — the response keys,
|
|
195
|
+
and each leaf back through its scalar registration's `serialize:` — so
|
|
196
|
+
`Result.from_h(JSON.parse(result.to_json)) == result`. `#as_json` is
|
|
197
|
+
generated beside `from_h` as its mirror, and `#to_json` goes through it, so
|
|
198
|
+
`render json:` and a plain `.to_json` agree. `#to_h` is unchanged and still
|
|
199
|
+
the Ruby view (Symbol prop names, `T::Enum` members, codec-built objects):
|
|
200
|
+
a Symbol-keyed hash can't be mistaken for a server's response, and a JSON
|
|
201
|
+
string can, so the JSON is the one that has to be true. The trip is as
|
|
202
|
+
faithful as each scalar's own `cast:`/`serialize:` pair — a `Time` goes back
|
|
203
|
+
out with the microseconds its registration writes, and a `cast:` with no
|
|
204
|
+
`serialize:` has no wire spelling at all, so its value reaches the encoder
|
|
205
|
+
as it is (the same reason an input can't send one). A `register_enum`
|
|
206
|
+
`fallback:` member is in no to-wire table — several wire values collapse into
|
|
207
|
+
it, and it is exactly the member a drifted response casts to — so serializing
|
|
208
|
+
one falls back to the member's own `#serialize`, which casts back to the
|
|
209
|
+
fallback; without a `fallback:` the table is total and a miss still raises.
|
|
210
|
+
The rule's reasoning is in [DECISIONS.md](DECISIONS.md).
|
|
211
|
+
- **A field named after a method every struct answers now generates, with a
|
|
212
|
+
trailing underscore.** `class` becomes the prop `class_`, `hash` becomes
|
|
213
|
+
`hash_`, and so on for `display`, `to_json`, `each`, and `supplied` on an
|
|
214
|
+
input — in results and input types alike. Both sides used to refuse: the
|
|
215
|
+
result side told you to alias the key in the query, and the input side told
|
|
216
|
+
you nothing you could act on, because a schema's field name is not yours to
|
|
217
|
+
rename. `class`, `hash` and `display` are columns somebody has, and a Hasura
|
|
218
|
+
`bool_exp` has one input field per column, so that refusal turned whole
|
|
219
|
+
schemas away. One rule, both directions: a prop may not shadow a method its
|
|
220
|
+
struct answers, so the prop — and only the prop — moves out of the way. The
|
|
221
|
+
wire is untouched, so the query you wrote, the request that goes out and the
|
|
222
|
+
response that comes back all keep the schema's spelling; `result.class` is
|
|
223
|
+
still Ruby's `class`, and `result.class_` is the field. The prop is the
|
|
224
|
+
field's one Ruby name — `.new`, `.coerce`, a **result's** `#to_h` and pattern
|
|
225
|
+
matching all use `class_`, while an **input** struct's `#to_h` is the wire
|
|
226
|
+
hash it would send (`{"class" => …}`) and input structs don't pattern-match
|
|
227
|
+
at all. An `InputError`'s structured half is the wire's throughout, so a
|
|
228
|
+
refusal on that field reports `#path` `["class"]` and `#coordinate`
|
|
229
|
+
`"Tricky.class"`. Generated source notes the rename on the line above the
|
|
230
|
+
prop (`# wire: class — reserved as a prop name`), which is the one
|
|
231
|
+
prop-vs-wire difference a reader can't infer. A key you aliased in the query
|
|
232
|
+
to get past the old refusal still generates from the alias — drop it and
|
|
233
|
+
regenerate if you want the field's own name back.
|
|
234
|
+
|
|
235
|
+
**The reserved names are a list the gem owns**, rather than whatever
|
|
236
|
+
`T::Struct` answered to in the generating process. Deriving them made
|
|
237
|
+
generation depend on require order: with ActiveSupport loaded first a result
|
|
238
|
+
key named `asJson` was refused, loaded second it became a prop that shadowed
|
|
239
|
+
the real `#as_json`, so `render json: result` serialized the field. The list
|
|
240
|
+
is what a struct answers — the public instance methods of `T::Struct` and
|
|
241
|
+
`Object`, the hooks Ruby or Rails call on an object that doesn't define one
|
|
242
|
+
(`initialize`, `to_ary`, `to_a`, `to_hash`, `to_str`, `to_int`, `to_proc`,
|
|
243
|
+
`to_json`, `as_json`, `to_param`, `to_query`, `try`, `presence`, `each`,
|
|
244
|
+
`deconstruct`, `deconstruct_keys`), and the methods the gem's own mixins
|
|
245
|
+
define. Kernel's *private* methods are deliberately not on it: a struct
|
|
246
|
+
doesn't answer them, and `format`, `select`, `test`, `open`, `load` and `pp`
|
|
247
|
+
are ordinary database columns. The rule itself has one public home,
|
|
248
|
+
`GraphWeaver::Codegen.prop_name`, for the parts of the gem outside generation
|
|
249
|
+
that have to agree with it.
|
|
250
|
+
- **A federation `@key` on a reserved field generates instead of refusing.**
|
|
251
|
+
`@key(fields: "class")` was refused with nowhere to send you — a subgraph's
|
|
252
|
+
`@key` field is not yours to rename — and `@key(fields: "hash")` emitted
|
|
253
|
+
`def self.slot(hash:)` beside `const :hash_`, so reading `slot.hash_` and
|
|
254
|
+
writing it back raised a bare `ArgumentError`. A key kwarg now takes the same
|
|
255
|
+
trailing underscore the prop does (`Representations.room(class_: …)`), and
|
|
256
|
+
still sends `"class"` on the wire. Regenerate if a `@key` of yours names such
|
|
257
|
+
a field. ([federation](docs/federation.md))
|
|
258
|
+
- **A `@key` that selects through a list stays a list.** `@key(fields: "id
|
|
259
|
+
lineItems { sku }")` over a `[LineItem!]!` typed its kwarg as one
|
|
260
|
+
`T::Hash[…]`: the correct list was refused and a single hash was accepted and
|
|
261
|
+
sent, which described an entity that doesn't exist. The kwarg is
|
|
262
|
+
`T::Array[T::Hash[…]]` now, the list stays a list on the wire, and the
|
|
263
|
+
generated key set spells the hop `"lineItems[]"`. No other `@key` shape
|
|
264
|
+
changes — **regenerate** if one of yours selects through a list.
|
|
265
|
+
- **A block-built type helper is named for its source, not for what is
|
|
266
|
+
loaded.** `GraphWeaver.extend_type("Widget") { … }` minted its module by
|
|
267
|
+
counting whatever `GraphWeaver::TypeHelpers` constants already existed, which
|
|
268
|
+
made the name a function of how many times *that process* had read the
|
|
269
|
+
registry: `rake graph_weaver:generate` baked `WidgetV3` while a plain boot
|
|
270
|
+
only ever creates `WidgetV1`, so `rails server` died on "includes
|
|
271
|
+
GraphWeaver::TypeHelpers::WidgetV3, but nothing registers it" while `verify`,
|
|
272
|
+
run moments earlier on the same tree, called it up to date. The module is
|
|
273
|
+
named for where the block is written and what it extends —
|
|
274
|
+
`TypeHelpers::Pet` at the top level, `TypeHelpers::Billing::Pet` in `graph
|
|
275
|
+
:billing` — so two graphs extending one type name each get their own
|
|
276
|
+
constant, and the same source counts the same way in every process and after
|
|
277
|
+
a `to_prepare` reload. **Regenerate.** The reasoning is in
|
|
278
|
+
[DECISIONS.md](DECISIONS.md).
|
|
279
|
+
- **Generation refuses an input type whose fields collide on one Ruby prop.**
|
|
280
|
+
Two input fields that underscore onto the same name — `nameWithOwner` and
|
|
281
|
+
`name_with_owner`, or `class` and `class_`, since `class` is renamed out of a
|
|
282
|
+
method's way — emitted `const :name_with_owner` twice, and the generated file
|
|
283
|
+
raised `ArgumentError` when it was *required*, from a trace with no schema in
|
|
284
|
+
it. It now refuses at generation, naming both fields, the type and the prop —
|
|
285
|
+
and says which variable reaches what (`$outer reaches Inner through inner`),
|
|
286
|
+
since the colliding type is usually several hops below the declaration you
|
|
287
|
+
wrote. The escape is to pass the object as a literal with a variable per
|
|
288
|
+
field, a schema's field name not being yours to rename; where the type is
|
|
289
|
+
reached through a list there is no such form, and the refusal says plainly
|
|
290
|
+
that no form of the query generates. The output side has always refused this;
|
|
291
|
+
the three real schemas swept (GitHub, PokeAPI, countries — 12,684 input
|
|
292
|
+
fields) contain no such pair.
|
|
293
|
+
- **An input type named `Result`, `QUERY` or `Representations` is refused**,
|
|
294
|
+
the way an enum by one of those names already was. Those are the constants
|
|
295
|
+
every generated query module defines, and nothing checked an input struct
|
|
296
|
+
against them: `input Result` emitted `class Result` twice, the second
|
|
297
|
+
reopening the first into one struct that answered for both the variable and
|
|
298
|
+
the response — generated code that looks authoritative and is wrong — while
|
|
299
|
+
`input QUERY` raised a bare `TypeError: QUERY is not a class` from Ruby,
|
|
300
|
+
about the document heredoc. The refusal names the type and the three
|
|
301
|
+
constants. No public schema in a 23-schema sweep declares one (SpaceX has an
|
|
302
|
+
*object* named `Result`, which is unaffected — a result class is named for
|
|
303
|
+
the response key, not the type).
|
|
304
|
+
- **`respond_to?` on a generated result struct no longer answers true for a
|
|
305
|
+
name that doesn't exist.** It said true for any near miss, so the standard
|
|
306
|
+
duck-typing guard was the thing that broke — `obj.pet if obj.respond_to?(:pet)`
|
|
307
|
+
raised the very `NoMethodError` the hint exists to explain. `method_missing`
|
|
308
|
+
still hints; the cost is that `struct.method(:nmae)` now raises Ruby's own
|
|
309
|
+
bare `NameError` rather than the hinted one.
|
|
310
|
+
- **Generated result structs behave like ordinary Ruby objects**: value `==`
|
|
311
|
+
(with `eql?` and `hash`, so a result works as a hash key),
|
|
312
|
+
`deconstruct_keys` for pattern matching, and `#to_h`. All three go the whole
|
|
313
|
+
way down a nested result. `#to_h` is the Ruby shape, not the wire's —
|
|
314
|
+
snake_case prop names as Symbol keys, nils kept, enums as their `T::Enum`
|
|
315
|
+
members — so it is a view, not something to send back to a server. A result
|
|
316
|
+
key that would collide with one of the new names (`deconstruct_keys`)
|
|
317
|
+
generates with a trailing underscore, as any other reserved name does.
|
|
318
|
+
- **A response whose shape drifted names the field and what arrived.** A server
|
|
319
|
+
that sends an object where a list belongs used to raise *"failed to cast
|
|
320
|
+
response into Q::Result::Person: Parameter 'data': Expected type
|
|
321
|
+
T::Hash[String, T.untyped], got T::Array[String]"* — the field named nowhere,
|
|
322
|
+
the struct named being the parent, and the "got" describing pairs `Hash#map`
|
|
323
|
+
had made on the way in rather than anything the server sent. It reads `pets:
|
|
324
|
+
expected a list, but the server sent an object` now, and points into the list
|
|
325
|
+
(`pets.0: expected an object, but the server sent null`) when an element is
|
|
326
|
+
the problem. A nullable sibling that is legitimately `null` is never the one
|
|
327
|
+
blamed: the guard that skips it reads the prop's type with its nilable-ness
|
|
328
|
+
intact, where it used to strip it and so never fired.
|
|
329
|
+
- **`Response#report` no longer calls a method a server's error path names.**
|
|
330
|
+
The walk that resolves `entity_ids` stepped through the typed data with
|
|
331
|
+
`respond_to?`, which is true of every `Object` method — so an error path with
|
|
332
|
+
a segment named `freeze` **froze the caller's result struct** and then
|
|
333
|
+
reported an id for a field that doesn't exist, `display` printed the struct to
|
|
334
|
+
stdout, and `tap`/`send`/`method` raised `LocalJumpError`/`ArgumentError` out
|
|
335
|
+
of error handling. It reads the struct's own props now, so a segment that
|
|
336
|
+
isn't a field of that struct resolves to nil — and a field whose prop took a
|
|
337
|
+
trailing underscore (`class` → `class_`) resolves for the first time.
|
|
338
|
+
- **The emitted `QUERY` is the query you wrote**, in two ways it wasn't.
|
|
339
|
+
Naming an anonymous operation splices the module's name into the operation's
|
|
340
|
+
own declaration at a position graphql-ruby reports — which is a byte offset
|
|
341
|
+
measured against character line starts, and a whole-document offset for a
|
|
342
|
+
token with no newline after it. A comment above a single-line `{ … }`, or any
|
|
343
|
+
multibyte character before the operation, put the name in the wrong place;
|
|
344
|
+
the emitted module still generated and `verify` still reported it up to date,
|
|
345
|
+
and every call failed on the wire. The splice is now measured correctly
|
|
346
|
+
**and** the spliced document is re-parsed — if it doesn't declare the name,
|
|
347
|
+
generation refuses and names the file rather than shipping a module that
|
|
348
|
+
can't run. The heredoc also used to right-strip every line, which silently
|
|
349
|
+
edits the *value* of a block-string argument — trailing whitespace inside
|
|
350
|
+
`"""…"""` is significant — so a query carrying one now sends what the
|
|
351
|
+
`.graphql` file says.
|
|
352
|
+
- **"Select `__typename`" now says why the ones you did select don't count.**
|
|
353
|
+
Putting `__typename` in every `... on Type` and nowhere else looks like
|
|
354
|
+
compliance — it is what Linear's published SDK documents do, and it refuses
|
|
355
|
+
36 of their 250 operations — and the tag really is on the wire; it is just
|
|
356
|
+
read *after* the dispatch it would decide, and a member the query never named
|
|
357
|
+
would carry none at all. The refusal says so when that is the shape in front
|
|
358
|
+
of it, and keeps the short message otherwise. The rule is unchanged, and
|
|
359
|
+
[generated modules](docs/generated_modules.md#abstract-types) states it too.
|
|
360
|
+
- **Two codegen refusals name what you wrote.** Two result keys that generate
|
|
361
|
+
one class name now name both of them, the way the sibling prop-collision
|
|
362
|
+
message already did; and a module named `T` is refused rather than emitting
|
|
363
|
+
code that shadows Sorbet's `T` in its own body.
|
|
364
|
+
- **A date and a timestamp are refused for each other, both ways.** A date
|
|
365
|
+
reads back as a `Date` and a timestamp as a `Time`; the same rule now holds
|
|
366
|
+
for variables on the way out. `DateTime` is a `Date` to Ruby, so it passed
|
|
367
|
+
straight through the cast and went on the wire as a full timestamp —
|
|
368
|
+
`"2024-01-15T10:20:30+00:00"` where the schema said `ISO8601Date`, which a
|
|
369
|
+
lenient server truncated and a strict one refused — while a `Time` for an
|
|
370
|
+
`ISO8601Date`, and a `Date` for an `ISO8601DateTime`, surfaced Ruby's raw
|
|
371
|
+
*"no implicit conversion of Time into String"*. Truncating to the date here
|
|
372
|
+
would be the same guess made silently, so each now raises an `InputError`
|
|
373
|
+
naming the variable and the class: `$d of On: expected a Date, got a DateTime
|
|
374
|
+
— pass .to_date if dropping the time of day is what you meant`, and `$t of
|
|
375
|
+
At: expected a Time, got a Date — a Date has no time of day — pass the Time
|
|
376
|
+
you mean`. What a timestamp variable *accepts* grew to match — a `DateTime`
|
|
377
|
+
and the `ActiveSupport::TimeWithZone` from `Time.zone.now` both convert
|
|
378
|
+
losslessly, and both used to raise.
|
|
379
|
+
- **A scalar you cast yourself gets the library's guard and its verdict.** With
|
|
380
|
+
a registration like `register_scalar("Date", Date, cast: :iso8601, serialize:
|
|
381
|
+
:iso8601)`, the emitted guard was a bare `value.is_a?(Date)` pass-through —
|
|
382
|
+
so a `DateTime` (which *is* a `Date` to Ruby) went by untouched and your own
|
|
383
|
+
`serialize:` wrote `"2024-01-15T10:20:30+00:00"` into a field the schema says
|
|
384
|
+
holds a date, the same cross-type trap the built-in `Date` refuses. It is
|
|
385
|
+
refused here now, with the same words. Anything else wrong used to reach your
|
|
386
|
+
codec and come back as Ruby's own sentence about an argument you never
|
|
387
|
+
wrote — `$input of Probe: birthday: no implicit conversion of Integer into
|
|
388
|
+
String`, filed under `kind: :unparseable`. The verdict is the library's now,
|
|
389
|
+
whoever wrote the codec, and it splits the way Ruby itself does: a
|
|
390
|
+
`TypeError` means the *class* was wrong and reads `$input of Probe: birthday:
|
|
391
|
+
expected a Date, got 5` under `kind: :type_mismatch`, while an
|
|
392
|
+
`ArgumentError` means the *content* was and keeps the parser's own words
|
|
393
|
+
(`invalid date`, `kind: :unparseable`) — the difference between "send
|
|
394
|
+
something else" and "fix the text", which is the half a form needs. The same
|
|
395
|
+
split applies to any `TypeError` a `cast:` or `serialize:` raises. The guard
|
|
396
|
+
is emitted into your generated files, so a checked-in one keeps the old
|
|
397
|
+
`is_a?` until you regenerate.
|
|
398
|
+
- **"has no cast" now says which mistake you made.** Two unrelated
|
|
399
|
+
misconfigurations reached the same refusal: a registered class none of the
|
|
400
|
+
three probes (`.parse`, `.load`, `Kernel#Type`) matched, and a `type:` given
|
|
401
|
+
by *name*, which is never probed at all because there is no class in hand.
|
|
402
|
+
The advice differs — name a `cast:`, versus pass the class — but the sentence
|
|
403
|
+
didn't. Each now names its own cause: the class form lists the three probes
|
|
404
|
+
that found nothing, and the string form says it was registered by name and
|
|
405
|
+
shows the class form to switch to. The string form's no-probing behavior is
|
|
406
|
+
unchanged and correct;
|
|
407
|
+
[scalars](docs/scalars.md#registering-a-class-of-your-own) now says so where
|
|
408
|
+
the string form is introduced.
|
|
409
|
+
- **A type-string scalar's bad value is named like every other one.**
|
|
410
|
+
`register_scalar("Vector", "T::Array[Float]")` gives the field no coercer, so
|
|
411
|
+
only sorbet stands between the value and the struct — and the check that
|
|
412
|
+
turns sorbet's complaint back into the library's asked `#valid?`, which stops
|
|
413
|
+
at the outermost type. `[1, 2, 3]` for a `T::Array[Float]` passed it while
|
|
414
|
+
the struct's setter (which checks recursively) refused, so the refusal came
|
|
415
|
+
out blaming the list that *held* the struct, with empty `details` and
|
|
416
|
+
sorbet's wording: *"items: invalid input for LineItemInput: Parameter
|
|
417
|
+
'vector': Can't set LineItemInput.vector to [1, 2, 3] (instance of Array) -
|
|
418
|
+
need a T::Array[Float]"*. It now asks the same question the setter does, so
|
|
419
|
+
it says *"vector: expected T::Array[Float], got [1, 2, 3]"* with
|
|
420
|
+
`coordinate: "LineItemInput.vector"`, `path: ["input", "items", 0, "vector"]`
|
|
421
|
+
and `details: {type:}` — the same shape a class-registered scalar's refusal
|
|
422
|
+
has always had. Lists of lists and nilable fields alike.
|
|
423
|
+
- **`register_scalar` warns when the Ruby type inherits `eql?`.** A result
|
|
424
|
+
compares its props with `eql?`, so a value object that inherits it compares
|
|
425
|
+
by identity: two results parsed from the same bytes are unequal, and neither
|
|
426
|
+
works as a hash key, while the leaf itself compares fine.
|
|
427
|
+
`alias_method :eql?, :==` plus a matching `hash` is the fix. The warning
|
|
428
|
+
fired only for a class that defined `==` and forgot `eql?`, though its own
|
|
429
|
+
comment described the whole hazard — a class overriding *neither* breaks
|
|
430
|
+
result equality identically and got nothing. One rule now: a registered class
|
|
431
|
+
that inherits `#eql?` warns. `String`, `Integer`, `Float`, `Date`, `Time`,
|
|
432
|
+
`DateTime` and `BigDecimal` all define it and stay silent; a `T::Enum` is
|
|
433
|
+
exempt, its values being singletons; and a class that gets `==` from
|
|
434
|
+
`Comparable` and stops there is a true positive, not a false one. See
|
|
435
|
+
[scalars](docs/scalars.md#registering-a-class-of-your-own).
|
|
436
|
+
- **A timestamp keeps its sub-second part on the way out.** `Time#iso8601`
|
|
437
|
+
takes no precision, so `"2024-01-15T10:20:30.500Z"` read off the wire went
|
|
438
|
+
back out as `"2024-01-15T10:20:30Z"` — half a second gone from an
|
|
439
|
+
`updatedAt` concurrency token, or from the `since:` a window is read on.
|
|
440
|
+
graphql-ruby's own `ISO8601DateTime` writes whole seconds, so a stock Ruby
|
|
441
|
+
server never showed it; **any JS/Apollo server writes milliseconds on every
|
|
442
|
+
timestamp**. A `Time` (or `DateTime`) that carries a fraction is now written
|
|
443
|
+
with microseconds, and one that doesn't sends the bytes it always has.
|
|
444
|
+
- **A `Float` variable must be a finite number.** `Kernel#Float("1e400")` is
|
|
445
|
+
`Infinity` rather than a raise (so is `(10**400).to_f`), and JSON has no
|
|
446
|
+
spelling for a non-finite number — the GraphQL spec excludes them from
|
|
447
|
+
`Float` outright. It used to travel as far as the transport, which blamed
|
|
448
|
+
the whole request (*"variables are not JSON-serializable"*); the refusal now
|
|
449
|
+
names the variable and the value. Every door is checked, so an actual
|
|
450
|
+
`Float::INFINITY` is refused as well as a string that parses to one — and
|
|
451
|
+
`register_scalar "Ratio", Float` now reads a whole number off the wire
|
|
452
|
+
exactly as the built-in `Float` does, which it didn't before.
|
|
453
|
+
- **A variable with no JSON form is refused at the wire.** `JSON.generate`
|
|
454
|
+
renders a value it doesn't know as that value's `#to_s`, so an `Upload!`
|
|
455
|
+
given a real file went out as `{"file":"#<File:0x00007f…>"}` — 200 back, no
|
|
456
|
+
error, and a memory address in the server's database. A `File`, an `IO`, a
|
|
457
|
+
`Pathname` or a plain object is refused before the body is built, naming the
|
|
458
|
+
variable and what to do instead; a `Date`, `Time`, `BigDecimal` or `Symbol`
|
|
459
|
+
still travels as its string. File uploads still need your own transport —
|
|
460
|
+
graph_weaver doesn't implement the GraphQL multipart request spec.
|
|
461
|
+
- **A variable's whole trip onto the wire is branded.** Serialization ran
|
|
462
|
+
*outside* the coercion's rescue, so anything it raised arrived as a bare
|
|
463
|
+
`NoMethodError` naming neither the variable nor the operation — now it reads
|
|
464
|
+
`$budget of Store: …`, the way a coercion failure already did.
|
|
465
|
+
- **`filter_parameters` scrubs a message's value at every depth, as it already
|
|
466
|
+
did `#value`.** The message side only ever asked whether the *variable's own*
|
|
467
|
+
name was filtered, so `execute(credentials: { token: "…" })` refused with the
|
|
468
|
+
secret quoted in the sentence — and in the `warn` line `Error#initialize`
|
|
469
|
+
writes — while `InputError#value` sitting beside it read `[FILTERED]`. The
|
|
470
|
+
value a message quotes now goes through the same scrub as `#value`, so a
|
|
471
|
+
filtered key one level in reads `got {"token" => "[FILTERED]"}`. Messages
|
|
472
|
+
change only where a filter matches; nothing else moved.
|
|
473
|
+
- **Every transport failure names the endpoint it failed against.**
|
|
474
|
+
`TransportError` and `ServerError` carry `#url`, say it in the message
|
|
475
|
+
(`HTTP 502: <html>… — POST https://api.example.com/graphql`) and in `#to_h` —
|
|
476
|
+
an app talking to two graphs used to get `HTTP 502` and nothing else, so every
|
|
477
|
+
investigation started with "which one". The url they name is the one the gem
|
|
478
|
+
is **willing to say**: a url's userinfo and any query parameter
|
|
479
|
+
`GraphWeaver.filter_parameters` already filters are folded to `[FILTERED]`, by
|
|
480
|
+
the same list that scrubs the variables line. That applies everywhere a
|
|
481
|
+
transport says its url — the boot line, the per-request `debug` lines,
|
|
482
|
+
`Transport#inspect`, and `payload[:url]` on `execute.graph_weaver`, i.e. your
|
|
483
|
+
APM — so `https://svc:hunter2@api.example.com/graphql?access_token=…` no
|
|
484
|
+
longer lands in a log file or a third-party trace store. `Transport#url` is
|
|
485
|
+
unchanged: it is where requests actually go, and `#safe_url` is the sayable
|
|
486
|
+
one.
|
|
487
|
+
- **A schema dump no longer records the credential that introspected it.** The
|
|
488
|
+
provenance stamp (`schema:refresh` and `cache:`) wrote the transport's url
|
|
489
|
+
verbatim, so a url carrying userinfo or an `?access_token=` landed in a file
|
|
490
|
+
that gets committed. It now records the endpoint bare — userinfo and any
|
|
491
|
+
query parameter `filter_parameters` filters are dropped, the rest kept — and
|
|
492
|
+
re-introspection still authenticates from the dump's `auth_env`, which is
|
|
493
|
+
where the token belonged. The loader's log lines and errors say the url the
|
|
494
|
+
way every transport failure now does, secrets marked `[FILTERED]`. A dump
|
|
495
|
+
written by an earlier version from such a url still holds the token: refresh
|
|
496
|
+
it once, and rotate the token if the file was pushed.
|
|
497
|
+
- **A `multipart/mixed` response is named rather than dumped.** Forcing an
|
|
498
|
+
incremental-delivery `Accept` used to raise `non-GraphQL response:` followed
|
|
499
|
+
by the whole multipart payload, which misdiagnoses a body that is perfectly
|
|
500
|
+
well-formed GraphQL — just more than one document. It says so now: "this
|
|
501
|
+
response is incremental delivery (@defer/@stream), which this client doesn't
|
|
502
|
+
read".
|
|
503
|
+
- **A response behind a UTF-8 BOM now parses.** RFC 8259 §8.1 lets a parser
|
|
504
|
+
ignore a leading BOM and Ruby's doesn't, so a .NET/IIS-fronted endpoint's
|
|
505
|
+
answer came back as `non-GraphQL response: {"data":…}` — a body that looks
|
|
506
|
+
perfectly good, broken by three invisible bytes. And **an empty body says so**
|
|
507
|
+
(`HTTP 204: empty response body`) rather than trailing off after the colon,
|
|
508
|
+
which read as a truncated message rather than as the answer it was.
|
|
509
|
+
- **Two more net/http failures arrive as `TransportError`.** A garbage status
|
|
510
|
+
line (`Net::HTTPBadResponse` — a misbehaving proxy, HTTP sent to a port
|
|
511
|
+
speaking something else, or a keep-alive socket that desynced) and a body
|
|
512
|
+
that isn't the gzip it claims to be (`Zlib::Error`) used to escape the
|
|
513
|
+
`GraphWeaver::Error` umbrella entirely, so `rescue GraphWeaver::Error` missed
|
|
514
|
+
them and `Retry` treated a retriable failure as fatal. Both are now
|
|
515
|
+
`TransportError` — which retries, on a fresh connection — as is
|
|
516
|
+
`Net::ProtocolError`. `Transport::Faraday` already classified all three this
|
|
517
|
+
way; the two shipped transports now agree.
|
|
518
|
+
- **A retry policy that was inert behind a gateway now fires.** `Retry` read
|
|
519
|
+
only the failures that *raised*, and Apollo Router answers everything it
|
|
520
|
+
decides itself with a GraphQL errors body — rate limiting is `503` plus
|
|
521
|
+
`REQUEST_RATE_LIMITED`, its own faults are `500` plus a code — so the body
|
|
522
|
+
won over the status and `retries: 3` made one attempt. One rule now: a
|
|
523
|
+
response retries when its status is one a `ServerError` retries on (5xx, 408,
|
|
524
|
+
429), or when its error codes are named in `retry_codes:`. A `200` is never
|
|
525
|
+
retried on status, so a router's partial `GATEWAY_TIMEOUT` still needs
|
|
526
|
+
`retry_codes:` to opt in, and a mutation still gets one attempt unless
|
|
527
|
+
`retry_mutations: true`. `REQUEST_RATE_LIMITED` joins
|
|
528
|
+
`GraphQLError::THROTTLE_CODES`, so `#throttled?` answers it too. The
|
|
529
|
+
reasoning is in [DECISIONS.md](DECISIONS.md).
|
|
530
|
+
- **A retry delay can no longer kill the retry loop.** `base_delay:` and
|
|
531
|
+
`max_delay:` are refused if negative, where the typo is; a custom `backoff:`
|
|
532
|
+
returning one is floored at no wait. Either used to reach `Kernel#sleep`,
|
|
533
|
+
which raises `ArgumentError` — so one mistyped option reported as a bug
|
|
534
|
+
somewhere else, and the failure being retried was lost behind it.
|
|
535
|
+
- **`Transport::HTTP`'s connection pool is fork-safe.** A socket idle at `fork`
|
|
536
|
+
time was inherited by every child, and a round trip carries nothing saying
|
|
537
|
+
which process opened it — so forked workers interleaved requests on one fd and
|
|
538
|
+
a caller could receive a well-formed GraphQL response to *another process's*
|
|
539
|
+
query, with no exception anywhere. The trigger is the documented boot path:
|
|
540
|
+
Puma `preload_app!` (or Sidekiq) plus an initializer that introspects, which
|
|
541
|
+
leaves exactly one warm socket in the pool. The pool now belongs to the
|
|
542
|
+
process that built it — on the first request after a fork the inherited
|
|
543
|
+
sockets are abandoned (not closed: that would take down the fd the parent is
|
|
544
|
+
still using) and the pool's permits are rebuilt.
|
|
545
|
+
- **`GraphWeaver.new(url, pool_size: N)`** sizes the bundled HTTP transport's
|
|
546
|
+
connection pool from the url client, where before only `Transport::HTTP.new`
|
|
547
|
+
took it and an app had to set `RAILS_MAX_THREADS` to reach it. With
|
|
548
|
+
`transport: :faraday` it is refused rather than dropped — Faraday's adapter
|
|
549
|
+
owns its connections.
|
|
550
|
+
- **`Transport::Faraday#url` keeps a url's query string.** Faraday moves it
|
|
551
|
+
into the connection's default params and strips it from `url_prefix`, so
|
|
552
|
+
`Transport::Faraday.new("https://api.example.com/graphql?apiKey=…").url`
|
|
553
|
+
reported an endpoint no request goes to — the one the boot log line names,
|
|
554
|
+
and the one `graphql: :wire` serves a graph's resolvers at. `#url` is now
|
|
555
|
+
reconstructed from where requests actually go, through Faraday's own encoder
|
|
556
|
+
— so an Array param reads `a[]=1&a[]=2`, the way the wire carries it, and a
|
|
557
|
+
Hash param is not a URL-escaped `Hash#inspect`.
|
|
558
|
+
- **A request header can be a callable.** On `Transport::HTTP` a `headers:`
|
|
559
|
+
value answering `#call` is resolved per request rather than captured when the
|
|
560
|
+
transport was built, so a rotating credential needs no new transport:
|
|
561
|
+
|
|
562
|
+
```ruby
|
|
563
|
+
GraphWeaver::Transport::HTTP.new(url, headers: {
|
|
564
|
+
"Authorization" => -> { "Bearer #{Tokens.fetch}" },
|
|
565
|
+
"X-Tenant" => -> { Current.tenant&.id }, # nil ⇒ header omitted
|
|
566
|
+
})
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
A value (or a call) of `nil` sends no such header. `Transport::Faraday`
|
|
570
|
+
resolves one per request too, so a rotating credential means the same thing
|
|
571
|
+
on both bundled transports. **And `auth:` takes a token that rotates** —
|
|
572
|
+
`auth: -> { Tokens.fetch }` is resolved per request like any other callable
|
|
573
|
+
header value, where it used to raise and send you to Faraday's middleware.
|
|
574
|
+
- **A non-String header value is sent as its `to_s`** on both transports, rather
|
|
575
|
+
than escaping as `NoMethodError: undefined method 'strip'` from inside
|
|
576
|
+
net/http, naming neither graph_weaver nor the header. The documented
|
|
577
|
+
`"X-Tenant" => -> { Current.tenant&.id }` crashed in any app whose ids are
|
|
578
|
+
Integers. A callable is still resolved first and `nil` still drops the header.
|
|
579
|
+
- **Both transports name themselves to the graph.** Every request carries
|
|
580
|
+
`apollographql-client-name` and `apollographql-client-version`, which is what
|
|
581
|
+
an Apollo Router or GraphOS keys client attribution on — a real router's span
|
|
582
|
+
showed `client.name: ""` for everything this gem sent. The name is your Rails
|
|
583
|
+
application's, or `graph_weaver` outside Rails; the version is the gem's.
|
|
584
|
+
Override either in `headers:`, which is how one app names its several clients
|
|
585
|
+
apart. `Transport.default_headers` is the whole set; `DEFAULT_HEADERS` stays
|
|
586
|
+
the fixed half of it.
|
|
587
|
+
- **A prebuilt `Faraday::Connection` now sends graph_weaver's `User-Agent`.**
|
|
588
|
+
Faraday pre-fills its own on every connection, so the fill-in-the-blanks
|
|
589
|
+
`||=` never fired and the traffic attributed to `Faraday v…` — defeating the
|
|
590
|
+
header's whole purpose. A User-Agent you set yourself still wins.
|
|
591
|
+
- **`ServerError#headers` answers any casing.** Transports store response
|
|
592
|
+
headers downcased, so `e.headers["Retry-After"]` — the spelling the server
|
|
593
|
+
sent, and the one a caller reaches for — used to return nil. Lookup by name
|
|
594
|
+
(`#[]`, `#fetch`, `#dig`, `#key?`) now folds the case; iteration, `#keys` and
|
|
595
|
+
`#to_h` still yield the downcased spelling, so logs are unchanged.
|
|
596
|
+
- **A Rails app now sees its GraphQL calls without configuring anything.** The
|
|
597
|
+
railtie sets `GraphWeaver.instrumenter` to the `ActiveSupport::Notifications`
|
|
598
|
+
adapter (an instrumenter the app set is never replaced) and attaches
|
|
599
|
+
`GraphWeaver::LogSubscriber`, so every execution — over the wire and
|
|
600
|
+
in-process — is one notification an APM can subscribe to and one line in the
|
|
601
|
+
log: `GraphWeaver billing/InvoicesQuery (12.3ms) ok`, `… errors [THROTTLED]`,
|
|
602
|
+
`… failed GraphWeaver::TransportError` (unprefixed when the module names no
|
|
603
|
+
graph, so a single-schema app's log reads `GraphWeaver PersonQuery …`).
|
|
604
|
+
**One rule: the summary is info, the wire is debug** — this is the only
|
|
605
|
+
GraphWeaver line at info, so a production log gets one per operation and
|
|
606
|
+
nothing that can carry PII, and turning the logger up to debug adds the query
|
|
607
|
+
and variables *beneath* it rather than repeating it. Measured at ~4.5µs per
|
|
608
|
+
execution all told (0.13µs of that `ActiveSupport::Notifications` itself with
|
|
609
|
+
nothing subscribed), against a round trip measured in milliseconds.
|
|
610
|
+
|
|
611
|
+
**Both auto-wires are opt-out from an initializer.** `GraphWeaver.logger =
|
|
612
|
+
nil` and `GraphWeaver.instrumenter = nil` in `config/initializers` win,
|
|
613
|
+
because the railtie declares them first and `before:
|
|
614
|
+
:load_config_initializers` — Rails gives an initializer an implicit `after:`
|
|
615
|
+
of the previous one the railtie declared, which is how the fallback would
|
|
616
|
+
otherwise land *after* the app's own files and overwrite the nil it had just
|
|
617
|
+
set. An app that reached for `config.after_initialize { GraphWeaver.logger =
|
|
618
|
+
nil }` still works and can drop it. ([logging](docs/logging.md))
|
|
619
|
+
- **The instrumentation payload is a documented contract** ([logging](docs/logging.md#the-payload)),
|
|
620
|
+
the same shape whichever client slot ran the request: `:operation`,
|
|
621
|
+
`:graph`, `:client`, `:status` and `:duration_ms` always;
|
|
622
|
+
`:url`/`:http_status` over the wire, `:schema` in-process; `:error` (the
|
|
623
|
+
exception's class name) and `:code` — the first GraphQL error's code, or a
|
|
624
|
+
`ServerError`'s status, the one key to group an alert by — when there is
|
|
625
|
+
one; and `:retries` under a `Retry`, where each attempt is its own event
|
|
626
|
+
reading 0, 1, 2, so a call that took three goes no longer reads as three
|
|
627
|
+
unrelated slow requests. The query text and the variables are still
|
|
628
|
+
deliberately absent, and now pinned by a spec: `filter_parameters` governs
|
|
629
|
+
the log, which GraphWeaver writes itself, but the payload fans out to
|
|
630
|
+
subscribers that know none of those rules.
|
|
631
|
+
|
|
632
|
+
`:graph` is the Symbol a module was generated under — `nil` for one that
|
|
633
|
+
names none and for a client called directly — so a multi-graph app groups a
|
|
634
|
+
dashboard by graph without parsing operation names. It covers **one
|
|
635
|
+
request**: a generated `execute` labels the request it makes, each one when a
|
|
636
|
+
federated operation fans out, and never what a *server* does while answering,
|
|
637
|
+
so an in-process resolver that calls another API produces an event carrying
|
|
638
|
+
its own graph or none, rather than its caller's. It is read from what codegen
|
|
639
|
+
baked in, never inferred from the client — a wrong graph on a request is
|
|
640
|
+
worse than no graph. Bracketing a request this way is why a generated
|
|
641
|
+
`execute` is now `from_response(dispatch(variables, client:))`:
|
|
642
|
+
`GraphWeaver::QueryModule` owns the call, reading the module's own
|
|
643
|
+
`QUERY`/`OPERATION_NAME`, so nothing about it lands in every generated file.
|
|
644
|
+
Behavior, sigs and the `Response` envelope are identical.
|
|
645
|
+
- **A bare graphql-ruby schema class in a client slot is instrumented like
|
|
646
|
+
every other client.** `GraphWeaver.client = MyApp::Schema` and
|
|
647
|
+
`execute!(client: MyApp::Schema)` ran with no seam at all — no APM event, no
|
|
648
|
+
log line, not even at debug — while `GraphWeaver.new(MyApp::Schema)` around
|
|
649
|
+
the same class had both. So the in-process half of an app was invisible on
|
|
650
|
+
the dashboard, and turning the logger up showed it as a graph that never ran.
|
|
651
|
+
A schema class is now put through the same `InProcess` wrapper
|
|
652
|
+
`GraphWeaver.new(Schema)` builds, wherever a client is read — a graph's
|
|
653
|
+
`client "Billing::Schema"` included. ([logging](docs/logging.md))
|
|
654
|
+
- **An in-process client names an anonymous schema `"anonymous"`.** The
|
|
655
|
+
instrumentation payload's `:schema` was the schema's `#to_s`, which for one
|
|
656
|
+
built from SDL is its object address — a new value every boot, so an APM
|
|
657
|
+
grouping by it got a series per process rather than one per schema. A schema
|
|
658
|
+
class is still named by its constant, in the payload, the debug log and
|
|
659
|
+
`#inspect`. ([logging](docs/logging.md#the-payload))
|
|
660
|
+
- **Turning on debug logging no longer changes what happens.** The debug line
|
|
661
|
+
rendered the variables as JSON itself, *before* the guarded encode — so a
|
|
662
|
+
value with no JSON form (`NaN`, binary) raised a raw `JSON::GeneratorError`
|
|
663
|
+
past `rescue GraphWeaver::Error` whenever a logger was listening at debug,
|
|
664
|
+
and in-process it turned a query that ran into a `ServerError`. The line now
|
|
665
|
+
says `<unloggable: JSON::GeneratorError>` and the request carries on to the
|
|
666
|
+
same outcome it has with no logger set.
|
|
667
|
+
- **A non-federated app no longer logs a phantom "no routing table here"
|
|
668
|
+
warning.** `GraphWeaver::Error` writes a warn line as it is *constructed*,
|
|
669
|
+
and "is this source a composed supergraph?" was asked by calling
|
|
670
|
+
`SchemaLoader.routing_table` and rescuing its refusal — so every app whose
|
|
671
|
+
schema is an ordinary one warned, once per process, about a routing table it
|
|
672
|
+
had never asked for. The question has a predicate now,
|
|
673
|
+
`SchemaLoader.routing_table?`, and nothing builds an error to answer "no".
|
|
674
|
+
- **An app can have more than one schema.** `GraphWeaver.graph` declares one.
|
|
675
|
+
Everything a graph knows is said inside its block, in call style — six
|
|
676
|
+
settings and the three registrations you already write at the top level:
|
|
677
|
+
|
|
678
|
+
```ruby
|
|
679
|
+
GraphWeaver.graph :billing do
|
|
680
|
+
schema -> { Billing::Schema }
|
|
681
|
+
queries "app/graphql/billing/queries"
|
|
682
|
+
output "app/graphql/billing/generated"
|
|
683
|
+
client "Billing::Schema"
|
|
684
|
+
namespace "Billing"
|
|
685
|
+
register_scalar "Money", BigDecimal
|
|
686
|
+
end
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
`schema "x"` sets and a bare `schema` reads back; there is no `schema = "x"`
|
|
690
|
+
form, since the block is `instance_eval`'d and that would be a local variable
|
|
691
|
+
that silently does nothing — graphql-ruby's `field :name` convention. Anything
|
|
692
|
+
else the block calls is refused, naming the nine it takes, as is a graph name
|
|
693
|
+
that isn't a Symbol or a String. `client`, `namespace` and `types_module` each
|
|
694
|
+
take the constant or its name, since generated source spells it either way; a
|
|
695
|
+
setting a graph doesn't say falls back to the top-level one.
|
|
696
|
+
|
|
697
|
+
`generate!`, `verify_generated!`, `check_queries`, `load_generated!`,
|
|
698
|
+
`reload_generated!`, the rake tasks and watch mode all walk every graph, so
|
|
699
|
+
one `rake graph_weaver:generate` does the app and one `rake
|
|
700
|
+
graph_weaver:verify` gates it. `rake graph_weaver:graphs` lists what is
|
|
701
|
+
configured — settings, registrations, and the client each graph calls
|
|
702
|
+
(`rake -T` can't: a task description is baked before `:environment`). The
|
|
703
|
+
hand-rolled recipe this replaces — N `generate!` calls with
|
|
704
|
+
`reset_registrations!` between them — is gone from
|
|
705
|
+
[docs/federation.md](docs/federation.md).
|
|
706
|
+
|
|
707
|
+
**In Rails, declare graphs in the initializer and name an autoloaded schema
|
|
708
|
+
class with a lambda** — `schema -> { Billing::Schema }`. Zeitwerk is set up
|
|
709
|
+
after `config/initializers` run, so a bare constant there raises; the lambda
|
|
710
|
+
resolves when generation asks, and again after a dev reload has replaced the
|
|
711
|
+
class object. The block runs where you write it, registrations included, so a
|
|
712
|
+
registration naming one of your own constants is in the position a top-level
|
|
713
|
+
one is and has the same answer: declare that graph from a `to_prepare` block,
|
|
714
|
+
which is safe to re-run (the graph's name is its identity) and whose query
|
|
715
|
+
directories still reach the development file watcher, so an edit to its
|
|
716
|
+
`.graphql` regenerates before the next request.
|
|
717
|
+
|
|
718
|
+
**Nothing changes for a single-schema app**: the top-level settings *are* the
|
|
719
|
+
default graph, and top-level registrations still reach every graph, so a
|
|
720
|
+
`register_scalar` in an initializer can't be dropped by declaring a second
|
|
721
|
+
schema. `namespace` nests everything a graph generates, including its shared
|
|
722
|
+
types module (`Billing::GraphQLTypes`); without one, two files that generate
|
|
723
|
+
the same module refuse as they always have, and the message now names the
|
|
724
|
+
graphs and the fix.
|
|
725
|
+
- **Generation refuses a directory no declared graph covers.** Declaring a
|
|
726
|
+
graph replaces the implicit one the top-level settings describe, so an app
|
|
727
|
+
that declares one beside its existing `app/graphql/queries` would otherwise be
|
|
728
|
+
left with a directory nothing reads — `generate` skipping its files, `verify`
|
|
729
|
+
calling the tree up to date, `queries:check` passing a query containing
|
|
730
|
+
`nmae`, and the modules generated from it before the graph existed still
|
|
731
|
+
loading in production. `generate!`, `verify_generated!` and `check_queries`
|
|
732
|
+
refuse instead, naming the stray files and what each declared graph does
|
|
733
|
+
cover — and spelling the missing graph out in those paths, since "under
|
|
734
|
+
directories no declared graph covers" describes the reader's own settings
|
|
735
|
+
back at them. It says why that graph takes no `schema` or `namespace`: it
|
|
736
|
+
keeps reading the schema `schema_path` names, and every constant keeps its
|
|
737
|
+
name. The fix is to name the directory in a graph (`queries`/`output`),
|
|
738
|
+
declare a graph for it, or delete it; an app that declared no graph is
|
|
739
|
+
unaffected, its settings *being* its graph.
|
|
740
|
+
[getting started](docs/getting_started.md#more-than-one-schema) says it too.
|
|
741
|
+
- **Every test mode decides what to run against per graph, and the helpers say
|
|
742
|
+
which graph they mean.** With more than one schema the honest answer varies
|
|
743
|
+
per module: `graphql: :fake` for a billing module fabricates billing's shapes
|
|
744
|
+
with billing's own scalar registrations, where it used to refuse to pick a
|
|
745
|
+
schema for the suite. `graphql_fake`, `graphql_in_process` and
|
|
746
|
+
`graphql_router` are the stand-in for the modules of **one** graph, and
|
|
747
|
+
**`graph:` names it** — `graphql_fake(graph: :poke, "pokemon_v2_pokemon.name"
|
|
748
|
+
=> "pikachu")`, `graphql_in_process(graph: :catalog)`, `graphql_router(graph:
|
|
749
|
+
:storefront, fake: …)`. The only way to say which used to be `schema:`,
|
|
750
|
+
matched by the identity of the object the graph's `schema` setting holds,
|
|
751
|
+
which only a graph running a live class in-process has: an app that is a pure
|
|
752
|
+
client of two remote APIs has none, since `SchemaLoader` builds a fresh
|
|
753
|
+
anonymous class per load, so the client, the declared dump path and the
|
|
754
|
+
graph's own name were all refused with `names none of this app's graphs`.
|
|
755
|
+
`graph:` takes the handle a graph has everywhere else, printed by `rake
|
|
756
|
+
graph_weaver:graphs` and baked into every module's `GRAPH`. `schema:` still
|
|
757
|
+
serves the single-graph override it always did, every refusal now leads with
|
|
758
|
+
`graph:`, and `graphql_router` — which had no way at all to say whose `fake:`
|
|
759
|
+
it was holding, and so could only refuse — gains one. They still refuse,
|
|
760
|
+
naming your graphs, when there is none they could reach.
|
|
761
|
+
|
|
762
|
+
**A helper sets the stand-in for the graph it names; the tag sets the mode
|
|
763
|
+
for every graph no helper named.** `graphql_router(graph: :store)` followed
|
|
764
|
+
by `graphql_fake(graph: :countries)` used to collapse to whichever was named
|
|
765
|
+
last — a helper reinstalled the example's one mode, which cleared the
|
|
766
|
+
per-graph stand-in table, so the first helper's graph was silently re-derived
|
|
767
|
+
under the second's mode: the plain graph raised `:router` plans across a
|
|
768
|
+
supergraph, or the federated one answered with fabricated data. So `graphql:
|
|
769
|
+
:router` plus `graphql_fake(graph: :countries)` reads as what it means, and
|
|
770
|
+
is no longer refused as contradicting the tag — a helper naming one graph of
|
|
771
|
+
several isn't speaking for the example. A helper that *does* speak for the
|
|
772
|
+
whole example (one graph, or no `graph:`/schema to narrow it) still can't
|
|
773
|
+
contradict its tag.
|
|
774
|
+
|
|
775
|
+
`:router` asks each graph where its own supergraph is: the one that graph
|
|
776
|
+
names, else `config.router[:supergraph]`, else the committed dump when it
|
|
777
|
+
carries `@join__*`. So an app that had already said where its supergraph is
|
|
778
|
+
doesn't repeat it in `config.router`, and a module is never planned against
|
|
779
|
+
**another graph's** — which used to fail blaming a stale dump ("schema may
|
|
780
|
+
have changed since generation"). A graph in no supergraph is refused by name
|
|
781
|
+
and pointed at `graphql: :in_process`; two graphs naming one supergraph share
|
|
782
|
+
one router, parsed once.
|
|
783
|
+
|
|
784
|
+
Generated modules carry a private `GRAPH` naming the graph they were generated
|
|
785
|
+
from, so **regenerate** — a multi-graph app whose modules predate this says so
|
|
786
|
+
and refuses rather than guessing. `FakeClient.new` takes `registry:` for a
|
|
787
|
+
caller that holds the graph, since a graph whose `schema` is a file can't be
|
|
788
|
+
matched back off the schema object. (The per-graph registry behind all of
|
|
789
|
+
this, `Codegen::Registry`, is internal, like the rest of codegen's IR.)
|
|
790
|
+
- **`graphql: :wire` runs a spec against your own transport.** The other tags
|
|
791
|
+
sit *in* the client slot, so the transport an app ships — APM tracing, a
|
|
792
|
+
caller tag, mTLS — never ran in a spec. `:wire` leaves `GraphWeaver.client`
|
|
793
|
+
where it is and serves your resolvers at the endpoint it posts to: the request
|
|
794
|
+
is serialized, posted through your middleware, and deserialized by `from_h`
|
|
795
|
+
over the server's own bytes. What sits behind each endpoint is what that
|
|
796
|
+
graph *is* — decided **per graph**, in descending faithfulness: its router
|
|
797
|
+
when it is in a composed supergraph, its live schema class when it has one,
|
|
798
|
+
else **a fake of its schema**. So the commonest shape there is, and the one
|
|
799
|
+
the README's Countries and GitHub examples are — a pure client of someone
|
|
800
|
+
else's API, with a committed dump and no resolvers to run — needs no
|
|
801
|
+
hand-written `GraphQL::Schema` shaped like your own query. One endpoint is
|
|
802
|
+
served per graph: the client that graph bakes into its modules with
|
|
803
|
+
`client:`, or `GraphWeaver.client` for a graph that bakes none — so an app
|
|
804
|
+
whose graphs all bake one needs no app default at all. A graph whose baked
|
|
805
|
+
client posts nowhere is refused by name rather than its requests leaving the
|
|
806
|
+
suite, and a graph with no schema at all is refused in `:wire`'s own voice,
|
|
807
|
+
naming the three places one can come from. The one candidate the other tags
|
|
808
|
+
fall back to and this one can't is `GraphWeaver.client`'s own schema —
|
|
809
|
+
reading it introspects the very endpoint `:wire` has stubbed.
|
|
810
|
+
|
|
811
|
+
**The pick is said out loud**, because it was invisible from inside the
|
|
812
|
+
example: an app whose client is a url transport and whose `Shop::Schema` no
|
|
813
|
+
one named got a fake, so its suite went green against fabricated data with
|
|
814
|
+
nothing said. One line per endpoint goes to `GraphWeaver.logger` (a Rails app
|
|
815
|
+
already has one) — `:wire serving Shop::Schema (in-process) at https://…` at
|
|
816
|
+
`info`, and at `warn`, naming the class and `config.schema`, when a fake stood
|
|
817
|
+
in while this process has a `GraphQL::Schema` class nothing pointed at. A
|
|
818
|
+
warning rather than a refusal, because a loaded class isn't proof you meant it
|
|
819
|
+
at *this* endpoint (a federated suite loads every subgraph's) and a fake
|
|
820
|
+
behind the wire is a thing to want: name the class for the suite, and call
|
|
821
|
+
`graphql_fake` in the examples that want fabricated data. Nothing else is
|
|
822
|
+
logged from a `:wire` example any more — the predicates deciding what to serve
|
|
823
|
+
raised-and-rescued to answer "no", and every `GraphWeaver::Error` writes a
|
|
824
|
+
`warn` line as it is built, so each example used to log two refusals that
|
|
825
|
+
never happened.
|
|
826
|
+
|
|
827
|
+
**A `graphql_*` helper says what goes behind the wire.** Under the other tags
|
|
828
|
+
a helper takes the client slot; under `:wire` it is served instead — the slot
|
|
829
|
+
has to keep your own client for the transport to run at all — so
|
|
830
|
+
`graphql_fake("Reader.orders" => [{ "status" => "PAID" }, {}])` pins one
|
|
831
|
+
`:wire` example's data exactly as it pins a `:fake` one's, and
|
|
832
|
+
`graphql_router(fake: …)` says per example what `config.router = { fake: … }`
|
|
833
|
+
says for the suite. A `:wire` example over a fake proves your transport — the
|
|
834
|
+
request your middleware wrote, the headers it sent, `from_h` reading real
|
|
835
|
+
JSON off a socket — but not that your `cast:` agrees with the real server,
|
|
836
|
+
since the fabricated bytes are written to match your own scalar
|
|
837
|
+
registrations.
|
|
838
|
+
|
|
839
|
+
It needs [webmock](https://github.com/bblimke/webmock) **enabled** — `require
|
|
840
|
+
"webmock/rspec"` in the spec helper — which hooks Net::HTTP, Faraday and
|
|
841
|
+
HTTPX, so every bundled transport runs unchanged. Having it in the Gemfile is
|
|
842
|
+
not enough: `Bundler.require` makes it *loaded* without installing the
|
|
843
|
+
adapters, so `:wire` checks before serving and names the line to add rather
|
|
844
|
+
than letting the first request leave the suite for the real endpoint. It
|
|
845
|
+
needs **rack** too — webmock's `to_rack` builds the Rack env with it and
|
|
846
|
+
doesn't depend on it — and the refusal names both in one sentence rather than
|
|
847
|
+
letting you find them one `bundle install` at a time.
|
|
848
|
+
`GraphWeaver::Testing::Endpoint` is the ordinary Rack app behind it, mountable
|
|
849
|
+
anywhere for anyone who'd rather have a real socket. See
|
|
850
|
+
[testing](docs/testing.md#over-the-wire--graphql-wire), which also covers
|
|
851
|
+
[making the served endpoint fail](docs/testing.md#making-the-served-endpoint-fail):
|
|
852
|
+
`:wire` adds one stub per endpoint and webmock answers with the last one
|
|
853
|
+
declared, so a `stub_request(…).to_return(status: 503)` in the example is a
|
|
854
|
+
**served** failure — your transport reads the status and `Retry-After` off a
|
|
855
|
+
real response and spends its real retry budget, which a `Failure` client
|
|
856
|
+
(which raises above the wire) can't reach.
|
|
857
|
+
- **A `context:` can be a proc**, called per request with the headers as sent —
|
|
858
|
+
the identity seam nothing above the wire could reach:
|
|
859
|
+
|
|
860
|
+
```ruby
|
|
861
|
+
config.context = ->(headers) { { current_user: User.find_by(token: headers["Authorization"]) } }
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
A hash still works, and is still the baseline `graphql_context` merges onto.
|
|
865
|
+
- **`config.context`, `config.schema` and `config.router` are refused once
|
|
866
|
+
they're too late**, under one rule: **configure at load, or in an `around` —
|
|
867
|
+
never in a plain `before`.** The tag builds (and under `:wire` serves) an
|
|
868
|
+
example's clients in a `before` hook of its own, which rspec runs ahead of any
|
|
869
|
+
group `before`, so a set there changed nothing and the example passed against
|
|
870
|
+
whatever the tag had already picked — a `config.context` that silently never
|
|
871
|
+
reached a resolver, a `config.schema` the fake never saw. The refusal fires
|
|
872
|
+
exactly when the clients are already built and names the `around` and the
|
|
873
|
+
per-example helper (`graphql_context`, `graphql_fake(schema:)`,
|
|
874
|
+
`graphql_router(fake:)`). Move any such `before` to an `around`, or to
|
|
875
|
+
`Testing.configure` in the spec helper.
|
|
876
|
+
- **The testing fakes refuse rather than silently do nothing.**
|
|
877
|
+
`fail_at: "pets.0.name"` matched nothing at all — indices were stripped
|
|
878
|
+
before comparing — so a spec named a list element, injected no failure, and
|
|
879
|
+
passed. An index is a path segment like any other now: state the ones you
|
|
880
|
+
mean and the rest match any position, so the plain `"pets.name"` still fails
|
|
881
|
+
the first element the walk reaches; a path that starts with an index, or
|
|
882
|
+
isn't a String, is refused. `Failure.graphql("boom", extensions: {…})` put
|
|
883
|
+
the extensions on the **response**, so the error carried none, and `code:`
|
|
884
|
+
was a bare "unknown keyword" — the keywords beside a message now shape that
|
|
885
|
+
error (`code:`, `extensions:`, `path:`, `locations:`), the hash form still
|
|
886
|
+
holds several, and anything else is refused by name. The response's own
|
|
887
|
+
`extensions:` went with them; `FakeClient` is where a whole response is
|
|
888
|
+
shaped. And **`Failure.timeout`** joins `.transport`/`.server`: a
|
|
889
|
+
`TransportError` whose `#cause` is net/http's `Net::ReadTimeout`, so a spec
|
|
890
|
+
says "it timed out" without naming net/http's classes.
|
|
891
|
+
- **A pin is told from an option by a lookup, not by casing.** The rule was "a
|
|
892
|
+
dot or a leading capital is a pin", so a lowercase type could not be pinned at
|
|
893
|
+
all: `graphql_fake("pokemon_v2_pokemon" => …)` against a Hasura API came back
|
|
894
|
+
as `a fake doesn't take pokemon_v2_pokemon:` and the pin was silently gone.
|
|
895
|
+
Now a key the fake takes is an option, a key **your schema** knows — a type,
|
|
896
|
+
enum, scalar, `Type.field` coordinate, or a field name — is a pin, and a key
|
|
897
|
+
that is neither is refused naming both and guessing across both. A key written
|
|
898
|
+
in the leading positional hash is only ever a pin, which is the spelling for a
|
|
899
|
+
schema whose own vocabulary collides with an option name. A keyword that is a
|
|
900
|
+
near-miss for a pin (`Persn: "Ada"`) now raises `ArgumentError` from the fake
|
|
901
|
+
rather than `GraphWeaver::Error` from the override check; the same key in the
|
|
902
|
+
leading hash is unchanged. And a **scalar** pin written as the Ruby object —
|
|
903
|
+
`overrides: { "Money" => Money.parse("12.00") }` — is serialized the way the
|
|
904
|
+
registration says, rather than reaching `from_h` as a `Money` and failing
|
|
905
|
+
there; an object pin's fields already followed that rule. Where the
|
|
906
|
+
registration can't serialize a value (a `serialize:` Proc builds source), the
|
|
907
|
+
fake refuses naming the scalar instead of letting the cast fail two layers
|
|
908
|
+
down.
|
|
909
|
+
- **`list_size:` also takes a Hash, saying how long ONE list is.** Every list
|
|
910
|
+
the fake reaches read the same setting, so nested unbounded lists multiply:
|
|
911
|
+
`rows { owner { … } tags }` at `list_size: 1600` fabricates 1600 rows and
|
|
912
|
+
1600 tags in each of them, and per-row allocations double with every doubling
|
|
913
|
+
of the number (1,064 → 12,981 objects per row from 100 → 1600). Key the Hash
|
|
914
|
+
the way pins are keyed — a `"Type.field"` coordinate or a bare field name,
|
|
915
|
+
with `default:` for the rest — and the named list stays flat (205 objects per
|
|
916
|
+
row across the same sweep): `{ "Row.tags" => 3, default: 1000 }`. Integer and
|
|
917
|
+
Range mean exactly what they did; a key the schema doesn't know is refused
|
|
918
|
+
with a spellcheck, at the fake's door and at `Testing.configure`. Works the
|
|
919
|
+
same on `config.list_size`, `graphql_fake(list_size:)` and a router's `fake:`.
|
|
920
|
+
- **A test-time schema memo notices the file underneath it changing.**
|
|
921
|
+
`GraphWeaver::Testing.config.schema` keys its located dump on the resolved
|
|
922
|
+
path, so `GraphWeaver.schema_path=` and `root=` are no longer invisible to
|
|
923
|
+
`:fake` — only `Testing.reset!` used to clear it, which made the fabricated
|
|
924
|
+
shapes order-dependent across spec files. It still loads once while the file
|
|
925
|
+
stands still.
|
|
926
|
+
- **A `graphql:` tag now reaches a module generated with `client:`.** The baked
|
|
927
|
+
`DEFAULT_CLIENT` sits above `GraphWeaver.client`, which is the slot a tag
|
|
928
|
+
swapped, so `it "…", graphql: :fake` ran a bound module against its real
|
|
929
|
+
endpoint. The mode now stands in for that constant too — a per-call `client:`
|
|
930
|
+
and `MyQuery.client =` still win, and `:wire` still leaves every client where
|
|
931
|
+
it is. **If a spec relied on a bound module ignoring the tag, it now runs
|
|
932
|
+
against the fake.**
|
|
933
|
+
- **`@oneOf` was never enforced on any schema read from an introspection dump.**
|
|
934
|
+
Codegen emits `ONE_OF` from `one_of?` and `InputStruct#one_of!` is the only
|
|
935
|
+
thing that refuses two fields being set — but graphql-ruby's introspection
|
|
936
|
+
query omits `isOneOf` unless asked, and its loader drops the field even when
|
|
937
|
+
it is present, so every dump this gem has ever written said "not @oneOf" for
|
|
938
|
+
every input object and the enforcing struct was never generated. Both halves
|
|
939
|
+
are fixed, and a schema introspected now and one loaded back off the file that
|
|
940
|
+
writes are the same schema. Schemas built from SDL (`.graphql`/`.gql` dumps,
|
|
941
|
+
inline SDL, a live class) were always correct. A server too old to define
|
|
942
|
+
`isOneOf` *refuses* the query rather than answering null — PokeAPI's Hasura
|
|
943
|
+
does — so introspection asks for it and asks the baseline query again rather
|
|
944
|
+
than fail: one extra round trip, only where the first answer was going to be
|
|
945
|
+
an error either way.
|
|
946
|
+
- **`schema:refresh` and `schema:diff` work for a schema you serve yourself.**
|
|
947
|
+
Both refused unconditionally for a dump built from a graphql-ruby class
|
|
948
|
+
("records no source url"), so the documented CI chain — `schema:diff &&
|
|
949
|
+
queries:check && verify && cassettes:check` — was permanently red for an
|
|
950
|
+
in-process app, and a runtime `QueryError` on a renamed field advised
|
|
951
|
+
`schema:refresh`, the one task guaranteed to refuse. One rule now, asked per
|
|
952
|
+
graph: **the dump is the contract generation reads; `refresh` rewrites it
|
|
953
|
+
from the graph's source, `diff` says how far that source has drifted from
|
|
954
|
+
it** — whichever the source is. A url is re-introspected as before; a live
|
|
955
|
+
schema class fills the same duck-typed slot, so it answers introspection
|
|
956
|
+
itself and nothing touches a network. Only a graph whose dump IS its source
|
|
957
|
+
still refuses, because there is nothing behind it to re-read. If you wired up
|
|
958
|
+
graphql-ruby's `GraphQL::RakeTask` to rebuild the dump for graph_weaver, you
|
|
959
|
+
can drop it: `schema:refresh` writes the same artifact, in whatever format
|
|
960
|
+
the dump already is, to the path graph_weaver reads. `schema:diff`'s verdict
|
|
961
|
+
now names the source it compared against (`… matches Catalog::Schema`,
|
|
962
|
+
`… matches https://api.example.com/graphql`) rather than "the server".
|
|
963
|
+
`SchemaLoader.refresh!` takes `schema:` and `path:` for the same reason;
|
|
964
|
+
`SchemaLoader.endpoint` and `Graph#dump_source` are the two new public names.
|
|
965
|
+
|
|
966
|
+
**`schema:refresh` also writes a graph's first dump.** Nothing wrote the
|
|
967
|
+
second one: the installer is single-graph, `URL=` names one endpoint for an
|
|
968
|
+
app that has one dump per graph, and `real_world.md`'s `GraphWeaver.new(url,
|
|
969
|
+
cache: true).schema` recipe writes the conventional `schema_path`. A graph
|
|
970
|
+
naming a dump that isn't there yet is introspected from the url its own
|
|
971
|
+
client posts to, and the dump records that url as its source like any other —
|
|
972
|
+
so two plain remote APIs are: declare both graphs with `schema
|
|
973
|
+
"app/graphql/poke/schema.json"` and `client "POKE"`, run `schema:refresh`,
|
|
974
|
+
run `generate`. `schema:diff` stops reading a missing dump as "generates from
|
|
975
|
+
that url directly", which is what a graph naming a live schema class does,
|
|
976
|
+
and names the file and the task instead. `Graph#named_dump_path` and
|
|
977
|
+
`SchemaLoader.dump_path?` are the new public names; `Graph#dump_path` still
|
|
978
|
+
means "the dump that is there".
|
|
979
|
+
- **`schema:refresh` no longer overwrites a composed supergraph with the API
|
|
980
|
+
schema behind it.** Introspection returns the merged shape a router serves,
|
|
981
|
+
with the `@join__*` routing table gone — so `rake graph_weaver:schema:refresh
|
|
982
|
+
URL=<router>` replaced a 5.8 KB supergraph with 1.6 KB of API schema and
|
|
983
|
+
exited 0, after which `federation:diff` failed and `schema:diff`/`verify`
|
|
984
|
+
went green against the wrong artifact. Any write that would trade a
|
|
985
|
+
supergraph dump for a non-supergraph one is refused, naming `rover supergraph
|
|
986
|
+
compose`; `schema:refresh` with no `URL=` says the same thing to a federated
|
|
987
|
+
app instead of suggesting one.
|
|
988
|
+
- **A task that can't honour `SUPERGRAPH=` says so.** The flag reaches the
|
|
989
|
+
`federation:*` tasks, and `queries:check`, `verify`, `generate` and the rest
|
|
990
|
+
ignored it in silence — so `SUPERGRAPH=public.graphql rake
|
|
991
|
+
graph_weaver:queries:check` reported every query valid against a supergraph
|
|
992
|
+
missing a field they select. They refuse it instead; honouring it would
|
|
993
|
+
collapse a multi-graph app into one unnamed graph, which for `generate` means
|
|
994
|
+
pruning the other graphs' generated files. Declare the supergraph on a graph
|
|
995
|
+
to check against it. The reasoning is in [DECISIONS.md](DECISIONS.md).
|
|
996
|
+
- **`verify` fails when the dump has fallen behind the schema class it was
|
|
997
|
+
built from.** Its question is "is what's checked in current", and the dump is
|
|
998
|
+
checked in too — for an app that serves its own schema it is an artifact
|
|
999
|
+
derived from code in the same repo, and everything downstream reads it, so
|
|
1000
|
+
`generate` and `verify` both called a tree up to date while the live
|
|
1001
|
+
resolvers had already moved. A CI gate that only reads exit codes saw
|
|
1002
|
+
nothing. A dump you deliberately keep behind your own schema is a `verify`
|
|
1003
|
+
failure now — refresh it, or name the schema explicitly
|
|
1004
|
+
(`verify_generated!(schema:)`), which asks about no dump at all. The check
|
|
1005
|
+
costs one in-process introspection per graph and never a network call: a
|
|
1006
|
+
dump that records a url stays `schema:diff`'s subject.
|
|
1007
|
+
- **A cassette holds one entry per request key, and the docs now say so.** The
|
|
1008
|
+
key is the query, its variables and the operation name together; re-recording
|
|
1009
|
+
a request replaces its entry and a request the file hasn't seen appends one,
|
|
1010
|
+
so `GRAPHWEAVER_RECORD=1` never leaves two entries for one request however
|
|
1011
|
+
often it runs. Unchanged behavior, newly stated and specced: editing a query
|
|
1012
|
+
changes the key, so the old entry stays behind as a recording nothing sends —
|
|
1013
|
+
what `cassettes:check` counts as "not sent by any query module", and what
|
|
1014
|
+
deleting the cassette and recording afresh clears. **`cassettes:check`'s
|
|
1015
|
+
stale advice names both causes** too: the generated structs move when a
|
|
1016
|
+
*registration* moves, not only when the schema dump does, and re-recording
|
|
1017
|
+
doesn't fix that half — so regenerating is offered for it by name.
|
|
1018
|
+
([cassettes](docs/cassettes.md))
|
|
1019
|
+
- **An `@interfaceObject` no longer breaks the subgraph its interface's *other*
|
|
1020
|
+
implementers live in.** Apollo writes a bare `@join__field` — no `graph:` at
|
|
1021
|
+
all — on a concrete implementer's copy of a field really contributed through
|
|
1022
|
+
`@interfaceObject` elsewhere, and the routing table read that the way it reads
|
|
1023
|
+
no directive at all: "wherever the type lives". So `products` was said to
|
|
1024
|
+
resolve `Bundle.reviews`, which `Products::Schema` never defines — pinning
|
|
1025
|
+
`subgraphs: { "products" => Products::Schema }` raised `ConfigurationError` at
|
|
1026
|
+
`Router.new`, and under auto-detection `products` was reported absent and an
|
|
1027
|
+
unrelated `{ products { name price } }` refused `absent_subgraph`, advice
|
|
1028
|
+
about eager loading and all. The rule is now one sentence: **a field routes to
|
|
1029
|
+
exactly the subgraphs its `@join__field` names**, and only a field with *no*
|
|
1030
|
+
`@join__field` lives wherever its type does. Selecting the implementer's copy
|
|
1031
|
+
directly is refused `no_owner` ("the supergraph places `Bundle.reviews` in no
|
|
1032
|
+
subgraph") instead of being fetched from a subgraph that would not answer it.
|
|
1033
|
+
- **A `@requires` field and a plain one crossing into the same subgraph now ride
|
|
1034
|
+
one `_entities` call**, as Apollo's do, instead of two — they are split only
|
|
1035
|
+
when a prefetch didn't answer for some node, which is the one case their node
|
|
1036
|
+
sets differ. A dashboard query drops from 6 fetches to 5.
|
|
1037
|
+
- **A subgraph SDL that renames what it imports now loads.**
|
|
1038
|
+
`@link(import: [{name: "@key", as: "@primaryKey"}])` — the `@link` spec's own
|
|
1039
|
+
aliasing — raised an error blaming the file for a directive the file had
|
|
1040
|
+
declared. The `@link` header is read for every spelling it binds and then
|
|
1041
|
+
dropped from the schema, so `link__Import`/`link__Purpose` no longer appear in
|
|
1042
|
+
a schema loaded from subgraph SDL.
|
|
1043
|
+
- **Two more queries the local router refuses instead of answering them
|
|
1044
|
+
wrong.** A **`@fromContext` argument** was refused only from the
|
|
1045
|
+
crossing-aware half of the planner, so a query one subgraph answered whole
|
|
1046
|
+
skipped the check — and only a gateway injects a `@fromContext` value, so the
|
|
1047
|
+
resolver ran with the argument unset and the router returned a plausible
|
|
1048
|
+
wrong number. And a **progressive `@override(label:)`** wasn't refused at
|
|
1049
|
+
all: Federation 2.7 leaves both subgraphs resolving the field and lets the
|
|
1050
|
+
gateway split traffic by the label, where the router picked one and answered
|
|
1051
|
+
from it every time, silently and forever (new category
|
|
1052
|
+
`:progressive_override`; drop the label and composition drops the losing
|
|
1053
|
+
copy, which plans normally). Every refusal that must hold for a query however
|
|
1054
|
+
it is planned now runs above the single-subgraph shortcut, which is the one
|
|
1055
|
+
walk every plan passes through — see [DECISIONS.md](DECISIONS.md).
|
|
1056
|
+
- **`@defer`/`@stream` are refused by name** (`:incremental_delivery`) rather
|
|
1057
|
+
than by happening to fail validation — the answer would arrive in more than
|
|
1058
|
+
one payload, and the Apollo Router supports `@defer` for real. The scan runs
|
|
1059
|
+
above validation in both places that refuse, so the reason you get is this
|
|
1060
|
+
one and not graphql-ruby's "Directive @defer is not defined":
|
|
1061
|
+
`Testing::Router#execute` used to validate before it planned, and `rake
|
|
1062
|
+
graph_weaver:generate` had no check at all — it refused only while no schema
|
|
1063
|
+
declared the directive, and would have generated a module that dropped the
|
|
1064
|
+
deferred selections against a supergraph that did.
|
|
1065
|
+
- **A subgraph error out of `Testing::Router` carries `extensions: {"service"
|
|
1066
|
+
=> "<subgraph>"}`.** Every real transport stamps which subgraph failed and a
|
|
1067
|
+
client branches on it, so a test asserting on an unstamped error passed here
|
|
1068
|
+
and broke in front of a gateway. The Apollo Router's spelling; a resolver's
|
|
1069
|
+
own `extensions` are left alone. **Update any spec that compares a router
|
|
1070
|
+
error hash whole** — and note that a production router with
|
|
1071
|
+
`include_subgraph_errors` omitted redacts the stamp along with the message
|
|
1072
|
+
([testing](docs/testing.md)).
|
|
1073
|
+
- **`rake graph_weaver:federation:diff` sees three things it used to miss.**
|
|
1074
|
+
It no longer **calls an absent subgraph stale**: a schema was recognized by
|
|
1075
|
+
the types its subgraph declares, and two subgraphs extending one entity
|
|
1076
|
+
declare the same one — so a `prefs` running in another process was matched to
|
|
1077
|
+
its neighbour `accounts`, every field only `prefs` resolves was reported
|
|
1078
|
+
stale, and the gate failed red advising a recompose that would change
|
|
1079
|
+
nothing. A schema now also has to define something the supergraph attributes
|
|
1080
|
+
to that subgraph **alone**; one that shares everything it declares is "not
|
|
1081
|
+
here", which [docs/federation.md](docs/federation.md) already promised doesn't
|
|
1082
|
+
fail the task. `#skipped` (and the `not checked` section) now names those
|
|
1083
|
+
coordinates rather than the types.
|
|
1084
|
+
|
|
1085
|
+
It **compares a field's type, not just its name**. The check was coordinate
|
|
1086
|
+
presence — `Warehouse.code` going from `String!` to `ID!` under an untouched
|
|
1087
|
+
supergraph reported "matches the schemas here", and CI passed on a
|
|
1088
|
+
composition describing a graph nobody serves. Both types are compared and a
|
|
1089
|
+
disagreement is its own drift kind, **shape**, beside stale and not composed
|
|
1090
|
+
in, with both signatures in the message: `Warehouse.code (inventory): String!
|
|
1091
|
+
in the supergraph, ID! here`. `Drift#to_h` grows a `"shape"` key and `#drift?`
|
|
1092
|
+
counts it, so a suite asserting on either sees the new kind.
|
|
1093
|
+
|
|
1094
|
+
And it **names a subgraph you retired from the composition but left loaded**.
|
|
1095
|
+
Every check there walks the supergraph's subgraph list, so a Ruby schema the
|
|
1096
|
+
composition no longer places sat on the only side nothing looked at —
|
|
1097
|
+
recompose without `reviews`, leave `Reviews::Schema` defining everything it
|
|
1098
|
+
always did, and the report said "matches the schemas here (checked 3 of 3
|
|
1099
|
+
subgraphs)", exit 0. It is named on stderr ("not placed — no subgraph of any
|
|
1100
|
+
supergraph read here is:"), as a **warning rather than drift**: a process that
|
|
1101
|
+
loads a subgraph of a supergraph this run never reads is the same picture, and
|
|
1102
|
+
nothing tells the two apart. `Drift#unplaced` is the list for one supergraph
|
|
1103
|
+
and `Drift#subgraphs` is the names it does have; the task asks every graph in
|
|
1104
|
+
the run, so a multi-graph app's second supergraph places its own.
|
|
1105
|
+
|
|
1106
|
+
The `federation:*` tasks also ask the declared graphs where the supergraph is.
|
|
1107
|
+
`federation:diff`, `:subgraphs` and `:coverage` looked only at the
|
|
1108
|
+
conventional dump, so an app that had written
|
|
1109
|
+
`GraphWeaver.graph(:accounts) { schema "…/supergraph.graphql" }` had to repeat
|
|
1110
|
+
it in `SUPERGRAPH=` — and without the flag got a refusal describing a file it
|
|
1111
|
+
never named. Each task now runs once per declared graph whose schema is a
|
|
1112
|
+
composed supergraph, heading each report with the graph's name, and
|
|
1113
|
+
`:coverage` measures that graph's own `queries` rather than
|
|
1114
|
+
`GraphWeaver.queries_paths`. `SUPERGRAPH=` still overrides for one run, and a
|
|
1115
|
+
single-schema app with a composed dump prints exactly what it did.
|
|
1116
|
+
- **Three fixes in `rails g graph_weaver:install`.** It **wires the rspec
|
|
1117
|
+
require where rspec reads it**: the setup told you to put `require
|
|
1118
|
+
"graph_weaver/rspec"` in `spec/support/graph_weaver.rb`, and rspec-rails ships
|
|
1119
|
+
the `spec/support` glob **commented out**, so for anyone who hadn't
|
|
1120
|
+
uncommented it the require never ran and a `graphql: :fake` example silently
|
|
1121
|
+
went to the real client. The line now goes in `spec/rails_helper.rb` (under
|
|
1122
|
+
rspec-rails' own `require "rspec/rails"`), or `spec/spec_helper.rb` if that's
|
|
1123
|
+
all there is, or is printed when the app has neither — idempotent, so a re-run
|
|
1124
|
+
and a line you added by hand both leave it alone. It **no longer wires auth
|
|
1125
|
+
you didn't ask for**: the initializer carried `auth: ENV["GRAPHWEAVER_AUTH"]`
|
|
1126
|
+
on every url install, so a public API's setup read an ENV var nobody had set,
|
|
1127
|
+
while the `--auth` docs said the flag was "omitted entirely for a public API
|
|
1128
|
+
that needs no token" — without `--auth` that line is now written commented,
|
|
1129
|
+
which is both the honest default and how you add a token later. And it
|
|
1130
|
+
**keeps a schema dump you already have**: the dump was the one file it didn't
|
|
1131
|
+
write through `create_file`, so Thor never prompted on it and declining every
|
|
1132
|
+
conflict on a re-run still replaced it — a re-run naming a *different*
|
|
1133
|
+
endpoint silently overwrote the old graph's dump along with the source url it
|
|
1134
|
+
records. An existing dump is left alone and named (with where it was
|
|
1135
|
+
introspected from, when that isn't the source just given); delete it and
|
|
1136
|
+
re-run to re-introspect, or `rake graph_weaver:schema:refresh` to re-fetch in
|
|
1137
|
+
place. All three describe what the *next* install writes — existing
|
|
1138
|
+
initializers are untouched.
|
|
1139
|
+
- **`rails g graph_weaver:install` adds each graph's output directory to
|
|
1140
|
+
`AllCops: Exclude:`** when the app already has a `.rubocop.yml`, so plain
|
|
1141
|
+
`rubocop` stops reporting `Style/Documentation`, `Style/ClassAndModuleChildren`
|
|
1142
|
+
and `Metrics/*` on generated files. The block it appends carries
|
|
1143
|
+
`inherit_mode: merge: [Exclude]`, because RuboCop *replaces* an `Exclude`
|
|
1144
|
+
array on merge rather than unioning it — without that line the append would
|
|
1145
|
+
wipe the app's effective excludes, RuboCop's own `vendor`, `node_modules` and
|
|
1146
|
+
`tmp` defaults included, along with anything reaching the file through
|
|
1147
|
+
`inherit_from:`. It never creates the file, and it leaves an `AllCops:` you
|
|
1148
|
+
already have alone — a second one would replace it rather than merge —
|
|
1149
|
+
printing the lines to add instead, as it does for a multi-document
|
|
1150
|
+
`.rubocop.yml`, where rubocop reads only the first document and an appended
|
|
1151
|
+
block would land where nothing reads it.
|
|
1152
|
+
- **New: `rake graph_weaver:unused`, the over-fetch the other checks can't see.**
|
|
1153
|
+
`verify` says the Ruby matches the query; this asks whether the query still
|
|
1154
|
+
asks for what the app *uses*. It reads the props each query's generated
|
|
1155
|
+
structs carry, sweeps your `.rb`/`.erb`/`.slim`/`.haml`/`.jbuilder` once for
|
|
1156
|
+
every form a prop could be read by (`.sku`, `sku:`, `:sku`, `"sku"`), and
|
|
1157
|
+
names what nothing reads — by query file, selection, and the coordinate the
|
|
1158
|
+
query spells (`Person.bornOn`, not the prop `born_on`). `.rake` and
|
|
1159
|
+
`.builder` are swept too. Nothing is edited and it exits 0; `STRICT=1` exits
|
|
1160
|
+
1 when anything is unread — `0`, `false`, `no`, `off` and empty are off, so
|
|
1161
|
+
the spelling a CI config uses to turn it off turns it off — and
|
|
1162
|
+
`PATHS=app,lib` narrows the sweep, refusing a path that isn't there rather
|
|
1163
|
+
than sweeping nothing and reporting every selection unread. A repo with no
|
|
1164
|
+
queries is told `no queries in <dir>`, the sentence its sibling `generate`
|
|
1165
|
+
already says, instead of being advised to generate nothing. A line handing a
|
|
1166
|
+
query module straight to `render json:` / `to_h` / `to_json` / `as_json` /
|
|
1167
|
+
`serialize` / `deconstruct_keys` reads every prop at once, so that module is
|
|
1168
|
+
excused and the line is quoted — matching a serializer by name is the softest
|
|
1169
|
+
thing here and a wrong excuse should be visible; the sweep follows the local
|
|
1170
|
+
a module was assigned to, so the ordinary two-line controller (`result =
|
|
1171
|
+
Q.execute!` then `render json: result.person`) is excused as well, and it
|
|
1172
|
+
says which local it followed. A graphql-ruby *type* file is skipped, in the
|
|
1173
|
+
`< Types::BaseObject` spelling graphql-ruby's own generator emits as well as
|
|
1174
|
+
the fully qualified one; a `Resolver` or `Mutation` is not, since a BFF reads
|
|
1175
|
+
the upstream graph exactly there. It matches names as text, which makes it a
|
|
1176
|
+
lint and not a proof — the task's own footer names what it is blind to, and
|
|
1177
|
+
carries the measured rate: on a real app half to two thirds of genuinely
|
|
1178
|
+
unread selections go unreported
|
|
1179
|
+
([getting started](docs/getting_started.md#the-selections-nothing-reads)).
|
|
1180
|
+
- **Three things are refused where they used to go wrong later.** A `client`
|
|
1181
|
+
that isn't a constant is refused at generation: the value is spelled into
|
|
1182
|
+
every generated module, so a `client` given the endpoint url emitted
|
|
1183
|
+
`-> { https://api.example.com/graphql }` — a file that doesn't parse — from a
|
|
1184
|
+
run that reported success, and the message now names the constant to declare
|
|
1185
|
+
instead. A `cast:` or `serialize:` **proc that returns a value** is refused at
|
|
1186
|
+
registration: a proc there builds *source* for the generated file, so
|
|
1187
|
+
`cast: ->(v) { v.to_sym }` interpolated to nothing and every response failed
|
|
1188
|
+
far from the registration, blaming the codec; it is probed once when
|
|
1189
|
+
registered, and a non-String return names the spelling to use. And a router's
|
|
1190
|
+
`fake:` refuses `seed:`, as `graphql_fake` and `graphql_router` already did —
|
|
1191
|
+
rspec's `--seed` drives the fake, and a router is built once for the suite, so
|
|
1192
|
+
a seed there would pin every example to one run.
|
|
1193
|
+
(`GraphWeaver::Testing.config.seed` remains the override for a harness that
|
|
1194
|
+
isn't rspec.)
|
|
1195
|
+
- **A generated output nested deeper than a `generated_paths` glob reaches is
|
|
1196
|
+
hidden from Zeitwerk again, and loaded.** `app/graphql/a/b/generated` was read
|
|
1197
|
+
as already covered by the default `app/graphql/*/generated` — `File.fnmatch?`
|
|
1198
|
+
lets `*` cross a `/`, and `Dir.glob`, which expands the same pattern
|
|
1199
|
+
everywhere else, does not. It was then neither ignored nor required:
|
|
1200
|
+
production boot died on a `Zeitwerk::NameError` naming a constant you never
|
|
1201
|
+
wrote, and development simply had the modules missing. Nothing to do — a
|
|
1202
|
+
layout like `app/graphql/subgraphs/billing/generated` now works.
|
|
1203
|
+
- **A symlinked generated output is hidden from Zeitwerk, and refused when it
|
|
1204
|
+
is declared too late to hide.** The railtie resolved a path with
|
|
1205
|
+
`File.expand_path`, which doesn't follow symlinks, while Zeitwerk walks real
|
|
1206
|
+
directories — so an `output` that was a symlink, or an absolute one through a
|
|
1207
|
+
symlinked ancestor (the Capistrano `current/` shape), was ignored under a name
|
|
1208
|
+
Zeitwerk never visits and its modules died on `uninitialized constant`. The
|
|
1209
|
+
refusal that exists for a too-late output compared the same unresolved path
|
|
1210
|
+
against real autoload roots, so it stayed silent for exactly that spelling; it
|
|
1211
|
+
now fires, and names the output the way your graph spells it.
|
|
1212
|
+
- **A graph declared from `config.to_prepare` whose `output` no loader can be
|
|
1213
|
+
told to skip is refused at boot**, naming the graph and the fixes. Rails sets
|
|
1214
|
+
Zeitwerk up before `to_prepare` runs and Zeitwerk reads its ignore list only
|
|
1215
|
+
then, so such a directory cannot be hidden from autoloading — it used to fail
|
|
1216
|
+
in every environment with a Zeitwerk error that blamed a dropped
|
|
1217
|
+
`extend_type`. The refusal asks Zeitwerk which directories a loader would
|
|
1218
|
+
really walk, so an app that hid the output itself with
|
|
1219
|
+
`Rails.autoloaders.main.ignore(...)` is left alone. An output under
|
|
1220
|
+
`config.autoload_once_paths` is refused at the point that knows, and names the
|
|
1221
|
+
two routes that work — `Rails.autoloaders.once.ignore(...)` from
|
|
1222
|
+
`config/application.rb`, or generating somewhere that is not an autoload-once
|
|
1223
|
+
path — since Rails sets the `once` autoloader up in bootstrap, where nothing
|
|
1224
|
+
an initializer does can reach it.
|
|
1225
|
+
- **`config.graph_weaver` refuses a key the railtie doesn't read.** It takes
|
|
1226
|
+
`watch`; `config.graph_weaver.queries_paths = ...` was taken silently and did
|
|
1227
|
+
nothing. The refusal names `GraphWeaver.queries_paths =` as the setting that
|
|
1228
|
+
works, and covers every spelling of that write —
|
|
1229
|
+
`config.graph_weaver[:queries_paths] = ...` and `store` went straight to
|
|
1230
|
+
`Hash#[]=`, which is the silent no-op the refusal exists to prevent.
|
|
1231
|
+
- **`load_generated!` tells a `Zeitwerk::NameError` apart from a dropped
|
|
1232
|
+
`extend_type`/`register_enum`.** The old message sent you hunting for a
|
|
1233
|
+
registration that was still there; the new one says Zeitwerk owns the
|
|
1234
|
+
directory and names the fix (`GraphWeaver.generated_paths`, or the
|
|
1235
|
+
conventional `app/graphql/*/generated`).
|
|
1236
|
+
- **`GraphWeaver.configure do |config| ... end`** — `GraphWeaver::Testing.configure`
|
|
1237
|
+
existed and the top level didn't. `config` is `GraphWeaver` itself, so the
|
|
1238
|
+
flat `GraphWeaver.client = ...` spelling is the same call and stays valid.
|
|
1239
|
+
- **Four smaller fixes.** `GraphWeaver::Transport::Faraday` resolves without a
|
|
1240
|
+
require — the constant is autoloaded, so the `Transport::Faraday.new(url) { |conn| … }`
|
|
1241
|
+
that [docs/transports.md](docs/transports.md) shows works where you'd write
|
|
1242
|
+
it, an initializer, instead of raising `NameError` at boot (still opt-in:
|
|
1243
|
+
naming it is what loads faraday). `rails g graph_weaver:install` writes the
|
|
1244
|
+
current scalar spelling, `register_scalar("Money", BigDecimal)`, rather than a
|
|
1245
|
+
three-keyword registration for a scalar 0.6.1 registers for you. The
|
|
1246
|
+
unregistered-scalar report prints where the task that found it prints: one of
|
|
1247
|
+
`generate`'s and `verify`'s two registry advisories went only to the logger,
|
|
1248
|
+
which in Rails is `log/development.log`, so nobody running the task saw it —
|
|
1249
|
+
`GraphWeaver.untyped_scalars` is the list, beside
|
|
1250
|
+
`GraphWeaver.unmatched_registrations`, and it is grouped by graph when a run
|
|
1251
|
+
covers more than one — merged flat it read as "forgotten everywhere" for a
|
|
1252
|
+
scalar registered for one graph and forgotten for the next, so
|
|
1253
|
+
`GraphWeaver.untyped_scalars_by_graph` is the same fact kept apart. And `rake graph_weaver:cassettes:check`
|
|
1254
|
+
sees a namespaced graph's modules: it looked for top-level constants, so an
|
|
1255
|
+
app whose graphs set `namespace` found "0 generated modules", refused for
|
|
1256
|
+
having checked nothing, and blamed the cassette directory.
|
|
1257
|
+
- Internal: `bin/round-trip --hostile` now spoils an **input** leaf as well as
|
|
1258
|
+
a response one — a wrong type, an unparseable string, a value the enum
|
|
1259
|
+
doesn't have, a nil where null is illegal, at any depth including inside
|
|
1260
|
+
lists — and requires an `InputError` whose `#path` names the corrupted slot
|
|
1261
|
+
and whose `#kind` says what was wrong. The 40,000 clean round trips before it
|
|
1262
|
+
were evidence about response decoding and close to none about input errors;
|
|
1263
|
+
run against the GitHub schema it found the missing list index in `#path` on
|
|
1264
|
+
its own. It also reads a renamed prop now: a field whose Ruby name took a
|
|
1265
|
+
trailing underscore used to make the harness compare `class` against
|
|
1266
|
+
`class_` and call every such round trip lossy, which hid whatever else that
|
|
1267
|
+
query would have caught — and it calls `execute` with the kwarg a variable
|
|
1268
|
+
really becomes, since it used `prop_name` and an argument named `hash`
|
|
1269
|
+
(Linear's `Query.comment(hash:)`) went out as `hash_:` for `execute` to
|
|
1270
|
+
refuse. The rename exists so a *prop* can't shadow a method its struct
|
|
1271
|
+
answers; an execute kwarg shadows nothing and keeps the schema's spelling.
|
|
1272
|
+
|
|
1273
|
+
The harness also **reads a scalar off the registry rather than off its name**.
|
|
1274
|
+
Sweeping 23 public schemas turned up two declaring `scalar Time` (GitLab's,
|
|
1275
|
+
universe's) and one declaring `DateTimeOrDuration` (Linear's), and both
|
|
1276
|
+
oracles mis-read them: `Time` is a row in the harness's own legal/illegal
|
|
1277
|
+
tables, keyed there by the *Ruby* type a registration casts to, so an
|
|
1278
|
+
unregistered custom scalar of that name was spoiled as if it had a codec, and
|
|
1279
|
+
generated code was failed for accepting what `T.untyped` pass-through must
|
|
1280
|
+
accept. The lookup asks the Ruby type now, with a built-in's own name allowed
|
|
1281
|
+
to refine it. Separately, `bin/round-trip`'s stand-in registrations spelled
|
|
1282
|
+
out `serialize: :iso8601` for a timestamp, which is `Time#iso8601` and drops
|
|
1283
|
+
the sub-second the harness's wire expectation keeps; they name the class and
|
|
1284
|
+
stop, as docs/scalars.md has always said to. No library behavior changed —
|
|
1285
|
+
both were the harness lying about real schemas — and the registrations moved
|
|
1286
|
+
to `RoundTrip.register_scalars!` so the suite can hold them to it. And
|
|
1287
|
+
`railties` joins the development dependencies, so a spec can boot a real
|
|
1288
|
+
`Rails::Application` and assert the railtie's initializer order rather than
|
|
1289
|
+
reasoning about it — the ordering bug in the logger and instrumenter
|
|
1290
|
+
auto-wires was invisible to a suite that never booted Rails.
|
|
1291
|
+
- Docs: [logging](docs/logging.md) gains the payload table, the log line, and
|
|
1292
|
+
two-line adapters for OpenTelemetry (`in_span`) and Datadog
|
|
1293
|
+
(`Datadog::Tracing.trace`) — both run against the real gems. Datadog's
|
|
1294
|
+
Net::HTTP and Faraday contribs already trace the transport; the instrumenter
|
|
1295
|
+
adds the span *above* it, named for the operation, which is the one that
|
|
1296
|
+
means anything when every call is a POST to the same url. It also says what
|
|
1297
|
+
`filter_parameters` does and doesn't reach: what GraphWeaver composes, never
|
|
1298
|
+
a server- or resolver-authored `GraphQLError#message`.
|
|
1299
|
+
[errors](docs/errors.md) now says what a *server's* input rejection carries,
|
|
1300
|
+
and leads with what isn't portable rather than burying it: the section opens
|
|
1301
|
+
with the servers read by name (graphql-ruby, Apollo, Hasura), what marks an
|
|
1302
|
+
error as input on each, and a worked fallback for a server that marks
|
|
1303
|
+
nothing — including the nil-`#field` branch a `:refused` error takes, the
|
|
1304
|
+
deep-underscore a Rails JSON controller owes its params before `execute`, and
|
|
1305
|
+
the Hasura `where:` value that is legal to the comparison type and fails at
|
|
1306
|
+
Postgres as `data-exception`, which is neither code the arm reads. It also
|
|
1307
|
+
says what shape the convention's `path` must be, and that a
|
|
1308
|
+
`GraphQL::Schema::Validator` on a *field* is never told which list element it
|
|
1309
|
+
is validating — graphql-ruby coerces a list with a plain `map` — so a `qty`
|
|
1310
|
+
inside `lines: [LineInput!]!` reports `["input", "qty"]` for every element
|
|
1311
|
+
alike; install the validator on the **list argument** instead and index the
|
|
1312
|
+
coerced Array yourself (the recipe is there, and a spec runs it). And it says
|
|
1313
|
+
plainly that neither side collects every problem: `coerce` raises on the first
|
|
1314
|
+
field that won't convert (unknown keys and absent required fields being the
|
|
1315
|
+
two it does list in full), and graphql-ruby 2.6 aborts variable validation at
|
|
1316
|
+
the first `validates:` failure across the whole tree — so build the form
|
|
1317
|
+
expecting to iterate.
|
|
1318
|
+
New [i18n](docs/i18n.md) page proposes stable keys for input problems;
|
|
1319
|
+
nothing in it ships yet. It spells out which spelling a path is in and how it
|
|
1320
|
+
maps to a form field, the nil branch, and how to render `#path` when the
|
|
1321
|
+
index is the half that says which list row failed.
|
|
1322
|
+
[scalars](docs/scalars.md#registering-a-class-of-your-own) **leads with the
|
|
1323
|
+
money shapes that work internationally**. The worked `Money` example
|
|
1324
|
+
hardcoded `"USD"` in its cast — correct for the wire shape it assumed (a bare
|
|
1325
|
+
decimal), and silently wrong for any other currency, which comes back
|
|
1326
|
+
mislabelled with no error anywhere. That's the schema's shape, not the
|
|
1327
|
+
library's, so the section now orders the shapes by what they carry: an object
|
|
1328
|
+
scalar (`{"amount","currency"}`), one string carrying both, and an object type
|
|
1329
|
+
`Money { amount currency }` — which needs no `register_scalar` at all, just an
|
|
1330
|
+
`extend_type` — with the bare decimal last and marked single-currency-only.
|
|
1331
|
+
Also new there: register what your cast *returns*, not where the factory
|
|
1332
|
+
lives (`register_scalar("URL", URI)` runs fine and fails `srb tc` at every
|
|
1333
|
+
call site, because Sorbet's `URI` module payload has no `include Kernel` —
|
|
1334
|
+
register `URI::Generic`); pass-through means no invented codec, not an
|
|
1335
|
+
optional cast, since a class JSON can't parse into is still refused when a
|
|
1336
|
+
query reads the field; a `JSON`-narrowed coordinate opts that field out of
|
|
1337
|
+
`:fake` fabrication as well as hardening its shape; and which cross-type
|
|
1338
|
+
values are refused — Ruby **objects**, where a timestamp *string* for a `Date`
|
|
1339
|
+
parses and truncates, as graphql-ruby's own `ISO8601Date` does. And what a
|
|
1340
|
+
timestamp cast costs, measured: `Time.parse`, the reader every `DateTime`
|
|
1341
|
+
field already uses, is ~7× `Date.iso8601`, and `cast: :iso8601` is ~3×
|
|
1342
|
+
cheaper than it and stricter.
|
|
1343
|
+
[getting_started](docs/getting_started.md) cuts the first hour down to the
|
|
1344
|
+
single-schema path, with multi-schema as its own section further down, names
|
|
1345
|
+
the measured false-negative rate of `rake graph_weaver:unused` where the task
|
|
1346
|
+
is introduced — a lint you can act on is worth more than one you are invited
|
|
1347
|
+
to trust — gains the runnable CI script it never had, one per topology plus
|
|
1348
|
+
the GitHub Actions job, says that a validator installed by symbol is
|
|
1349
|
+
invisible to Zeitwerk (reference it from the same `to_prepare` block, above
|
|
1350
|
+
the schema), and answers the Sorbet question where the adoption decision is
|
|
1351
|
+
made rather than at the end. Its §5 also names the two `rover` commands that
|
|
1352
|
+
close the federated-CI gap below, and the Actions job grew a `federation:diff`
|
|
1353
|
+
step.
|
|
1354
|
+
[testing](docs/testing.md) gains `fail_at`'s path syntax, an
|
|
1355
|
+
`extensions.input` example, `Testing::Sequence` for ending a paging loop, what
|
|
1356
|
+
`to_timeout` does and doesn't prove (webmock stands in for the socket, so
|
|
1357
|
+
neither it nor a `sleep` in `to_return` can exercise a `read_timeout:` of
|
|
1358
|
+
yours), that a fake-mode pin is keyed by the schema's scalar name rather than
|
|
1359
|
+
the Ruby class, that `:fake` never runs your server's `validates:` or custom
|
|
1360
|
+
validators, and loses the "partial failure" label on a call that fails the
|
|
1361
|
+
whole response. New there: **a production Apollo Router redacts what
|
|
1362
|
+
`Testing::Router` hands you** — with `include_subgraph_errors` omitted, the
|
|
1363
|
+
default, a subgraph error becomes `{"message" => "Subgraph errors redacted",
|
|
1364
|
+
"path" => […]}` with the extensions emptied, so a spec asserting on a
|
|
1365
|
+
subgraph's message or on the `extensions.service` stamp passes here and fails
|
|
1366
|
+
in staging; it says what to assert on instead and gives the `Failure.graphql`
|
|
1367
|
+
that reproduces the redacted shape. Also the nested-list multiplication
|
|
1368
|
+
behind `list_size:`, measured.
|
|
1369
|
+
[federation](docs/federation.md#producing-a-supergraph) gains the step it
|
|
1370
|
+
never had — how to *produce* a supergraph: `federation_sdl` from the
|
|
1371
|
+
`apollo-federation` gem (named in prose for the first time), where other
|
|
1372
|
+
teams' subgraph SDL comes from, and `rover supergraph compose` with its
|
|
1373
|
+
config and its `--elv2-license` flag. It also writes down that
|
|
1374
|
+
[`router.trace`'s fetch count is the local router's plan, not a
|
|
1375
|
+
gateway's](docs/federation.md#the-local-router): the data is faithful, the
|
|
1376
|
+
cost isn't quite — the same dashboard query is 4 fetches through a real
|
|
1377
|
+
`@apollo/gateway`, because a `@requires` prefetch here is its own call even
|
|
1378
|
+
when it goes to the same subgraph as the plain read beside it, so an N+1
|
|
1379
|
+
regression assertion belongs on a bound or on the subgraph set rather than on
|
|
1380
|
+
an exact count. Also newly written down there: a `@join__` directive the
|
|
1381
|
+
routing table doesn't read refuses `Router.new` for the whole graph (an
|
|
1382
|
+
upgrade-timing event for every team sharing the supergraph), a document that
|
|
1383
|
+
fails ordinary GraphQL validation gets an `errors` response rather than an
|
|
1384
|
+
`Unplannable`, and apollo-federation's `orphan_types`-before-`query` ordering
|
|
1385
|
+
trap. It now opens with a signpost saying which of its sections belong to a
|
|
1386
|
+
client team, a subgraph team and the supergraph owner, and gains four more:
|
|
1387
|
+
**in CI**, because no task in the toolbox looks at the schema production is
|
|
1388
|
+
serving — they all compare the app to its own checked-in artifacts, and
|
|
1389
|
+
`schema:diff` can't be pointed at a supergraph — so it names the two `rover`
|
|
1390
|
+
commands that close it; **`@tag` and contract variants**, what codegen says
|
|
1391
|
+
against each (it refuses at the boundary, which is the guarantee), why
|
|
1392
|
+
`federation:diff` can't tell two variants apart, that nothing cross-checks the
|
|
1393
|
+
variant you generated against with the endpoint you call, and that a variant
|
|
1394
|
+
is mechanically just another `GraphWeaver.graph`; **two changes every gate
|
|
1395
|
+
calls clean**, a scalar swapped for one that still serializes as the same JSON
|
|
1396
|
+
kind (`String!` → `Currency!`, `"19.99"` → `"$19.99"`) and an enum value
|
|
1397
|
+
removed where no query names it literally, with `schema:diff`'s `breaking:
|
|
1398
|
+
true` the only signal for either; and that an `@override` migration is
|
|
1399
|
+
finished from the **old** side, since deleting the new owner's copy first
|
|
1400
|
+
hands the field back to the subgraph you were migrating away from and nothing
|
|
1401
|
+
reports it. Also there: `Federation::Drift`'s `subgraphs:` takes a
|
|
1402
|
+
resolver-less schema from `SchemaLoader.load(sdl)`, so a subgraph published as
|
|
1403
|
+
SDL by a team that doesn't write Ruby is fully compared — and detection unions
|
|
1404
|
+
every candidate that fits a subgraph, so diffing a *proposal* has to name it.
|
|
1405
|
+
[generated modules](docs/generated_modules.md#an-input-object-generates-its-whole-closure)
|
|
1406
|
+
states the rule the docs implied and never said — results are generated per
|
|
1407
|
+
selection set, input types by transitive closure, which is why one Hasura
|
|
1408
|
+
`$where` emits a thousand files — and documents the escape hatch that only
|
|
1409
|
+
ever appeared in an error message: write the filter as a query literal with a
|
|
1410
|
+
variable per leaf. It also says how far a result's immutability goes: its
|
|
1411
|
+
props, so the `String` or `Hash` a leaf holds is the one the response carried,
|
|
1412
|
+
as with `Struct` or `Data` — and that caching one wants `Marshal`, not YAML,
|
|
1413
|
+
since a `T::Enum` member is a singleton compared by identity and Psych
|
|
1414
|
+
rehydrates a duplicate, so `pet.species == Species::Dog` comes back false. It
|
|
1415
|
+
also names the one enum misuse nothing catches — `result.tier == "GOLD"` is
|
|
1416
|
+
silently `false`, on a generated `T::Enum` as on any other — and the
|
|
1417
|
+
sorbet-runtime switch that reports it
|
|
1418
|
+
(`T::Configuration.enable_legacy_t_enum_migration_mode` plus a
|
|
1419
|
+
`soft_assert_handler`), which belongs in your boot rather than in generated
|
|
1420
|
+
code because it covers your own enums too. And two costs: `verify_generated!`
|
|
1421
|
+
runs a full `generate!` however little changed, while
|
|
1422
|
+
`T::Configuration.default_checked_level` buys nothing for `from_h`. Its
|
|
1423
|
+
type-helpers section now shows that an `extend_type` mixin *can* carry sigs
|
|
1424
|
+
`srb tc` checks — declare the fields it reads as abstract sigs, which the
|
|
1425
|
+
struct's `const`s satisfy.
|
|
1426
|
+
[upgrading](docs/upgrading.md) opens each version section with a table of
|
|
1427
|
+
which changes apply to you, separates the grep hits that self-heal on
|
|
1428
|
+
regenerate from the ones to rename by hand, and ends on `graph_weaver:verify`.
|
|
1429
|
+
New [alternatives](docs/alternatives.md) compares the field — graphql-client,
|
|
1430
|
+
graphlient, artemis, hand-rolled — including where this gem loses. Also:
|
|
1431
|
+
browsing a live schema from a console
|
|
1432
|
+
([real world](docs/real_world.md#browsing-the-schema)), the four settings
|
|
1433
|
+
rake-free generation needs to agree on, what `retries:` does and doesn't
|
|
1434
|
+
cover plus `pool_size:` and the pool's fork-safety, and that the request body
|
|
1435
|
+
carries no persisted-query id — so a gateway safelist with `require_id`
|
|
1436
|
+
refuses it, with the `Transport::HTTP` subclass that bolts APQ on today
|
|
1437
|
+
([transports](docs/transports.md#retries)), that Rails' own default
|
|
1438
|
+
`filter_parameters` includes `:email` so a stock app redacts an ordinary field
|
|
1439
|
+
named `email` the day the gem is added ([logging](docs/logging.md#filtered-variables)),
|
|
1440
|
+
and keying a translation on the exception class where there is no `kind`
|
|
1441
|
+
([i18n](docs/i18n.md#transport-and-server-failures)). And
|
|
1442
|
+
`spec/doc_samples_spec.rb` parses every fenced Ruby sample in README +
|
|
1443
|
+
`docs/` and resolves every link between them, so a sample that doesn't run
|
|
1444
|
+
can't ship — two that didn't now do (`Codegen.generate(client:)` takes the
|
|
1445
|
+
constant's *name*, and `Federation::Drift` needs `require
|
|
1446
|
+
"graph_weaver/federation"`).
|
|
1447
|
+
### v0.6.1 (2026-09-10)
|
|
1448
|
+
- **A stdlib scalar registers with nothing but its class.**
|
|
1449
|
+
`GraphWeaver.register_scalar("Decimal", BigDecimal)` now emits the whole
|
|
1450
|
+
codec: `BigDecimal(...)` reads the wire value, `to_s("F")` writes it back,
|
|
1451
|
+
and the generated file requires `bigdecimal`. The careful spelling people
|
|
1452
|
+
reached for was wrong — `serialize: :to_s` puts `"0.125e2"` on the wire —
|
|
1453
|
+
so the library owns it. Cast inference gained Kernel's conversion functions
|
|
1454
|
+
(`BigDecimal()`, and any `Kernel#Type` your app defines for a type the wire
|
|
1455
|
+
can't already be), and `serialize:` accepts `[:method, *args]` for a
|
|
1456
|
+
serializer that takes arguments. `cast:`/`serialize:`/`requires:` are
|
|
1457
|
+
unchanged, for a class of your own.
|
|
1458
|
+
- **Scalar names that are conventions are registered already**: graphql-ruby's
|
|
1459
|
+
`ISO8601Date`, `ISO8601DateTime`, `BigInt` and `JSON`, plus `DateTime` —
|
|
1460
|
+
what GitHub, Shopify and most hand-written schemas call an ISO 8601
|
|
1461
|
+
timestamp. A date deserializes into `Date` and a timestamp into `Time`, so
|
|
1462
|
+
nothing invents a midnight; `BigInt` reads the decimal string graphql-ruby
|
|
1463
|
+
writes as well as a JSON number; `JSON` is registered as `T.untyped` on
|
|
1464
|
+
purpose, which also drops it from the "unregistered custom scalars" report.
|
|
1465
|
+
A schema that means something else by one of these names fails loudly — the
|
|
1466
|
+
cast raises, naming the field — and one `register_scalar` overrides it, as
|
|
1467
|
+
it always could. **Regenerate** if a query of yours reads a scalar with one
|
|
1468
|
+
of those names: its prop was `T.untyped` and now has a type.
|
|
1469
|
+
|
|
1
1470
|
### v0.6.0 (2026-09-09)
|
|
2
1471
|
- **One rule for relative path settings: they resolve against
|
|
3
1472
|
`GraphWeaver.root`** — `Rails.root` in a Rails app, the working directory
|
|
@@ -1067,7 +2536,7 @@ resolvers in-process: no gateway, no node, no sockets. It plans one shape — a
|
|
|
1067
2536
|
query whose every field resolves in a single subgraph, passed to that subgraph
|
|
1068
2537
|
verbatim — and raises `Unplannable` (a `GraphWeaver::Error`) for anything that
|
|
1069
2538
|
crosses a boundary, at plan time, before any subgraph runs. See
|
|
1070
|
-
[docs/testing.md](docs/testing.md#a-
|
|
2539
|
+
[docs/testing.md](docs/testing.md#a-federated-graph--graphql-router).
|
|
1071
2540
|
|
|
1072
2541
|
**`rake graph_weaver:federation:coverage SUPERGRAPH=…` says how much of your
|
|
1073
2542
|
query set that router can plan**, and groups every refusal by what stopped it —
|