graph_weaver 0.4.4 → 0.5.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 +1357 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -136
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -251
- data/lib/graph_weaver/codegen/enum_type.rb +27 -98
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +617 -264
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +12 -6
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +21 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +15 -1
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +820 -57
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +59 -7
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +186 -62
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +194 -28
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +31 -6
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +74 -18
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +398 -170
- metadata +20 -3
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,1360 @@
|
|
|
1
|
+
### v0.5.0 (2026-09-07)
|
|
2
|
+
- **`graphql_in_process(SomeSchema)`** runs one example against that schema
|
|
3
|
+
class's real resolvers — the sibling of `graphql_fake`, and how a federated
|
|
4
|
+
app tests a single subgraph directly rather than through the stitched graph.
|
|
5
|
+
`graphql: :in_process` is that call with no argument, which runs
|
|
6
|
+
`config.schema` when it is a live class, so a non-federated app needs
|
|
7
|
+
nothing. A suite testing several subgraphs names one per example, which no
|
|
8
|
+
suite-wide setting could express.
|
|
9
|
+
- **`Testing::Router` crosses a boundary on a nested `@key` or `@requires`.**
|
|
10
|
+
`@key(fields: "id organization { id }")` and
|
|
11
|
+
`@requires(fields: "origin { lat lon }")` used to refuse; they now plan, to
|
|
12
|
+
any depth, with the object carried in the representation the way the SDL
|
|
13
|
+
spells it (a null inner object included). **Nothing to do** — queries your
|
|
14
|
+
suite marked "run this one against a real router" may now simply run. Where
|
|
15
|
+
a type declares more than one `@key`, the plan takes the first one the
|
|
16
|
+
fetching subgraph can supply.
|
|
17
|
+
- **`:nested_field_set` narrowed rather than disappeared.** It now names only
|
|
18
|
+
a nested field set no one *fetch* can build — `origin` resolving in one
|
|
19
|
+
subgraph and `origin.lat` in another, or a nested `@key` whose object a
|
|
20
|
+
`@requires` would half-fill from somewhere else. Either way the object
|
|
21
|
+
would arrive in pieces, and a representation comes from one fetch. If you
|
|
22
|
+
group refusals by category, that bucket shrinks; its message and
|
|
23
|
+
`docs/federation.md`'s table say what is left. `:chained_requires` is
|
|
24
|
+
unchanged and still a different refusal.
|
|
25
|
+
- **`Testing.config.router` takes `subgraphs:` without `supergraph:`.** It
|
|
26
|
+
raised — "must be the arguments to build one, e.g. `{ supergraph: … }`" —
|
|
27
|
+
even where the committed dump already is the supergraph, which is the case
|
|
28
|
+
the docs call "no config at all" and the single most likely config a
|
|
29
|
+
federated app writes (marking a remote subgraph `:fake`). Either key alone
|
|
30
|
+
is enough now; a missing `supergraph:` derives exactly as it does with no
|
|
31
|
+
`config.router` at all.
|
|
32
|
+
- **`graphql_fake(**options)`** builds the example's fake where the example
|
|
33
|
+
can say what it needs — `graphql_fake(overrides: { "Reader.orders" => [{}, {}] })`
|
|
34
|
+
— and returns it, so `#requests` is in reach. `graphql: :fake` is this call
|
|
35
|
+
with no options. Options had nowhere to go before: the tag builds its client
|
|
36
|
+
in a `config.before(:each)`, which rspec runs ahead of every group hook, so
|
|
37
|
+
`Testing.config.overrides` set in a `before` block was always too late and
|
|
38
|
+
failed silently, as wrong data.
|
|
39
|
+
- **`GraphWeaver.client` is snapshotted and restored around *every* example**,
|
|
40
|
+
not only a tagged one. `graphql: false` used not to restore while
|
|
41
|
+
`graphql: :fake` did, which made "tag `:fake`, then throw the client away"
|
|
42
|
+
the idiom for cleanup. Building your own client is now a plain assignment in
|
|
43
|
+
a `before` block. An example that deliberately leaked a client into later
|
|
44
|
+
examples no longer can.
|
|
45
|
+
- **`graphql: :none` is gone** — a second spelling of `graphql: false`, which
|
|
46
|
+
stays. Change any `:none` tag to `false`.
|
|
47
|
+
- **`GraphWeaver.client!` names the tag** when `graph_weaver/rspec` is loaded:
|
|
48
|
+
`no client configured — tag the example graphql: :fake (or :in_process /
|
|
49
|
+
:router), or build one with graphql_fake`. "Set `GraphWeaver.client=`" was
|
|
50
|
+
advice for the wrong file.
|
|
51
|
+
- **An override pins a subtree by naming only the fields the test is about.**
|
|
52
|
+
`overrides: { "Reader.orders" => [{ "status" => "PAID" }, {}] }` pins the
|
|
53
|
+
list's length and merges each element onto fabricated data — the rest of the
|
|
54
|
+
selection is still generated. It used to *replace*, so pinning one nested
|
|
55
|
+
field meant hand-writing the whole selection set in wire casing, and
|
|
56
|
+
under-supplying died as `key not found: "book"` at cast time. A pinned key
|
|
57
|
+
the query doesn't select is now refused (spellchecked, and naming the
|
|
58
|
+
response keys it could have been), for the same reason a typo'd coordinate
|
|
59
|
+
is. At a union or interface, a pinned object names its `"__typename"` and
|
|
60
|
+
gets that member rather than a random one.
|
|
61
|
+
- **An override of `nil` pins the field null.** It used to read as "no
|
|
62
|
+
override" and fabricate a value.
|
|
63
|
+
- **`Testing::FakeClient#requests`** records every `execute` in order
|
|
64
|
+
(`{ query:, variables:, operation_name: }`) — "did we send the right
|
|
65
|
+
variables", and "did we call it at all", without a hand-rolled spy.
|
|
66
|
+
- **`FakeClient`'s selection-walking internals are private** (`each_field`,
|
|
67
|
+
`gather`, `load_operation`, …). Nothing documented called them; if you did,
|
|
68
|
+
`Object.new.extend(GraphWeaver::Selection)` is the supported host.
|
|
69
|
+
- **The local router refuses a `@requires` whose field set names another
|
|
70
|
+
`@requires` field** (`chained_requires`). It used to answer: a prefetch sends
|
|
71
|
+
the entity's own `@key` and nothing else, so the inner requirement never
|
|
72
|
+
arrived and the field was computed from a representation missing its input —
|
|
73
|
+
the same field then holding two different values in one response. The
|
|
74
|
+
one-hop limit was documented but not enforced.
|
|
75
|
+
- **An error path no longer names an injected `@key`/`@requires` alias.** A
|
|
76
|
+
stitched error came back as `["thing", "_gw_w"]`, a field no schema contains;
|
|
77
|
+
it is `["thing", "w"]` now, as a real router reports it.
|
|
78
|
+
- **`@skip`/`@include` driven by a variable's declared default was ignored on a
|
|
79
|
+
boundary-crossing field**, so `query($show: Boolean = true) { … @include(if: $show) }`
|
|
80
|
+
called with no variables silently dropped the field. graphql-ruby applies an
|
|
81
|
+
operation's defaults; the local router read only what the caller passed.
|
|
82
|
+
- **`Testing::FakeClient` raises a GraphQL validation error for an unknown
|
|
83
|
+
field**, as every other client in the slot does. It used to die with
|
|
84
|
+
`NoMethodError: undefined method 'type' for nil` from inside the fabricator —
|
|
85
|
+
undiagnosable, and the commonest mistake there is: a query drifting ahead of
|
|
86
|
+
the schema dump, or a typo in one written inside an example.
|
|
87
|
+
- **The local test router plans a union or interface at a subgraph boundary.**
|
|
88
|
+
`search { ... on Track { playCount } ... on Artist { upcomingEvents { … } } }`
|
|
89
|
+
— a feed, a search page, any polymorphic list — used to be refused
|
|
90
|
+
(`abstract_boundary`), because a representation names one concrete
|
|
91
|
+
`__typename` and the planner runs before any data exists. It now plans one
|
|
92
|
+
branch per concrete type the supergraph says the subgraph can answer with,
|
|
93
|
+
asks the fetch for `__typename`, and buckets the returned objects by it at
|
|
94
|
+
execution time — one `_entities` fetch per concrete type, which is what a
|
|
95
|
+
real router does. Nothing to change; queries that were refused now run.
|
|
96
|
+
- The `crosses_subgraph` refusal category is **gone**, and
|
|
97
|
+
`abstract_boundary` now means only one thing: the supergraph doesn't record
|
|
98
|
+
which concrete types a subgraph answers an abstract type with (no
|
|
99
|
+
`@join__unionMember`/`@join__implements`, and the type in more than one
|
|
100
|
+
subgraph). Match on `Unplannable#category` for either of those and you must
|
|
101
|
+
change it.
|
|
102
|
+
- A fragment whose type condition can't hold at a position is now dropped
|
|
103
|
+
rather than refused — `... on Note` under a field whose subgraph has no
|
|
104
|
+
Note in that union never matches, and a real router answers `{}` there too.
|
|
105
|
+
- **A query file whose name can't spell a constant now names the file.**
|
|
106
|
+
`01_home_featured.graphql` reported `module_name: must be a constant name,
|
|
107
|
+
got "01HomeFeaturedQuery"` and left you to find which of thirty files it
|
|
108
|
+
meant; it now names the path and says the fix is a rename. It's a
|
|
109
|
+
`GraphWeaver::Error` too, so `rake graph_weaver:generate` aborts on it
|
|
110
|
+
instead of burying it under a backtrace — rescue `ArgumentError` for this
|
|
111
|
+
and you must change it. An explicit `module_name:` argument still raises
|
|
112
|
+
`ArgumentError`.
|
|
113
|
+
- **`rails g graph_weaver:install <supergraph>` recognises a composed
|
|
114
|
+
supergraph** and says what changes because of it: the `federation:*` tasks,
|
|
115
|
+
and that `graphql: :router` runs specs against your real subgraph resolvers.
|
|
116
|
+
The initializer it writes says so too.
|
|
117
|
+
- **Every `rake graph_weaver:federation:*` task was a silent no-op in a stock
|
|
118
|
+
Rails app.** Rails defaults `config.rake_eager_load` to false, and subgraph
|
|
119
|
+
detection only sees *loaded* schema classes — so `federation:subgraphs`
|
|
120
|
+
reported `nil` for every subgraph and `federation:diff` printed "checked 0 of
|
|
121
|
+
4 subgraphs" and exited **0**, a CI gate permanently green while checking
|
|
122
|
+
nothing. The tasks now eager-load the app first, and detection resolves.
|
|
123
|
+
- **`federation:diff` fails when it compared against nothing** (exit 1, naming
|
|
124
|
+
what to do). Comparing against *some* subgraphs is still a pass — a
|
|
125
|
+
partly-local supergraph is a supported setup — but a comparison against none
|
|
126
|
+
of them proved nothing. If your subgraphs all run elsewhere, drop the task
|
|
127
|
+
from CI; it has nothing there to gate.
|
|
128
|
+
- **The absent-subgraph refusal names the cause.** It advised `subgraphs: { … }`
|
|
129
|
+
on a `Router.new` an rspec `graphql: :router` example never calls. It now
|
|
130
|
+
leads with the usual cause — the schema class isn't loaded yet, so
|
|
131
|
+
eager-load it — and names `GraphWeaver::Testing.config.router = { subgraphs:
|
|
132
|
+
… }` as the way to name one under the tag.
|
|
133
|
+
- **Subgraph-mapping refusals are `GraphWeaver::ConfigurationError` (was
|
|
134
|
+
`ArgumentError`)** — `rescue GraphWeaver::Error` around `Testing::Router.new`
|
|
135
|
+
now catches them, as `docs/errors.md` said it would. Rescue `ArgumentError`
|
|
136
|
+
for these and you must change it. The rule the docs now state: what the
|
|
137
|
+
library *concludes*, having read your schema, is a `GraphWeaver::Error`; an
|
|
138
|
+
argument wrong on its face (`pool_size: must be >= 1`) stays an
|
|
139
|
+
`ArgumentError`. An ambiguous-detection refusal also names each candidate
|
|
140
|
+
schema once, instead of repeating a reloaded constant.
|
|
141
|
+
- **The local test router refused every mutation that stitched below its root**,
|
|
142
|
+
with a message claiming its root fields "span subgraphs" when there was one
|
|
143
|
+
root field in one subgraph — and advice (split it per subgraph) that couldn't
|
|
144
|
+
be followed. It now plans any mutation whose root fields share a subgraph:
|
|
145
|
+
they go over as one document and that subgraph serializes them, and what
|
|
146
|
+
hangs below a root is an ordinary read afterwards. The refusal is left for
|
|
147
|
+
root fields that genuinely span subgraphs, where the message is true.
|
|
148
|
+
- **The local router honoured `@skip`/`@include` on a field but ignored it on a
|
|
149
|
+
fragment spread or inline fragment that crossed a subgraph boundary** — it
|
|
150
|
+
answered a selection the operation had excluded, and ran an extra subgraph
|
|
151
|
+
fetch to do it. Folding a same-type fragment into its parent dropped the
|
|
152
|
+
fragment node, and its directives with it. They now move onto the selections
|
|
153
|
+
they guarded, and a fetch is skipped entirely when everything it was for is
|
|
154
|
+
excluded (so `trace` matches what a real router does). A field and the
|
|
155
|
+
fragment around it both carrying the same directive refuses, as
|
|
156
|
+
`conditional_fragment` — one selection can't hold two conditions of one name.
|
|
157
|
+
- **`rake graph_weaver:federation:diff` reported false drift for every input
|
|
158
|
+
object**, so a correct supergraph failed the CI gate with advice to recompose
|
|
159
|
+
something that wasn't broken. An input object exposes its members as
|
|
160
|
+
`arguments`, not `fields`, and the check only asked for fields.
|
|
161
|
+
- **A `Pathname` works anywhere a schema path does** — `GraphWeaver.new`,
|
|
162
|
+
`generate!(schema:)`, `SchemaLoader.load`. `Rails.root.join("schema.graphql")`
|
|
163
|
+
previously failed as `undefined method 'lstrip' for an instance of Pathname`.
|
|
164
|
+
- **`Transport::HTTP`'s `pool_size:` defaults to `RAILS_MAX_THREADS`** (else 5,
|
|
165
|
+
as before) — the variable Rails already sizes its own connection pool from,
|
|
166
|
+
because it is the same question. A threaded app that raised its thread count
|
|
167
|
+
no longer silently queues behind five sockets.
|
|
168
|
+
- **A saturated pool says so.** The first request that has to wait for a
|
|
169
|
+
connection logs a warning naming the wait and the ceiling; later ones log at
|
|
170
|
+
debug. Queueing was previously indistinguishable from a slow server, which
|
|
171
|
+
mattered most under a fiber scheduler — `async`/Falcon multiplexes fine, but
|
|
172
|
+
nothing sets `RAILS_MAX_THREADS` there, so the default capped it at 5 with no
|
|
173
|
+
signal. See `docs/transports.md`.
|
|
174
|
+
|
|
175
|
+
#### One `execute`, one way to pass a client (**breaking**)
|
|
176
|
+
|
|
177
|
+
Every client answers the same call — `execute(query, variables:,
|
|
178
|
+
operation_name:)`, returning the raw response hash. Three objects a user holds
|
|
179
|
+
used to disagree with that, and each disagreement was a bug waiting.
|
|
180
|
+
|
|
181
|
+
- **`Client#execute` is that contract now**, so `GraphWeaver::Retry.new(client)`,
|
|
182
|
+
`Testing::Sequence.new(client, fake)` and a cassette recorder over a client
|
|
183
|
+
all work. Its one-shot parse-and-run sugar moved to **`Client#run` /
|
|
184
|
+
`#run!`** (and `GraphWeaver.run` / `.run!` for the throwaway-client form):
|
|
185
|
+
**rename `client.execute!(query, **vars)` to `client.run!(query, **vars)`.**
|
|
186
|
+
- **A generated module takes the per-call client as a kwarg**: rename
|
|
187
|
+
`PersonQuery.execute(some_client, id: "1")` to
|
|
188
|
+
`PersonQuery.execute(client: some_client, id: "1")`. Variables are
|
|
189
|
+
unaffected — `$client` was already refused at generation. It is also what
|
|
190
|
+
makes a mistyped variable name itself: `PersonQuery.execute(id: "1")` on a
|
|
191
|
+
query that declares no variables now raises `unknown keyword: :id` instead of
|
|
192
|
+
blaming the client. **Regenerate** (`rake graph_weaver:generate`);
|
|
193
|
+
`rake graph_weaver:verify` fails until you do.
|
|
194
|
+
- **`GraphWeaver.resolve_transport` is gone.** Nothing needs unwrapping any
|
|
195
|
+
more. A client that can't execute is still refused, by the module it was
|
|
196
|
+
passed to — whose name is now in the message.
|
|
197
|
+
- A module parsed from a `Client` bakes that client rather than its transport.
|
|
198
|
+
For a client built from a schema *dump* (no transport), `execute` now says
|
|
199
|
+
`this client has no transport (built from a schema dump)` instead of quietly
|
|
200
|
+
running on `GraphWeaver.client`.
|
|
201
|
+
- `$transport` is a usable GraphQL variable name again — the generated body has
|
|
202
|
+
no such local.
|
|
203
|
+
|
|
204
|
+
#### One reset, one plurality rule (**breaking**)
|
|
205
|
+
|
|
206
|
+
- **`GraphWeaver.reset_scalars!`, `clear_scalars!`, `reset_enums!` and
|
|
207
|
+
`reset_type_helpers!` are gone.** `GraphWeaver.reset_registrations!` is the
|
|
208
|
+
clean slate between tests; to reset one registry, call the same name on
|
|
209
|
+
`GraphWeaver::Codegen`.
|
|
210
|
+
- **Every directory setting is a list — `queries_paths`, `generated_paths`,
|
|
211
|
+
`fragments_paths`** — and *every entry is read*, by `generate!`,
|
|
212
|
+
`verify_generated!`, `check_queries` and `load_queries!` alike. (0.4.x
|
|
213
|
+
dropped a plural `queries_paths` because only `load_queries!` walked it;
|
|
214
|
+
that divergence is what the singular was protecting against, and it is gone.)
|
|
215
|
+
**Rename any `queries_path` / `generated_path` / `fragments_path` you set or
|
|
216
|
+
read** — assigning a String still works (`GraphWeaver.queries_paths =
|
|
217
|
+
"app/graphql/queries"`), and `generated_paths.first` is the one directory
|
|
218
|
+
`generate!` writes into. `schema_path` stays singular: a run reads one
|
|
219
|
+
schema, so a second entry would name a dump nothing opens.
|
|
220
|
+
|
|
221
|
+
- `rake graph_weaver:queries:check` prints an unparseable query's position once
|
|
222
|
+
rather than twice, and `GraphWeaver.check_queries` returns the documented
|
|
223
|
+
`"message"` / `"line"` / `"column"` shape for parse errors too — the position
|
|
224
|
+
is no longer folded into the message.
|
|
225
|
+
- Docs: a spec-local `generated_paths` entry needs an explicit
|
|
226
|
+
`GraphWeaver.load_generated!` (in Rails the Railtie has already run by then),
|
|
227
|
+
and belongs outside `spec/support/`, whose files rspec-rails requires itself
|
|
228
|
+
in sorted order. A registration naming one of your own constants goes in a
|
|
229
|
+
`to_prepare` block — the same rule the in-process client already follows —
|
|
230
|
+
rather than the `require Rails.root.join(...)` dance.
|
|
231
|
+
- **Removing an `extend_type` registration no longer bricks the app.**
|
|
232
|
+
Generated files carry `include GraphWeaver::TypeHelpers::Foo`, so dropping
|
|
233
|
+
the registration made boot fail — and because `rake graph_weaver:generate`
|
|
234
|
+
depends on `:environment`, the regeneration that would repair it failed the
|
|
235
|
+
same way. The graph_weaver tasks now skip loading generated modules (none of
|
|
236
|
+
them reads one), so `rake graph_weaver:generate` repairs the tree. Outside a
|
|
237
|
+
task, the dangling include now raises a `GraphWeaver::Error` naming the
|
|
238
|
+
registration that went missing and how to recover, instead of a bare
|
|
239
|
+
`NameError` pointing into generated code.
|
|
240
|
+
- **A cancelled request no longer leaks its socket.** `Transport::HTTP`
|
|
241
|
+
closed a connection of unknown state with a bare `rescue`, which catches
|
|
242
|
+
only `StandardError`. A fiber scheduler cancels with `Async::Stop`, which
|
|
243
|
+
descends from `Exception`, so cancelling an in-flight request walked past
|
|
244
|
+
the cleanup and left the socket open until GC. Affects any app under
|
|
245
|
+
`async`/Falcon with per-request timeouts. Nothing to do — the fix is
|
|
246
|
+
internal.
|
|
247
|
+
- `rake graph_weaver:queries:check` and `federation:diff` flush stdout before
|
|
248
|
+
aborting, so a piped CI log shows the details before the verdict rather
|
|
249
|
+
than after it.
|
|
250
|
+
- `generate!`, `verify_generated!` and `check_queries` accept a **path or SDL
|
|
251
|
+
string** for `schema:`, like every other schema slot in the library. A String
|
|
252
|
+
used to reach `schema.validate` as itself and die with `undefined method
|
|
253
|
+
'validate' for an instance of String`.
|
|
254
|
+
|
|
255
|
+
- **`#parse` on anything that holds a schema**, not just `Client`:
|
|
256
|
+
`GraphWeaver::InProcess`, `Testing::FakeClient` and `Testing::Router` gain it
|
|
257
|
+
(and `#load_queries!`) from the new `GraphWeaver::Parsing` mixin. Replace
|
|
258
|
+
`GraphWeaver.parse(schema: router.schema, client: router, query: q)` with
|
|
259
|
+
`router.parse(q)`. `Retry` holds no schema and has no `#parse` — parse from
|
|
260
|
+
what it wraps. Nothing changes for `Client`, and the client contract is
|
|
261
|
+
untouched: a bare `GraphQL::Schema` class still fills the client slot.
|
|
262
|
+
- **`graphql: false` (or `graphql: :none`) opts an example out of
|
|
263
|
+
`config.default_mode`** — no client is installed, so the example can wire its
|
|
264
|
+
own. Previously a default swept up every untagged example with no way out,
|
|
265
|
+
and both spellings raised "is not a mode"; that message now names the opt-out
|
|
266
|
+
alongside the modes.
|
|
267
|
+
- Docs: `graphql_context` is setup, so `docs/testing.md` now shows it in a
|
|
268
|
+
`before` block for a group sharing one identity, with the inline form kept
|
|
269
|
+
for one-offs. The per-example reset runs ahead of group hooks, so a
|
|
270
|
+
group-level `before` re-applies from the same baseline every time.
|
|
271
|
+
|
|
272
|
+
- `examples/federation.rb` — a runnable federated-testing example, and the first
|
|
273
|
+
one that needs no network: three real subgraphs, a boundary-crossing query
|
|
274
|
+
through a generated module, `router.trace`, and a plan-time refusal. Guarded
|
|
275
|
+
by `spec/examples_spec.rb`, which runs it.
|
|
276
|
+
- Docs: the local router moved from `docs/testing.md` to `docs/federation.md`.
|
|
277
|
+
`testing.md` now covers which client an example runs against; `federation.md`
|
|
278
|
+
covers what a federated graph does. No behaviour change — update any bookmark
|
|
279
|
+
to `docs/testing.md#the-in-process-router--graphql-router`, now
|
|
280
|
+
`docs/federation.md#the-local-router`.
|
|
281
|
+
- **`graphql: :in_process` no longer hunts for the live schema class.** It runs
|
|
282
|
+
against `GraphWeaver::Testing.config.schema`, or the schema class your client
|
|
283
|
+
already runs in-process — one sentence, no heuristic. The third fallback
|
|
284
|
+
(`Testing::LiveSchema`, which searched every loaded `GraphQL::Schema` for one
|
|
285
|
+
defining everything the reference schema declares) is **deleted**. It only
|
|
286
|
+
ever applied to an app whose client points at a *different* API, and under
|
|
287
|
+
Zeitwerk it failed on the first `:in_process` example anyway, since an
|
|
288
|
+
autoloaded schema isn't loaded until something names it. Such an app now sets
|
|
289
|
+
`config.schema = MySchema`; when neither source is there, the error says so.
|
|
290
|
+
- **One positive predicate on `Response`, not two.** `#ok?` is gone; `#success?`
|
|
291
|
+
is the survivor, so the pair is `errors?` / `success?`. `success?` is what
|
|
292
|
+
`Process::Status` and `Faraday::Response` call it, and `ok?` reads as HTTP
|
|
293
|
+
200 — which a GraphQL response carrying errors also is. Semantics unchanged:
|
|
294
|
+
partial data alongside top-level errors is **not** a success.
|
|
295
|
+
- **`Testing::Config#auto_fake` is gone** — it was the pre-tag spelling of
|
|
296
|
+
`config.default_mode = :fake`. Use that.
|
|
297
|
+
- **`rake graph_weaver:federation:diff` no longer loads the test harness.**
|
|
298
|
+
`Federation::Drift` needed one thing from it — the list of loaded schema
|
|
299
|
+
classes — and did `require "graph_weaver/testing"` from inside itself to get
|
|
300
|
+
it, pulling faker into a task that fabricates nothing. That question, and
|
|
301
|
+
"does this schema define this coordinate", now live in `GraphWeaver::Schemas`,
|
|
302
|
+
shared with `Testing::Subgraphs`. Measured over the fixture supergraph, the
|
|
303
|
+
task loads 15 files instead of 323 (253 of them faker's).
|
|
304
|
+
`Testing::Subgraphs.loaded` moved with it: call `GraphWeaver::Schemas.loaded`.
|
|
305
|
+
- Internal: `codegen/enum_type.rb` held `extend_type`, the type-helper and alias
|
|
306
|
+
registries and `GraphWeaver::TypeHelpers` — none of them enums, so a search
|
|
307
|
+
for `extend_type` landed in a file named for something else. Those moved to
|
|
308
|
+
`codegen/type_helpers.rb`; `enum_type.rb` now holds `EnumType` and the enum
|
|
309
|
+
registry, mirroring `scalar_type.rb`. No API change.
|
|
310
|
+
- **`register_enum` and `extend_type` say where to register** when handed a
|
|
311
|
+
constant's *name* instead of the constant. Passing a String is the natural
|
|
312
|
+
workaround for "`uninitialized constant PetKind` in my initializer", and the
|
|
313
|
+
answer is Rails' own: autoloading is set up after `config/initializers` run,
|
|
314
|
+
so register from a `Rails.application.config.to_prepare` block — which
|
|
315
|
+
`rake graph_weaver:generate` also runs before generating. Both registries
|
|
316
|
+
still take the constant itself; a name would be a second spelling that
|
|
317
|
+
couldn't reach `fallback:` or `map:` anyway, since those name enum *members*.
|
|
318
|
+
|
|
319
|
+
#### Scalar coercion is one switch (**breaking**)
|
|
320
|
+
|
|
321
|
+
`coerce:` takes `true`/`false` only — the Symbol form is gone. It was a third
|
|
322
|
+
way to ask one question (*may a variable of this scalar accept loose input?*)
|
|
323
|
+
and the only one that also made you answer *how*, which the scalar already
|
|
324
|
+
knows: `Int`/`Float` convert, anything with a `cast:`/`serialize:` pair parses,
|
|
325
|
+
and a pass-through scalar can't.
|
|
326
|
+
|
|
327
|
+
**What to do:**
|
|
328
|
+
|
|
329
|
+
- `coerce: :to_i` / `coerce: :to_f` — write `coerce: true`. Generated output is
|
|
330
|
+
unchanged.
|
|
331
|
+
- `coerce: :to_s` on `String`/`ID` — drop it and call `.to_s` at the call site.
|
|
332
|
+
Those have nothing to convert from, so `coerce: true` on one raises now
|
|
333
|
+
instead of emitting a no-op.
|
|
334
|
+
- any other Symbol on a custom scalar — give the scalar a `cast:`/`serialize:`
|
|
335
|
+
pair and `coerce: true`.
|
|
336
|
+
|
|
337
|
+
`GraphWeaver.auto_coerce` is unaffected, and `coerce: true` is now exactly what
|
|
338
|
+
it turns on for one scalar instead of all of them.
|
|
339
|
+
|
|
340
|
+
#### One shared module, not three (**breaking** — regenerate)
|
|
341
|
+
|
|
342
|
+
`GraphQLInputs`, `GraphQLUnions` and `GraphQLEnums` are now one `GraphQLTypes`.
|
|
343
|
+
They were three constants, three config knobs and three file shapes for one
|
|
344
|
+
idea — a type shared across query modules — and the rule now fits in a
|
|
345
|
+
sentence: **a type shared across query modules lives in the shared module and is
|
|
346
|
+
aliased in.**
|
|
347
|
+
|
|
348
|
+
**What to do:** run `rake graph_weaver:generate` (`verify` fails until you do,
|
|
349
|
+
naming the stale files). Every generated file changes: a query module opens with
|
|
350
|
+
one `require_relative "types"` instead of up to three, and its aliases read
|
|
351
|
+
`AdoptionInput = GraphQLTypes::AdoptionInput`. On disk, `enums.rb`, `inputs.rb`,
|
|
352
|
+
`inputs/` and `unions.rb` become `types.rb` (the manifest) plus one file per
|
|
353
|
+
type under `types/` — the old files are pruned for you, since pruning keys off
|
|
354
|
+
the generated header. If you referenced `GraphQLInputs::PetFilter` (or the other
|
|
355
|
+
two) by hand, spell it `GraphQLTypes::PetFilter`.
|
|
356
|
+
|
|
357
|
+
`GraphWeaver.inputs_module=` / `unions_module=` / `enums_module=` are now
|
|
358
|
+
`GraphWeaver.types_module=`, and `generate!`/`verify_generated!` take one
|
|
359
|
+
`types_module:` in place of three.
|
|
360
|
+
|
|
361
|
+
One namespace also removes the aliasing *between* the shared artifacts: an input
|
|
362
|
+
struct's props and a union member's selections spell their enums bare now, being
|
|
363
|
+
lexically inside the same module. The manifest requires the enum files first for
|
|
364
|
+
that reason.
|
|
365
|
+
|
|
366
|
+
New: a shared fragment whose name is already a schema type in that module is
|
|
367
|
+
refused at generation, naming both — a fragment is named by you, a type by the
|
|
368
|
+
schema, and one module is one namespace. Previously they lived apart and could
|
|
369
|
+
never meet.
|
|
370
|
+
|
|
371
|
+
#### Has anyone changed a subgraph without recomposing?
|
|
372
|
+
|
|
373
|
+
rake graph_weaver:federation:diff SUPERGRAPH=supergraph.graphql
|
|
374
|
+
|
|
375
|
+
A committed supergraph is a snapshot of a composition, and nothing checked that
|
|
376
|
+
it still described your subgraphs — so it could quietly promise a graph that no
|
|
377
|
+
longer exists. This reads the routing table against the subgraph schemas loaded
|
|
378
|
+
in this process, needs **no network**, and exits non-zero on drift, so it gates
|
|
379
|
+
a PR alongside `graph_weaver:verify`.
|
|
380
|
+
|
|
381
|
+
Both directions, because they mean opposite things: **stale** (the supergraph
|
|
382
|
+
carries `Product.weight`, nothing here defines it — recompose) and **not
|
|
383
|
+
composed in** (a schema here defines `Product.dimensions`, the supergraph
|
|
384
|
+
doesn't carry it — publish the subgraph). Comparison is deliberately looser
|
|
385
|
+
than field-set equality, which would be wrong both ways: a subgraph carries
|
|
386
|
+
federation plumbing no supergraph has, and `@external`/`@shareable` put a field
|
|
387
|
+
in more than one subgraph.
|
|
388
|
+
|
|
389
|
+
A supergraph is routinely only partly local, so the report names three states —
|
|
390
|
+
checked, not here, and answered with fabricated data — and the headline counts
|
|
391
|
+
them. Only drift fails; absence is a supported setup.
|
|
392
|
+
`GraphWeaver::Federation::Drift` is the same thing as data (`#to_h`, `#drift?`).
|
|
393
|
+
|
|
394
|
+
#### Validation errors name the subgraph behind the type
|
|
395
|
+
|
|
396
|
+
When the schema dump is a composed supergraph, `rake graph_weaver:queries:check`
|
|
397
|
+
brands each error with who resolves the type it points at:
|
|
398
|
+
|
|
399
|
+
app/graphql/queries/product.graphql
|
|
400
|
+
4:5 Field 'dimensions' doesn't exist on type 'Product' (products, reviews)
|
|
401
|
+
|
|
402
|
+
`Product.dimensions` says what broke; `(products, reviews)` says whose code to
|
|
403
|
+
look at. A plain schema has no routing table and is unaffected.
|
|
404
|
+
|
|
405
|
+
- New: `SchemaLoader::RoutingTable#declared_fields`, `#declares?`, `#responsible`.
|
|
406
|
+
|
|
407
|
+
#### A partly-local supergraph now works
|
|
408
|
+
|
|
409
|
+
The testing router serves a supergraph composed from several services when only
|
|
410
|
+
some of them run in this process. A subgraph no loaded schema defines is
|
|
411
|
+
**absent** rather than an error at construction, so the router builds and every
|
|
412
|
+
query that doesn't reach those fields runs normally. A query that does reach
|
|
413
|
+
them is refused at plan time, before anything executes, naming the subgraph and
|
|
414
|
+
the field that reached for it.
|
|
415
|
+
|
|
416
|
+
subgraphs: { "reviews" => :fake } # answer an absent subgraph with fabricated data
|
|
417
|
+
|
|
418
|
+
Faking is opt-in and never silent: a faked fetch is marked `faked: true` in
|
|
419
|
+
`router.trace`, logged at `:warn` per fetch, and listed by `router.faked` and
|
|
420
|
+
`#inspect`. It is deliberately not surfaced as a response error — that would
|
|
421
|
+
make `execute!` raise, defeating the point.
|
|
422
|
+
|
|
423
|
+
- `Testing::Subgraphs.resolve` now returns only the subgraphs this process
|
|
424
|
+
serves instead of raising when one has no candidate. Two candidates still
|
|
425
|
+
refuse, naming both.
|
|
426
|
+
|
|
427
|
+
#### One tag picks what a test runs against
|
|
428
|
+
|
|
429
|
+
`auto_fake` and `config.router` each installed a client for **every** example
|
|
430
|
+
and refused to coexist, so a suite had to choose fakes or real resolvers once,
|
|
431
|
+
for everything — and running in-process against a live schema had no configured
|
|
432
|
+
mode at all. Now an rspec tag says it per example, or per group:
|
|
433
|
+
|
|
434
|
+
it "renders the empty state", graphql: :fake do … end
|
|
435
|
+
it "authorizes drafts", graphql: :in_process do … end
|
|
436
|
+
describe "checkout", graphql: :router do … end
|
|
437
|
+
|
|
438
|
+
`rspec --tag graphql:router` runs one mode's examples. The tag is namespaced
|
|
439
|
+
under one `graphql:` key on purpose: a bare `:fake` or `:router` would collide
|
|
440
|
+
with an app's own metadata and silently change which client an unrelated
|
|
441
|
+
example runs against.
|
|
442
|
+
|
|
443
|
+
**Nothing needs configuring.** Each mode derives what it runs against and
|
|
444
|
+
refuses — naming what it looked for — rather than guessing. The schema is
|
|
445
|
+
`config.schema` if you set one, else the committed dump, else the schema
|
|
446
|
+
`GraphWeaver.client` talks to. `:in_process` finds the live schema *class*: the
|
|
447
|
+
one your client already runs in-process, else the loaded class defining
|
|
448
|
+
everything that schema declares (the rule `subgraphs:` detection already uses).
|
|
449
|
+
`:router` plans against the dump when the dump is a composed supergraph — a
|
|
450
|
+
federated suite whose checked-in dump is the supergraph needs no config at all.
|
|
451
|
+
|
|
452
|
+
- **New:** `graphql_context(current_user: user)` sets the context your
|
|
453
|
+
resolvers see. It merges onto `config.context` and is reset before the next
|
|
454
|
+
example, so an example running as somebody else can't leak into the one
|
|
455
|
+
after. Pass a block to scope it. Under `graphql: :fake` it refuses — there
|
|
456
|
+
are no resolvers to receive it.
|
|
457
|
+
- **New:** `config.default_mode` is what an untagged example runs against
|
|
458
|
+
(`nil`, the default, leaves `GraphWeaver.client` alone). It replaces
|
|
459
|
+
`config.auto_fake`, which still works as the old spelling of
|
|
460
|
+
`default_mode = :fake`.
|
|
461
|
+
- **New:** `config.context` — the baseline every `:in_process` and `:router`
|
|
462
|
+
example starts from. `config.router = { context: … }` now refuses and points
|
|
463
|
+
here; the per-example reset would have overwritten it.
|
|
464
|
+
- `GraphWeaver.execute`, `.new` and `Client.new` now refuse a *client* where a
|
|
465
|
+
schema source belongs — an `InProcess`, `Retry`, transport or fake used to
|
|
466
|
+
crash with `undefined method 'lstrip'`. The message names both ways to say
|
|
467
|
+
what you meant.
|
|
468
|
+
|
|
469
|
+
#### Ruby-keyword field names now generate
|
|
470
|
+
|
|
471
|
+
A result key that underscores to a Ruby keyword — `pageInfo { next }`,
|
|
472
|
+
`filter { in }` — no longer refuses to generate. A prop is only ever read off
|
|
473
|
+
a receiver, so `const :next` is fine; the one bare read, an `alias:`
|
|
474
|
+
delegator's first hop, now spells `self.next`. Output props keep only the ban
|
|
475
|
+
the input side already had: names every `T::Struct` already answers to
|
|
476
|
+
(`class`, `hash`, `serialize`). If you aliased a query around this, you can
|
|
477
|
+
drop the alias and regenerate. `GraphWeaver::Codegen::RESERVED_PROPS` is gone
|
|
478
|
+
— `STRUCT_METHODS` is the whole rule now.
|
|
479
|
+
|
|
480
|
+
- `optional: true` on an `alias:` no longer hides a path segment the schema has
|
|
481
|
+
no field for. It still skips a field this query didn't select — that is what
|
|
482
|
+
it is for — but a typo, or the classic `findPets` where the path is the Ruby
|
|
483
|
+
prop chain, now raises and says which of the two it looks like. If an
|
|
484
|
+
optional alias resolved only through a query-level rename (`{ renamed: meta }`),
|
|
485
|
+
it will now raise on queries that don't select that key.
|
|
486
|
+
- **New:** `GraphWeaver.reset_enums!`, `GraphWeaver.reset_type_helpers!` and
|
|
487
|
+
`GraphWeaver.reset_registrations!` — the registry resets scalars already had.
|
|
488
|
+
`reset_registrations!` is the clean slate to reach for between tests.
|
|
489
|
+
- An alias error no longer names the same type twice when a query module and
|
|
490
|
+
its root type share a name.
|
|
491
|
+
|
|
492
|
+
#### Testing::Router now plans a real query, not just a single-subgraph one
|
|
493
|
+
|
|
494
|
+
`GraphWeaver::Testing::Router` used to hand one operation to one subgraph
|
|
495
|
+
verbatim and refuse anything that crossed a boundary. It now splits at the
|
|
496
|
+
crossing, refetches the entity from its `@key` through
|
|
497
|
+
`_entities(representations:)`, and stitches — batching every node at a level
|
|
498
|
+
into one call, running root query fields that span subgraphs as one fetch
|
|
499
|
+
each, and fetching a `@requires` field set from the subgraph that holds it
|
|
500
|
+
before the field that needs it. On the demo corpus that moves 10/17 queries
|
|
501
|
+
plannable to 17/17. It still refuses, at plan time, every shape it can't
|
|
502
|
+
answer the way a real router would.
|
|
503
|
+
|
|
504
|
+
`subgraphs:` is now **optional**: each subgraph's Ruby schema is derived from
|
|
505
|
+
what the loaded schemas define, and refuses rather than guesses when two
|
|
506
|
+
match or none do. An explicit map (or a partial one) still wins, and is now
|
|
507
|
+
checked the same way — a mis-wired entry fails at construction naming what it
|
|
508
|
+
doesn't define, instead of surfacing three fetches later.
|
|
509
|
+
|
|
510
|
+
- **New:** `config.router = { supergraph: "supergraph.graphql" }` in
|
|
511
|
+
`graph_weaver/rspec` runs every example against your real subgraph
|
|
512
|
+
resolvers.
|
|
513
|
+
- **New:** `rake graph_weaver:federation:subgraphs` prints the subgraph map
|
|
514
|
+
detection sees, with the evidence for each match.
|
|
515
|
+
- **New:** `Testing::Router#context` is settable, so one example can run as a
|
|
516
|
+
different user without rebuilding the router.
|
|
517
|
+
- `Testing::Unplannable`'s `:requires` category is **gone** — the gap it named
|
|
518
|
+
is closed. `:root_fields_span` now applies only to mutations (query roots
|
|
519
|
+
are planned). New categories: `:no_key`, `:abstract_boundary`,
|
|
520
|
+
`:nested_field_set`, `:shadowed_key`.
|
|
521
|
+
- The coverage report's second line now names every subgraph a query touches
|
|
522
|
+
(`accounts+reviews`), not just the one it ran in.
|
|
523
|
+
- `rake graph_weaver:schema:diff`, `schema:refresh` and `cassettes:anonymize`
|
|
524
|
+
now load the Rails environment first, so an initializer's settings apply.
|
|
525
|
+
|
|
526
|
+
**`GraphWeaver::Testing::Router` — a local federation router for tests.** Give
|
|
527
|
+
it a supergraph and your subgraph schema classes and it satisfies the client
|
|
528
|
+
slot, so `GraphWeaver.client = router` runs every generated module against real
|
|
529
|
+
resolvers in-process: no gateway, no node, no sockets. It plans one shape — a
|
|
530
|
+
query whose every field resolves in a single subgraph, passed to that subgraph
|
|
531
|
+
verbatim — and raises `Unplannable` (a `GraphWeaver::Error`) for anything that
|
|
532
|
+
crosses a boundary, at plan time, before any subgraph runs. See
|
|
533
|
+
[docs/testing.md](docs/testing.md#a-local-federation-router).
|
|
534
|
+
|
|
535
|
+
**`rake graph_weaver:federation:coverage SUPERGRAPH=…` says how much of your
|
|
536
|
+
query set that router can plan**, and groups every refusal by what stopped it —
|
|
537
|
+
the number that decides whether wiring it up is worth it. Planning needs the
|
|
538
|
+
supergraph alone, so it runs in CI with no subgraph loadable.
|
|
539
|
+
|
|
540
|
+
**A supergraph's routing table is now readable:
|
|
541
|
+
`GraphWeaver::SchemaLoader.routing_table(supergraph)`.** `load` strips the
|
|
542
|
+
`@join__*` machinery to get the API schema; this keeps it — `owners("Product",
|
|
543
|
+
"shippingEstimate") # => ["reviews"]`, each type's `@key` field sets, and which
|
|
544
|
+
copies are `@external`. A `@join__` directive it hasn't been taught lands in
|
|
545
|
+
`unsupported` rather than being skipped.
|
|
546
|
+
|
|
547
|
+
**`Representations.<entity>` for an entity the query didn't select now says
|
|
548
|
+
what to do.** Builders are query-driven, so `Representations.warehouse(...)`
|
|
549
|
+
raised a bare `NoMethodError` naming nothing. It now names the builders this
|
|
550
|
+
query does have and the selection to add (`... on Warehouse { __typename }`).
|
|
551
|
+
|
|
552
|
+
**Shared-fragment directories are scanned recursively, and `.gql` files count.**
|
|
553
|
+
The scan was `fragments/*.graphql`, so `fragments/person/fields.graphql` — how
|
|
554
|
+
anyone with sixty fragments organizes them — was skipped in silence, and a
|
|
555
|
+
`.gql` file was ignored even though `parse("x.gql")` reads one. A duplicate
|
|
556
|
+
fragment name now names both files that define it.
|
|
557
|
+
|
|
558
|
+
**Query directories are scanned the same way — recursively, `.gql` included.**
|
|
559
|
+
`queries/admin/pets.graphql` produced nothing at all: no file, no error.
|
|
560
|
+
`generate!`, `check_queries` and `client.load_queries!` now walk the tree, and
|
|
561
|
+
`.gql` no longer leaks its extension into the module name. Directories organize
|
|
562
|
+
queries but do not namespace them — `queries/admin/pets.graphql` is still
|
|
563
|
+
`PetsQuery` in `pets_query.rb` — so two files with the same base name are
|
|
564
|
+
refused at generation, naming both, rather than one silently overwriting the
|
|
565
|
+
other's generated file. The scaffolded `graphql.config.yml` matches
|
|
566
|
+
(`**/*.{graphql,gql}`).
|
|
567
|
+
|
|
568
|
+
**`execute` now takes one kwarg per declared variable, always — a single
|
|
569
|
+
required input-object variable is no longer flattened into per-field kwargs.**
|
|
570
|
+
`mutation($input: AdoptionInput!)` generated `execute!(name:, species:, …)`,
|
|
571
|
+
but adding any second variable generated `execute!(input:, …)` instead — so an
|
|
572
|
+
unrelated edit to a query silently reshaped every call site, and the rule
|
|
573
|
+
couldn't be stated without its exception. It also made a schema's own field
|
|
574
|
+
names load-bearing: a field named `client` or `in` can't be a kwarg and can't
|
|
575
|
+
be renamed, so flattening quietly declined and the surface moved again.
|
|
576
|
+
**Rewrite affected call sites to pass the input as one kwarg:**
|
|
577
|
+
`AdoptMutation.execute!(input: { name: "Rex", species: "DOG" })`, or
|
|
578
|
+
`input: AdoptMutation::AdoptionInput.new(name: "Rex", species: Species::Dog)`
|
|
579
|
+
for the field-by-field static check.
|
|
580
|
+
|
|
581
|
+
**An input field named after a Ruby keyword no longer makes a schema
|
|
582
|
+
ungeneratable.** `StringQueryOperatorInput.in` — the standard Hasura/Gatsby
|
|
583
|
+
filter shape — raised "would become prop 'in', which collides with a Ruby
|
|
584
|
+
keyword", with no way out: an input field is the schema's name, not yours, and
|
|
585
|
+
`extend_type alias:` is output-only. But `prop :in` is legal Ruby, and nothing
|
|
586
|
+
reads an input prop bare (`serialize` goes through `public_send`), so the
|
|
587
|
+
refusal was over-broad. Input fields named `in`, `end`, `def`, `nil` and the
|
|
588
|
+
rest now generate. A field colliding with a method every struct defines
|
|
589
|
+
(`serialize`, `to_h`, `class`, `hash`) is still refused — those break at
|
|
590
|
+
require time. Output structs are unchanged: a result key *can* be renamed, in
|
|
591
|
+
the query.
|
|
592
|
+
|
|
593
|
+
**A variable named `$client` no longer generates a file that won't parse.**
|
|
594
|
+
`query($client: ID!)` emitted `def self.execute(client = nil, client:)` — a
|
|
595
|
+
`SyntaxError` raised at app boot from `load_generated!`, arbitrarily far from
|
|
596
|
+
the query that caused it, while `verify_generated!` reported the tree as
|
|
597
|
+
current. Generation now refuses `$client`, `$variables` and `$transport` — the
|
|
598
|
+
three locals the generated `execute` body owns — naming the fix. **Rename such
|
|
599
|
+
a variable in the query (`query($clientId: ID!)`) before regenerating.**
|
|
600
|
+
|
|
601
|
+
**`auto_coerce` no longer erases the typing of String/ID variables.** It mapped
|
|
602
|
+
both to `#to_s`, which widened their kwargs to `T.anything` — the majority of
|
|
603
|
+
real variables, statically unchecked, in exchange for a cast that can't fail.
|
|
604
|
+
`auto_coerce` now covers only the conversions that are conversions (`Int`→`to_i`,
|
|
605
|
+
`Float`→`to_f`) plus scalars with a full cast/serialize pair. **If you relied on
|
|
606
|
+
a String/ID kwarg accepting anything, opt in per scalar:**
|
|
607
|
+
`GraphWeaver.register_scalar("ID", String, coerce: :to_s)`.
|
|
608
|
+
|
|
609
|
+
**An anonymous operation is now named after its module — in the query text and
|
|
610
|
+
in `OPERATION_NAME`.** Requests started carrying `operationName` so servers and
|
|
611
|
+
APMs can attribute traffic, but the constant was only set when the `.graphql`
|
|
612
|
+
document named its operation — and anonymous is what the docs show, so every
|
|
613
|
+
trace arrived `anonymous` and the feature did nothing for the documented happy
|
|
614
|
+
path. `person.graphql` holding `query($id: ID!) { ... }` now emits
|
|
615
|
+
`query PersonQuery($id: ID!) { ... }` with `OPERATION_NAME = "PersonQuery"`.
|
|
616
|
+
Both halves move together: a server rejects an `operationName` its document
|
|
617
|
+
doesn't declare. A document that names its own operation is left untouched.
|
|
618
|
+
|
|
619
|
+
**Cassette files no longer store the request twice — re-record them.** Every
|
|
620
|
+
entry carried a `key:` (the normalized query + variables) *and* a `query:` and
|
|
621
|
+
`variables:` again, and replay matched on `key:` alone: editing the half a
|
|
622
|
+
reviewer reads changed nothing, editing the other half broke replay while the
|
|
623
|
+
file still looked right. The key is now derived from `query`/`variables`/
|
|
624
|
+
`operationName` at load, so the file holds the request once and diffs are real.
|
|
625
|
+
**Existing cassettes must be re-recorded** (`GRAPHWEAVER_RECORD=1`, or delete
|
|
626
|
+
the file) — this also covers cassettes of anonymous operations, which stopped
|
|
627
|
+
matching when entries started keying on `operationName`.
|
|
628
|
+
|
|
629
|
+
**`MissingRecording` now prints the variables — the part that usually differs.**
|
|
630
|
+
It printed the whole query and omitted the variables entirely, so the common
|
|
631
|
+
miss (same query, different variables) showed you 60 lines identical to the
|
|
632
|
+
YAML and nothing about the mismatch. The message now leads with the request's
|
|
633
|
+
variables, says what was recorded for that query (`1 entry recorded for this
|
|
634
|
+
query, with variables {"id" => "1"}`), and prints the query as one truncated
|
|
635
|
+
line.
|
|
636
|
+
|
|
637
|
+
**A first run with no cassette and no `client:` no longer raises
|
|
638
|
+
`MissingRecording`.** There is no request yet, so it raises `GraphWeaver::Error`
|
|
639
|
+
naming the actual situation. **Rescue `GraphWeaver::Error` if you were catching
|
|
640
|
+
`MissingRecording` for this case.**
|
|
641
|
+
|
|
642
|
+
**`Cassette.use` is now `GraphWeaver::Testing.cassette` — rename your calls.**
|
|
643
|
+
It never returned a `Cassette`; it returns a *client* (a recorder or a replayer)
|
|
644
|
+
to hand to `execute`, and the name said otherwise. `Cassette` is now only the
|
|
645
|
+
file — `.new`, `#size`, `#anonymize!`.
|
|
646
|
+
|
|
647
|
+
**Record mode with no `client:` now raises instead of replaying.**
|
|
648
|
+
`GRAPHWEAVER_RECORD=1` on a `Testing.cassette(name)` call with nothing to record
|
|
649
|
+
against quietly served the stale recording, so "re-record everything" produced a
|
|
650
|
+
half-refreshed cassette set with no signal. **Pass `client:` to every call you
|
|
651
|
+
want re-recorded.**
|
|
652
|
+
|
|
653
|
+
**`Recorder.new(..., anonymize:)` is gone.** It was unreachable through the
|
|
654
|
+
factory and duplicated `Testing.config.anonymize`. **Set the config flag** —
|
|
655
|
+
that's the one way to anonymize, with `rake graph_weaver:cassettes:anonymize`
|
|
656
|
+
as the cleanup tool for cassettes recorded before you turned it on.
|
|
657
|
+
|
|
658
|
+
**`FakeClient.new` no longer requires `schema:`.** Every other option fell back
|
|
659
|
+
to `Testing.config`; this one didn't, even though `config.schema` already
|
|
660
|
+
auto-locates the committed dump. `FakeClient.new` now works on its own, and
|
|
661
|
+
says what to set when no schema resolves at all.
|
|
662
|
+
|
|
663
|
+
**`GraphWeaver.queries_paths` (plural) is gone — use `queries_path`.**
|
|
664
|
+
`generate!` and `check_queries` read the singular (the first entry) while
|
|
665
|
+
`load_queries!` walked the whole list, so a second queries directory produced
|
|
666
|
+
modules at runtime that `rake graph_weaver:generate` never generated and
|
|
667
|
+
`verify` never checked — silently. Queries are single-schema by design. **If
|
|
668
|
+
you appended a second queries directory, fold it into the first** (or run a
|
|
669
|
+
second `generate!` with its own `queries:`). `generated_paths` and
|
|
670
|
+
`fragments_paths` stay plural; they genuinely load from several places.
|
|
671
|
+
|
|
672
|
+
**One GraphQL enum is now one Ruby type.** A schema enum a query touches — as
|
|
673
|
+
a variable, in a result, or both — is emitted once per schema into
|
|
674
|
+
`generated/enums.rb` as `GraphQLEnums::<Enum>`, and every query module aliases
|
|
675
|
+
it. Before, an enum read out of a result got a class named for the response key
|
|
676
|
+
and nested in the struct that selected it (`SearchQuery::Result::Search::Pet::Species`),
|
|
677
|
+
while the same enum used as a variable got a module-level one — so whether a
|
|
678
|
+
schema enum was one Ruby type or three depended on what else the query happened
|
|
679
|
+
to reference, and handing a value from one query into another's variable raised
|
|
680
|
+
a `TypeError` that wasn't even a `GraphWeaver::Error`.
|
|
681
|
+
|
|
682
|
+
**Regenerate, and expect enum constants to move.** A nested enum path in app
|
|
683
|
+
code becomes the query module's own alias — `SearchQuery::Species` — or
|
|
684
|
+
`GraphQLEnums::Species`; `srb tc` finds them all. The enums a shared fragment's
|
|
685
|
+
union members select are hoisted too, so `unions.rb` now aliases them rather
|
|
686
|
+
than re-emitting them.
|
|
687
|
+
|
|
688
|
+
**The shared module names no longer depend on your output directory.** They are
|
|
689
|
+
`GraphQLInputs`, `GraphQLUnions` and `GraphQLEnums`, full stop. The old rule
|
|
690
|
+
camelized the parent of `generated/` unless it was on a hardcoded blocklist, so
|
|
691
|
+
`output: "gen2"` gave you `Gen2Inputs` and renaming `app/graphql/generated` to
|
|
692
|
+
`app/gql/generated` renamed a public constant. **A multi-schema layout must now
|
|
693
|
+
name its modules explicitly** — `GraphWeaver.inputs_module=` /
|
|
694
|
+
`unions_module=` / `enums_module=`, or `generate!(inputs_module:, ...)` — in the
|
|
695
|
+
same initializer that already gives each schema its paths. `GraphWeaver.inputs_module`
|
|
696
|
+
and `unions_module` no longer take an output-path argument.
|
|
697
|
+
|
|
698
|
+
**One registration registry, not two.** `Client#register_scalar`,
|
|
699
|
+
`#register_enum`, `#register_enums` and `#extend_type` are **deleted** — a
|
|
700
|
+
client-scoped registration was invisible to `GraphWeaver.generate!` (the rake
|
|
701
|
+
tasks have no client), so the console typed a field richly and the checked-in
|
|
702
|
+
code silently generated `T.untyped`. **Move any `client.register_*` /
|
|
703
|
+
`client.extend_type` call to the `GraphWeaver.` form** (an initializer, next to
|
|
704
|
+
the rest of your config). The one thing client scoping bought — two servers
|
|
705
|
+
disagreeing about a scalar — is what the per-field coordinate form is for:
|
|
706
|
+
`GraphWeaver.register_scalar("User.birthday", Date)`.
|
|
707
|
+
|
|
708
|
+
Also gone with it: `GraphWeaver.register_enums` (bulk) — there was never a
|
|
709
|
+
`register_scalars` to match it, so call `register_enum` per line — and
|
|
710
|
+
`GraphWeaver.reject_positional_map!`, now folded into the one
|
|
711
|
+
`Codegen.register_enum` that every door reaches (so all three doors give the
|
|
712
|
+
same "the value map is a keyword" error instead of a bare arity complaint).
|
|
713
|
+
`Codegen.parse` / `.generate` / `.generate_inputs` / `.generate_unions` no
|
|
714
|
+
longer take `scalars:`/`enums:`/`types:`.
|
|
715
|
+
|
|
716
|
+
**`generate!` now takes a Client where it takes a schema** — `GraphWeaver.generate!(schema: api)`,
|
|
717
|
+
`verify_generated!`, `check_queries` and `parse` all accept one, so the object
|
|
718
|
+
you built in the console is the object the build step wants and no schema dump
|
|
719
|
+
is needed. `client:` still means what it meant (a constant name to bake as
|
|
720
|
+
`DEFAULT_CLIENT`) and still refuses a live object.
|
|
721
|
+
**Rails integration fixes, found by running the gem in a real Rails app.**
|
|
722
|
+
|
|
723
|
+
- **Production boot no longer raises `uninitialized constant
|
|
724
|
+
Generated::PersonQuery`.** The default `generated_path` is
|
|
725
|
+
`app/graphql/generated`, which Zeitwerk claims as an autoload root, while
|
|
726
|
+
the files there define top-level constants. Development (lazy) was fine and
|
|
727
|
+
eager loading was not, so this only showed up in production or
|
|
728
|
+
`rails zeitwerk:check`. The Railtie now hides the generated directory from
|
|
729
|
+
the loader; nothing to configure.
|
|
730
|
+
- **`rake graph_weaver:generate` runs your initializer again.** The tasks
|
|
731
|
+
asked whether Rails' `:environment` task existed at *load* time, but Rails
|
|
732
|
+
defines it after every Railtie's `rake_tasks` block, so the answer was
|
|
733
|
+
always no. Generation and `verify` therefore ran without booting the app —
|
|
734
|
+
silently dropping every `register_scalar` / `register_enum` / `extend_type`
|
|
735
|
+
in `config/initializers`, and generating code that disagreed with the
|
|
736
|
+
running app. **Regenerate**: if you register anything in an initializer,
|
|
737
|
+
your committed generated files are wrong, and `rake graph_weaver:verify`
|
|
738
|
+
will now say so.
|
|
739
|
+
- `generate`, `verify` and `schema:diff` report a `GraphWeaver::Error` the
|
|
740
|
+
way `schema:refresh` already did — the message, and a non-zero exit,
|
|
741
|
+
instead of a rake backtrace through codegen.
|
|
742
|
+
|
|
743
|
+
**`rails g graph_weaver:install` takes any source `GraphWeaver.new` takes.**
|
|
744
|
+
The source is one positional argument — an endpoint, a schema class or an
|
|
745
|
+
existing dump all work the same way:
|
|
746
|
+
|
|
747
|
+
```sh
|
|
748
|
+
rails g graph_weaver:install https://api.example.com/graphql
|
|
749
|
+
rails g graph_weaver:install MyApp::Schema # in-process, no socket
|
|
750
|
+
rails g graph_weaver:install db/schema.graphql # a dump you already have
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
The initializer reflects the form chosen: a schema class is resolved in a
|
|
754
|
+
`to_prepare` block (it is autoloaded, so an initializer can not read it, and a
|
|
755
|
+
dev reload replaces the class object), and a dump you already have becomes
|
|
756
|
+
`GraphWeaver.schema_path` rather than being copied. `--auth` and the
|
|
757
|
+
introspection step are url-only; a source that can not use them, a constant
|
|
758
|
+
that does not resolve, and a class that is not a schema are all refused
|
|
759
|
+
before any file is written.
|
|
760
|
+
|
|
761
|
+
**Generated struct names now come from the query's own field names.** A struct
|
|
762
|
+
is named for the response key that selects it — `stargazers` becomes
|
|
763
|
+
`Stargazers`, `edges` becomes `Edges` — so its name is a function of its own
|
|
764
|
+
position in the query and nothing else. Names came from GraphQL *type* names
|
|
765
|
+
before, disambiguated by field name only on collision, which meant **a second
|
|
766
|
+
selection of the same type renamed the first**: a silent break in checked-in
|
|
767
|
+
code your app references. Deep queries could also collide outright and refuse
|
|
768
|
+
to generate.
|
|
769
|
+
|
|
770
|
+
**Regenerate, and expect renames.** Nearly every nested struct changes name
|
|
771
|
+
(`PersonQuery::Result::Person::Pet` becomes `...::Person::Pets`), and app code
|
|
772
|
+
naming one won't typecheck until it's updated — `srb tc` finds them all. The
|
|
773
|
+
payoff: adding, removing, or reordering an unrelated selection can never move
|
|
774
|
+
a name again.
|
|
775
|
+
|
|
776
|
+
- The key is used verbatim, with no pluralization heuristic — a list field
|
|
777
|
+
`pets` generates `Pets`. To pick a different name, alias the field in the
|
|
778
|
+
query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
|
|
779
|
+
- Union and interface members keep their type-condition names (`... on Book`
|
|
780
|
+
gives `Book`), inside a container named for the field; a union hoisted from
|
|
781
|
+
a shared fragment is still named for the fragment.
|
|
782
|
+
- Two ties that walk order used to settle now resolve on their own: fields
|
|
783
|
+
sharing one collapsed union type take the first of their keys
|
|
784
|
+
alphabetically, and a name that would shadow the struct it nests in
|
|
785
|
+
(`pet { pet { ... } }`) takes a numeric suffix (`Pet2`).
|
|
786
|
+
|
|
787
|
+
**Requests now send `operationName`** — every graph_weaver request used to be
|
|
788
|
+
anonymous in Apollo Studio, Hasura, and any APM that keys traces, rate limits
|
|
789
|
+
and slow-query reports on it. Generated modules emit their operation name as
|
|
790
|
+
`OPERATION_NAME` beside `QUERY` and send it on the wire; a raw query string
|
|
791
|
+
handed to a transport falls back to the name in the document. In-process
|
|
792
|
+
execution passes it to `Schema.execute(operation_name:)`, which also makes a
|
|
793
|
+
multi-operation document selectable there.
|
|
794
|
+
|
|
795
|
+
To get the benefit, **name your operations** — `query Person($id: ID!)`, not
|
|
796
|
+
`query($id: ID!)` — and regenerate. An anonymous operation still works and
|
|
797
|
+
sends no `operationName`.
|
|
798
|
+
|
|
799
|
+
Three breaking changes come with it:
|
|
800
|
+
- **The client-slot contract widened to
|
|
801
|
+
`execute(query, variables:, operation_name: nil)`.** If you wrote your own
|
|
802
|
+
transport, client, or test double, add the kwarg — a client that doesn't
|
|
803
|
+
accept it now raises `ArgumentError: unknown keyword: :operation_name`. A
|
|
804
|
+
graphql-ruby `Schema` class already takes it, so bare schemas in the client
|
|
805
|
+
slot are unaffected. Subclasses of `GraphWeaver::Transport` only implement
|
|
806
|
+
`post(body)` and need no change.
|
|
807
|
+
- **Cassettes are keyed on `operationName` too**, so two operations in one
|
|
808
|
+
document can't collide. Cassettes recorded from a *named* operation before
|
|
809
|
+
this release no longer match — re-record them
|
|
810
|
+
(`GRAPHWEAVER_RECORD=1 bundle exec rspec`). Anonymous ones are unaffected.
|
|
811
|
+
- **`GraphWeaver::Transport.log_tag` takes an operation name, not a query
|
|
812
|
+
string** (`log_tag(query)` → `log_tag(operation_name)`); the constant
|
|
813
|
+
`Transport::OPERATION_NAME` is now `Transport::OPERATION_NAME_PATTERN`, since
|
|
814
|
+
generated modules define an `OPERATION_NAME` of their own.
|
|
815
|
+
|
|
816
|
+
Codegen bug fixes from the library review (all with regression coverage):
|
|
817
|
+
- Narrowing (`... on X` and nothing else) now reads the match off `__typename`
|
|
818
|
+
when the selection carries it, instead of off "the object came back empty".
|
|
819
|
+
Selecting `__typename` guaranteed a non-empty object, so **every non-matching
|
|
820
|
+
member was cast into `X`'s struct** — loudly when it had a non-null field,
|
|
821
|
+
silently when all its fields were nullable. Regenerate: any query mixing
|
|
822
|
+
`__typename` with a single type condition (the `_entities { __typename
|
|
823
|
+
... on Widget { … } }` federation shape) was mistyped and now filters
|
|
824
|
+
correctly.
|
|
825
|
+
- A dispatched union/interface now requires its `__typename` to be unaliased and
|
|
826
|
+
free of `@skip`/`@include` — `from_h` reads it unguarded, so either would have
|
|
827
|
+
raised at runtime. Fix the selection if generation now refuses it.
|
|
828
|
+
- **Unions and interfaces generate per named condition, plus one catch-all
|
|
829
|
+
`Other`** — not one struct per schema member. A two-condition query against
|
|
830
|
+
GitHub's `Node` (278 implementations) went from 5,386 lines / 279 structs to
|
|
831
|
+
162 lines / 4. **Regenerate, and expect member names to move**: a type your
|
|
832
|
+
query names no fields on is now `Other` rather than its own struct, so a
|
|
833
|
+
`case` over the members needs an `Other` branch (`T.absurd` will tell you).
|
|
834
|
+
In exchange, a `__typename` the query doesn't name — including a **member the
|
|
835
|
+
schema grows after you generate** — deserializes into `Other` instead of
|
|
836
|
+
raising `unexpected __typename`, so adding a union member upstream stays the
|
|
837
|
+
non-breaking change GraphQL says it is.
|
|
838
|
+
- `@skip`/`@include` on an inline fragment or a named spread now makes the
|
|
839
|
+
fields under it nilable, as it always did for a directly-marked field —
|
|
840
|
+
previously they kept non-null typing and a `data.fetch`, so a skipped block
|
|
841
|
+
raised `key not found`. The narrowing guard sees the fragment's own directive
|
|
842
|
+
too. Conversely, a field selected both conditionally and unconditionally is no
|
|
843
|
+
longer over-nilable: one unguaranteed selection doesn't unmake the guarantee.
|
|
844
|
+
- List variables coerce per element, so an enum inside a list accepts its wire
|
|
845
|
+
value the way a scalar enum already did (`sort: ["POPULARITY_DESC"]` used to
|
|
846
|
+
raise `NoMethodError: undefined method 'serialize' for String`). Input-object
|
|
847
|
+
and custom-scalar elements coerce in lists too.
|
|
848
|
+
|
|
849
|
+
- Federation schemas that previously wouldn't load now do:
|
|
850
|
+
- a supergraph whose `schema` definition carries a non-`@link` directive
|
|
851
|
+
(`@tag`, `@composeDirective`, a composed custom one) no longer dies with a
|
|
852
|
+
`GraphQL::ParseError` pointing into a document you never wrote.
|
|
853
|
+
- **raw subgraph SDL loads** — what `rover subgraph fetch`, `_service { sdl }`,
|
|
854
|
+
or your service repo's `.graphql` gives you. The federation directives a
|
|
855
|
+
subgraph applies but doesn't declare (`@key`, `@external`, `@shareable`, …)
|
|
856
|
+
are supplied on load, for both fed-1 and `@link`-style subgraphs. Note the
|
|
857
|
+
`@inaccessible` subtraction stays supergraph-only: a subgraph keeps those
|
|
858
|
+
fields, because it is not the public contract.
|
|
859
|
+
- A schema that won't build now raises `GraphWeaver::Error` naming the artifact
|
|
860
|
+
we took the source for (supergraph / subgraph / plain SDL / introspection),
|
|
861
|
+
instead of whatever graphql-ruby's internals happened to raise — a
|
|
862
|
+
`NoMethodError`, a `ParseError` pointing into a document you never wrote, a
|
|
863
|
+
bare `RuntimeError`. **Rescuing the raw graphql-ruby classes no longer
|
|
864
|
+
catches these.** The `@inaccessible` cascade also prunes a directive
|
|
865
|
+
definition's own arguments.
|
|
866
|
+
- **Single-line SDL loads.** `SchemaLoader.load("type Query { hi: String }")` —
|
|
867
|
+
the shape you type in a console — was rejected as "unsupported schema format",
|
|
868
|
+
because a string had to contain a newline to count as content rather than a
|
|
869
|
+
path.
|
|
870
|
+
- Rejecting a schema source is branded too, so the error class no longer depends
|
|
871
|
+
on which branch rejected it: an unsupported format and an unreadable file both
|
|
872
|
+
raise `GraphWeaver::Error` (were `ArgumentError` and `Errno::ENOENT`). A bare
|
|
873
|
+
host now says so — `"graphql.anilist.co" looks like a host; did you mean
|
|
874
|
+
"https://graphql.anilist.co"?` — instead of pointing at the file system.
|
|
875
|
+
- Cassette recording accepts a `GraphWeaver::Client` — the call
|
|
876
|
+
`docs/cassettes.md` shows (`Testing.cassette("github", client: live)`),
|
|
877
|
+
which failed with `ArgumentError: missing keywords`. And a client that can't
|
|
878
|
+
`execute` is now rejected on the spot, with its class named, rather than
|
|
879
|
+
surfacing later as `NoMethodError … for an instance of Hash`.
|
|
880
|
+
- Generated structs answer `respond_to?` the way `method_missing` behaves, so
|
|
881
|
+
`struct.method(:nmae)` gets the same "did you mean" hint the direct call does.
|
|
882
|
+
- `@oneOf` input objects enforce exactly one field. The schema can't express it
|
|
883
|
+
— every `@oneOf` field is nullable — so the struct accepted zero or many and
|
|
884
|
+
the server rejected the round trip; supplying the wrong number now raises
|
|
885
|
+
`GraphWeaver::InputError` naming the type and the keys. **Regenerate** to pick
|
|
886
|
+
it up.
|
|
887
|
+
- An enum whose values differ only in case (`enum E { active ACTIVE }`) is
|
|
888
|
+
refused at generation naming both wire values, instead of emitting two
|
|
889
|
+
`Active` constants and raising `RuntimeError: Enum values must be assigned to
|
|
890
|
+
constants` when the file loads. **Map such an enum onto one of yours**
|
|
891
|
+
(`register_enum`). `AB`/`A_B` and `IN_PROGRESS`/`INPROGRESS` still generate
|
|
892
|
+
fine — they name distinct constants.
|
|
893
|
+
- A `.graphql` file that won't parse raises `GraphWeaver::ValidationError`
|
|
894
|
+
**naming the file**, instead of a bare `GraphQL::ParseError` whose `[6, 1]`
|
|
895
|
+
pointed into a document you never wrote — fragment inlining parses on the
|
|
896
|
+
`generate!` path before `Codegen#generate`'s rescue could brand it. Fragment
|
|
897
|
+
files get the same treatment.
|
|
898
|
+
- Generated `from_response` shape-checks the envelope, so a malformed one stays
|
|
899
|
+
under `GraphWeaver::Error`. A non-object `data`, a `Hash` (or an array of
|
|
900
|
+
strings) for `errors`, and non-object `extensions` all escaped as a raw Sorbet
|
|
901
|
+
`TypeError` — the `data` one from `from_h`'s sig, before the struct's own
|
|
902
|
+
rescue could see it. A body that isn't an object at all deserialized to an
|
|
903
|
+
empty envelope (`String#[]` answers `"data"` with nil); it now raises.
|
|
904
|
+
- The generated `from_h` rescues `StandardError`, not just
|
|
905
|
+
`TypeError`/`ArgumentError`/`KeyError` — a registered scalar whose cast raises
|
|
906
|
+
anything else (`JSON::ParserError`, `URI::InvalidURIError`, your
|
|
907
|
+
`Money::ParseError`) escaped the umbrella. **Regenerate** to pick both up.
|
|
908
|
+
- A document holding more than one operation is refused at generation. Only the
|
|
909
|
+
first was ever typed, and the whole document went on the wire with no
|
|
910
|
+
`operationName`, so the request came back "Must provide operation name" —
|
|
911
|
+
**split multi-operation files into one operation each.**
|
|
912
|
+
- Result keys are checked before they become props, so generation refuses what
|
|
913
|
+
used to be an unloadable file. Two keys that underscore to the same prop
|
|
914
|
+
(`{ name Name: name }` — a plain alias, no exotic schema needed) raised
|
|
915
|
+
`ArgumentError: Attempted to redefine prop :name` at require time; so did a
|
|
916
|
+
field named `class`, `hash`, `send` or `frozen?`, which `T::Props` won't let a
|
|
917
|
+
struct redefine. **Alias the field in the query** (`classValue: class`) — the
|
|
918
|
+
error names the key and the spelling. The same reserved set now covers input
|
|
919
|
+
fields, which only checked Ruby keywords and `serialize`/`to_h` before.
|
|
920
|
+
- **Global registrations are validated against the schema**, like client-scoped
|
|
921
|
+
ones always were: `GraphWeaver.extend_type("Medai", …)` (or `register_scalar` /
|
|
922
|
+
`register_enum`) used to be a silent no-op, which is the failure mode
|
|
923
|
+
`docs/getting_started.md` step 3 walks you straight into — it now raises at
|
|
924
|
+
generation with the spellchecked hint. Registrations are global (see above),
|
|
925
|
+
so **drop any that names a type the schema you generate against doesn't
|
|
926
|
+
have**. The built-in scalars are exempt — a schema with no `Date` isn't a
|
|
927
|
+
mistake.
|
|
928
|
+
- `extend_type(requires:)` and `register_enum(requires:)` check each path is
|
|
929
|
+
loadable at registration, as `register_scalar(requires:)` already did and
|
|
930
|
+
`docs/scalars.md` already promised — a typo fails now, not in the generated
|
|
931
|
+
file.
|
|
932
|
+
- Docs: `docs/testing.md` passed the client to generated `execute` as a `client:`
|
|
933
|
+
kwarg — it's positional. `README.md` had module naming backwards for the
|
|
934
|
+
documented path (a file's module comes from the **file** name, not the
|
|
935
|
+
operation name). `docs/federation.md` covers subgraph SDL, federation v1
|
|
936
|
+
supergraphs, and that `@inaccessible` is subtracted only on the supergraph
|
|
937
|
+
path. `docs/cassettes.md` names `MissingRecording` correctly.
|
|
938
|
+
- **Federation namespaces are derived from the schema's own `@link`/`@core`
|
|
939
|
+
declarations** instead of a hardcoded `join__`/`link__`/`core__` list — the
|
|
940
|
+
spec URL's name segment gives the namespace, `as:` renames it, and `import:`
|
|
941
|
+
binds names into the root namespace (`{name: "@key", as: "@myKey"}` included).
|
|
942
|
+
Four things this fixes:
|
|
943
|
+
- a graph using fed-2.5+ auth (`@requiresScopes`/`@policy`/`@context`) no
|
|
944
|
+
longer leaks `federation__Scope`, `federation__Policy` or
|
|
945
|
+
`context__ContextFieldValue` into `schema.types`;
|
|
946
|
+
- a supergraph that renamed a spec (`@link(url: ".../join/v0.3", as: "j")`)
|
|
947
|
+
strips its `j__*` machinery — it previously failed to load at all;
|
|
948
|
+
- **a renamed `@inaccessible`** (`import: [{name: "@inaccessible", as:
|
|
949
|
+
"@private"}]`, or `as:` on the inaccessible spec) hides what it marks. It
|
|
950
|
+
was missed entirely before, so the derived API schema kept fields the
|
|
951
|
+
router does not serve and codegen over-permitted them. **Regenerate** if
|
|
952
|
+
your supergraph renames it.
|
|
953
|
+
- a `@core`-only fed-1 schema, and any composed graph carrying no `@join__`
|
|
954
|
+
marker, is now recognized as composed rather than loaded as plain SDL
|
|
955
|
+
(`core__Purpose` used to survive, and `@inaccessible` went unsubtracted).
|
|
956
|
+
|
|
957
|
+
- **Subgraph SDL loads with the entity resolver it serves.** No published
|
|
958
|
+
subgraph SDL contains `_entities`/`_service` — `rover subgraph fetch` and
|
|
959
|
+
`_service { sdl }` both print the schema, where the plumbing is implicit — so
|
|
960
|
+
the one query only a subgraph can describe couldn't be typed against the
|
|
961
|
+
artifact you have. Weaver now supplies `_Any`, `_Service` and an `_Entity`
|
|
962
|
+
union over the file's own `@key`'d types, alongside the `@key`/`@external`
|
|
963
|
+
definitions it already supplied. Supergraphs and plain SDL are untouched;
|
|
964
|
+
a file declaring its own `_entities` keeps it.
|
|
965
|
+
- **Typed `_entities` representations.** A query selecting entities now
|
|
966
|
+
generates a `Representations` builder per entity it can resolve, typed from
|
|
967
|
+
the `@key(fields:)` directives the subgraph SDL carries:
|
|
968
|
+
`UserQuery::Representations.user(id: "1")` → `{"__typename" => "User", "id"
|
|
969
|
+
=> "1"}`. `__typename` is injected, key fields are typed from the schema, and
|
|
970
|
+
a single `@key` makes them **required kwargs** — so an incomplete
|
|
971
|
+
representation is an `srb tc` error, not a round trip. Compound (`"upc sku"`)
|
|
972
|
+
and nested (`"organization { id }"`) key sets are parsed as the selection
|
|
973
|
+
sets they are; a type with two alternative keys takes them optionally and
|
|
974
|
+
raises `GraphWeaver::InputError` naming the type and what's missing when
|
|
975
|
+
neither is satisfied. Builders are emitted only for entities the query
|
|
976
|
+
actually reaches, and a key marked `resolvable: false` gets none.
|
|
977
|
+
**`Representations` joins `Result`/`QUERY` as a reserved module-level name**
|
|
978
|
+
— a shared fragment hoisting to it is now refused.
|
|
979
|
+
|
|
980
|
+
Transport improvements from the same review:
|
|
981
|
+
- **`Transport::HTTP` pools its connections** (`pool_size:`, default 5) instead
|
|
982
|
+
of serializing every request behind one socket and one mutex. The mutex was
|
|
983
|
+
held across the whole network round trip, so one transport — which is what
|
|
984
|
+
`GraphWeaver.client = api` gives a Rails app — allowed exactly one request in
|
|
985
|
+
flight process-wide. Against a 10 ms-latency server, 8 threads × 10 calls:
|
|
986
|
+
1059 ms before, 281 ms with the default pool of 5 (~3.8×). Sockets still open
|
|
987
|
+
lazily, stay keep-alive, and are dropped on any error so the next call
|
|
988
|
+
reconnects. **Lower `pool_size:` if your server counts connections per
|
|
989
|
+
client**; raise it to match a threaded web server's thread count.
|
|
990
|
+
- Both transports now send `Accept: application/graphql-response+json,
|
|
991
|
+
application/json;q=0.9` — the media type GraphQL-over-HTTP requires a
|
|
992
|
+
conforming client to accept, so a spec-conformant server can finally use the
|
|
993
|
+
newer status-code semantics — and `User-Agent: graph_weaver/<version>`, so
|
|
994
|
+
server operators can attribute the traffic. Previously the only header sent
|
|
995
|
+
was `Content-Type`, and net/http supplied `Accept: */*`. `headers:` still
|
|
996
|
+
overrides both; a prebuilt `Faraday::Connection` keeps whatever it carries.
|
|
997
|
+
- **`Transport::Faraday` takes `open_timeout:`/`read_timeout:` and defaults them
|
|
998
|
+
to 10s/30s**, the same as `Transport::HTTP`. It had no timeout knobs at all,
|
|
999
|
+
so it inherited net/http's 60s/60s — 6× and 2× the documented defaults. Both
|
|
1000
|
+
timeouts now also thread through the client: `GraphWeaver.new(url,
|
|
1001
|
+
read_timeout: 5)` works whichever transport is picked. Passing a timeout
|
|
1002
|
+
alongside a prebuilt `Faraday::Connection` raises, as `headers:` already did.
|
|
1003
|
+
The Faraday transport also logs its adapter at `:info` — the default
|
|
1004
|
+
`net_http` one opens a connection per request, which was invisible.
|
|
1005
|
+
- **New `GraphWeaver::InProcess`**, wrapping a live graphql-ruby schema class —
|
|
1006
|
+
`GraphWeaver.new(MySchema, context: { current_user: user })`. In-process
|
|
1007
|
+
execution worked but was blind in three ways: nothing supplied a `context:`,
|
|
1008
|
+
so a resolver reading `context[:current_user]` got nil (surfacing as "Cannot
|
|
1009
|
+
return null for non-nullable field Query.me"); all logging lived in
|
|
1010
|
+
`Transport#execute`, which an in-process schema bypasses, so not one line at
|
|
1011
|
+
DEBUG; and a resolver raise came out as a bare `RuntimeError` where the same
|
|
1012
|
+
failure over HTTP is a `ServerError`, so `rescue GraphWeaver::Error` caught
|
|
1013
|
+
one and missed the other. A resolver raise is now a `ServerError` (status
|
|
1014
|
+
500) with the original kept as `#cause` — in-process, the real backtrace is
|
|
1015
|
+
the point. **A bare schema class still works in any client slot**; the
|
|
1016
|
+
wrapper is an upgrade, not a requirement.
|
|
1017
|
+
- **`ServerError` carries the response `#headers`** (names downcased), plus
|
|
1018
|
+
`#retry_after` (seconds or HTTP-date, per RFC 9110) and `#rate_limited?`. The
|
|
1019
|
+
`Net::HTTPResponse` was always in hand and thrown away, so recovering
|
|
1020
|
+
`x-ratelimit-remaining` or a request id meant monkey-patching the transport.
|
|
1021
|
+
A `post` override may now return a third element, the headers; returning the
|
|
1022
|
+
documented `[status, body]` pair stays correct.
|
|
1023
|
+
- **`Retry` honours `Retry-After`** — the server's delay wins over the
|
|
1024
|
+
configured backoff, clamped to `max:` and not jittered. Related: **408 and
|
|
1025
|
+
429 now retry by default.** They were treated as ordinary 4xx ("your bug,
|
|
1026
|
+
retrying won't fix it"), which for the one status that exists to say "come
|
|
1027
|
+
back later" was exactly backwards, and left `Retry` incorrect against GitHub
|
|
1028
|
+
and Shopify. Pass `retry_if:` to restore the old behaviour.
|
|
1029
|
+
- **A throttling predicate, spelled the same everywhere**: `ServerError#throttled?`
|
|
1030
|
+
(429, or a 503 that says when to come back) and `QueryError#throttled?` /
|
|
1031
|
+
`Response#throttled?` (a throttle code in the errors array). An API says "slow
|
|
1032
|
+
down" with an HTTP status or with a code in a 200 body, and callers shouldn't
|
|
1033
|
+
have to know which. The codes are `GraphWeaver::GraphQLError::THROTTLE_CODES`
|
|
1034
|
+
— Shopify's `THROTTLED`, GitHub's `RATE_LIMITED`, and the common Apollo/Hasura
|
|
1035
|
+
spellings — so `retry_codes:` takes the constant instead of a hand-written
|
|
1036
|
+
string. `QueryError#to_h` gains `"throttled"` alongside `"schema_stale"`.
|
|
1037
|
+
- `Transport::HTTP` takes `ca_file:`/`ca_path:`/`cert:`/`key:`/`verify_mode:`,
|
|
1038
|
+
forwarded to `Net::HTTP.start` — a private CA or mTLS no longer means
|
|
1039
|
+
switching to Faraday, which was the real but undiscoverable answer. Passing
|
|
1040
|
+
one to an `http://` url raises instead of quietly doing nothing.
|
|
1041
|
+
- **An instrumentation seam**: `GraphWeaver.instrumenter = ->(event, payload,
|
|
1042
|
+
&block) { ... }`, a no-op until set, wrapping every request — over the wire
|
|
1043
|
+
and in-process, one seam for both. `ActiveSupport::Notifications` becomes a
|
|
1044
|
+
two-line adapter. The one event is `GraphWeaver::EXECUTE_EVENT`; its payload
|
|
1045
|
+
carries `:url`, `:schema`, `:operation` and `:status`, and deliberately not
|
|
1046
|
+
the query or variables (those are PII, and belong at debug on the logger
|
|
1047
|
+
where the level gates them). See `docs/logging.md`.
|
|
1048
|
+
Developer-experience fixes (all with regression coverage):
|
|
1049
|
+
- **FakeClient override keys are validated against the schema.** A typo'd key
|
|
1050
|
+
(`"Person.nmae" => "Daniel"`) pinned nothing, and the example passed against
|
|
1051
|
+
random fake data — a test that had quietly stopped checking what it claims to.
|
|
1052
|
+
Keys now raise, spellchecked, at `FakeClient.new` and at `Testing.configure`
|
|
1053
|
+
when a schema is already set. Bare field-name keys (`"name"`) still work;
|
|
1054
|
+
**fix or drop any key that doesn't name a field in your schema.**
|
|
1055
|
+
- Codegen validation errors name the position they already captured: each
|
|
1056
|
+
message is prefixed `4:5`, and `queries/typo.graphql:4:5` when the file is
|
|
1057
|
+
known (`Codegen.new`/`Codegen.generate` take it as `path:`), instead of
|
|
1058
|
+
leaving a project of thirty query files to search by hand.
|
|
1059
|
+
- A strict `alias:` whose path doesn't fit a query now names the query that
|
|
1060
|
+
failed and ends with `— pass optional: true to skip selections that don't
|
|
1061
|
+
fit`, the documented way out.
|
|
1062
|
+
- Generation lists the custom scalars it had no registration for at `info`
|
|
1063
|
+
(`3 unregistered custom scalars → T.untyped: …`). Informational — a scalar
|
|
1064
|
+
without a codec is a legitimate choice, just no longer a silent one.
|
|
1065
|
+
- `Response#ok?` (and `#success?`) — the positive form of `errors?`.
|
|
1066
|
+
- `FakeClient#schema` reads back the schema responses are fabricated against,
|
|
1067
|
+
which is how to reach it under `auto_fake`, where `GraphWeaver.client` is the
|
|
1068
|
+
fake; `Testing.config.schema` reads back too.
|
|
1069
|
+
New:
|
|
1070
|
+
- **`rake graph_weaver:queries:check` — which of your queries a schema change
|
|
1071
|
+
broke.** Re-introspects the url the dump records (leaving the dump alone) and
|
|
1072
|
+
validates every checked-in query against the server as it is now,
|
|
1073
|
+
reporting file plus line:col plus message and exiting non-zero on any
|
|
1074
|
+
failure, so it drops into CI. `GraphWeaver.check_queries` returns the same
|
|
1075
|
+
thing as data (`{path => [{"message", "line", "column"}]}`, empty when
|
|
1076
|
+
everything validates); pass `schema:` to check a schema you already have
|
|
1077
|
+
without touching the network. Complements `graph_weaver:verify`, which asks
|
|
1078
|
+
the different question of whether the committed Ruby is stale.
|
|
1079
|
+
- `verify_generated!` (and `rake graph_weaver:verify`) compares generated files
|
|
1080
|
+
with line endings normalized, so a checkout under git's `autocrlf` no longer
|
|
1081
|
+
reports every generated file as stale.
|
|
1082
|
+
- New [editor support](docs/editors.md) doc: the `graphql.config.yml` that gives
|
|
1083
|
+
VS Code and RubyMine validation, autocomplete and hover docs in your
|
|
1084
|
+
`.graphql` files — no JS project, no gem code, five lines of YAML.
|
|
1085
|
+
- **Byte-identical generation is now a stated guarantee**, not just a property:
|
|
1086
|
+
the same schema and queries produce the same files on any machine, in any
|
|
1087
|
+
order (`docs/generated_modules.md`). It was already true and spec-enforced;
|
|
1088
|
+
it was documented nowhere.
|
|
1089
|
+
|
|
1090
|
+
**Faraday is no longer auto-selected — `GraphWeaver.new(url)` always builds
|
|
1091
|
+
`Transport::HTTP`.** Selection used to be `defined?(::Faraday)`, and faraday
|
|
1092
|
+
rides into most bundles transitively (stripe, octokit, ...), so adding an
|
|
1093
|
+
unrelated gem silently swapped your transport, its timeouts, and its connection
|
|
1094
|
+
behaviour. The accidental default was also the slower one: `Transport::HTTP`
|
|
1095
|
+
pools persistent sockets (1 TCP connection for 10 requests) where Faraday's
|
|
1096
|
+
default `net_http` adapter reconnects per request (10 for 10) — a full TLS
|
|
1097
|
+
handshake each time over HTTPS.
|
|
1098
|
+
|
|
1099
|
+
**What you must do:** if you were relying on the auto-pick, ask for Faraday
|
|
1100
|
+
explicitly — `GraphWeaver.new(url, transport: :faraday)`. A middleware block
|
|
1101
|
+
still implies it (`GraphWeaver.new(url) { |conn| ... }`), since the block is
|
|
1102
|
+
Faraday's. Faraday is otherwise unchanged and fully supported. Alongside a url,
|
|
1103
|
+
`transport:` now takes `:http` (the default) or `:faraday` rather than a
|
|
1104
|
+
built transport object — passing an object there used to raise "pass a url or
|
|
1105
|
+
transport:, not both" and now raises naming the two symbols. Alongside a schema
|
|
1106
|
+
source it still takes a built transport, and now rejects a Symbol. The client
|
|
1107
|
+
logs which transport it built at `info`.
|
|
1108
|
+
|
|
1109
|
+
`docs/transports.md` gains the recipe for giving Faraday the connection reuse
|
|
1110
|
+
`Transport::HTTP` has by default: the `:net_http_persistent` adapter, the two
|
|
1111
|
+
gems it needs, and the version pairing (Faraday 2.x requires
|
|
1112
|
+
`faraday-net_http_persistent` **2.x**; 1.2.0 raises `NoMethodError: undefined
|
|
1113
|
+
method 'dependency'` at load). graph_weaver depends on neither and never
|
|
1114
|
+
selects it for you.
|
|
1115
|
+
|
|
1116
|
+
**Generated files are pruned when their query disappears.** Renaming or
|
|
1117
|
+
deleting a `.graphql` used to leave its `.rb` behind forever: `load_generated!`
|
|
1118
|
+
kept requiring it, its module kept resolving against a query that no longer
|
|
1119
|
+
existed, and `verify_generated!` stayed silent — the pruning only covered
|
|
1120
|
+
`inputs/*.rb` and `unions.rb`. `generate!` now deletes any generated file the
|
|
1121
|
+
plan no longer produces, and `verify_generated!` reports it as stale.
|
|
1122
|
+
|
|
1123
|
+
Only files carrying the `# Generated by GraphWeaver — do not edit.` header are
|
|
1124
|
+
ever deleted, so a hand-written file in the output directory survives. **What
|
|
1125
|
+
you must do:** nothing, unless you were relying on a lingering module — the
|
|
1126
|
+
next `generate!` removes it, and CI's `rake graph_weaver:verify` will name it
|
|
1127
|
+
first.
|
|
1128
|
+
|
|
1129
|
+
**Mutations now generate `…Mutation` modules, not `…Query`.**
|
|
1130
|
+
`save_list_entry.graphql` holding a `mutation` produces
|
|
1131
|
+
`SaveListEntryMutation` in `save_list_entry_mutation.rb`;
|
|
1132
|
+
`SaveListEntryQuery.execute!` read wrong for a write. Queries are unchanged.
|
|
1133
|
+
The rule is one rule — the camelized file name plus the operation the file
|
|
1134
|
+
defines — and all three naming sites follow it: `generate!`,
|
|
1135
|
+
`GraphWeaver.parse(path)`, and `client.load_queries!`. The operation name
|
|
1136
|
+
written *inside* the file still names nothing; it goes on the wire as
|
|
1137
|
+
`operationName`.
|
|
1138
|
+
|
|
1139
|
+
**What you must do:** regenerate (`rake graph_weaver:generate`) and rename the
|
|
1140
|
+
call sites of any mutation module — `AdoptQuery` → `AdoptMutation`, including
|
|
1141
|
+
nested constants like `AdoptQuery::AdoptionInput`. Regeneration prunes the old
|
|
1142
|
+
`*_query.rb` files, and `rake graph_weaver:verify` names anything missed.
|
|
1143
|
+
Changing a file's `query` to `mutation` from here on renames its constant the
|
|
1144
|
+
same way, which CI now catches rather than letting it drift.
|
|
1145
|
+
|
|
1146
|
+
**Generated modules get their client plumbing from
|
|
1147
|
+
`GraphWeaver::QueryModule`.** `client`/`client=` carry no per-query type
|
|
1148
|
+
information, so every generated file repeated the same fifteen untyped lines;
|
|
1149
|
+
they now live in the gem, beside the input-struct runtime, and a module says
|
|
1150
|
+
`extend GraphWeaver::QueryModule` instead. `execute`, `execute!`,
|
|
1151
|
+
`from_response` and `from_response!` stay generated — their sigs are your
|
|
1152
|
+
query's types. A baked `client:` constant is emitted as `DEFAULT_CLIENT`,
|
|
1153
|
+
still resolved on first use so a module can load before the initializer that
|
|
1154
|
+
builds its client, and resolution is unchanged: per call → per module → baked
|
|
1155
|
+
constant → `GraphWeaver.client`.
|
|
1156
|
+
|
|
1157
|
+
**What you must do:** regenerate (`rake graph_weaver:generate`). The files
|
|
1158
|
+
change; nothing about how you call them does.
|
|
1159
|
+
Error-message and console ergonomics from the same review:
|
|
1160
|
+
- **Validation errors name the query file and render one per line**, compiler
|
|
1161
|
+
style — `invalid query in app/graphql/queries/person.graphql:` followed by an
|
|
1162
|
+
indented `4:5 Field 'nmae' doesn't exist on type 'Person'` per error. They
|
|
1163
|
+
arrived as one joined line with no file at all, because `generate!` had the
|
|
1164
|
+
path in hand and never passed it to codegen, so thirty query files left you
|
|
1165
|
+
hunting for a bare `4:5`. `ValidationError#errors` and `#to_h` keep the shape
|
|
1166
|
+
`rake graph_weaver:queries:check` reads; only the message text changed, and
|
|
1167
|
+
**it is multi-line now** — update anything matching on it.
|
|
1168
|
+
- **`register_enum("Species", PetKind, {"DOG" => :dog})` says the value map is a
|
|
1169
|
+
keyword**, and shows the call with `map:` in it. Guessing the map as a third
|
|
1170
|
+
positional argument used to get Ruby's `wrong number of arguments (given 3,
|
|
1171
|
+
expected 2)`, which never mentions `map:`.
|
|
1172
|
+
- **`load_queries!` logs when it replaces an already-loaded module**, at
|
|
1173
|
+
`:info`, before swapping the constant: `replacing PersonQuery — objects built
|
|
1174
|
+
from the previous module stay instances of it`. Reloading is unchanged and
|
|
1175
|
+
still what the method is for; it just isn't silent about the structs it
|
|
1176
|
+
orphans, which is how a console session ends up with an `is_a?` that fails
|
|
1177
|
+
for no visible reason.
|
|
1178
|
+
**Rails install generator.**
|
|
1179
|
+
`rails g graph_weaver:install https://api.example.com/graphql` writes
|
|
1180
|
+
`config/initializers/graph_weaver.rb`, the `app/graphql/queries` and
|
|
1181
|
+
`app/graphql/generated` directories, `graphql.config.yml` (schema autocomplete
|
|
1182
|
+
and validation for `.graphql` files in VS Code / RubyMine) and the schema dump
|
|
1183
|
+
— replacing the console step the getting-started guide used to open with.
|
|
1184
|
+
`--auth` names the ENV var holding the token (default `GRAPHWEAVER_AUTH`),
|
|
1185
|
+
`--no-schema` skips the introspection. Re-running prompts on conflict like any
|
|
1186
|
+
Rails generator.
|
|
1187
|
+
|
|
1188
|
+
**`rake graph_weaver:schema:refresh` can now create the first dump.** It read
|
|
1189
|
+
its url from an existing dump's provenance stamp, so it couldn't bootstrap one
|
|
1190
|
+
— pass `URL=https://api.example.com/graphql` and it will, and both the
|
|
1191
|
+
no-dump and no-provenance messages now name that fix. The same logic is
|
|
1192
|
+
`GraphWeaver::SchemaLoader.refresh!(url:, auth:)`, which is what the generator
|
|
1193
|
+
calls.
|
|
1194
|
+
|
|
1195
|
+
**Pointing a client at a url that isn't a GraphQL endpoint now says so.** A
|
|
1196
|
+
REST base url, a GraphiQL page or a proxy that ate the path answers 200 with
|
|
1197
|
+
well-formed JSON, and `.schema` raised a bare `KeyError`/`NoMethodError` out of
|
|
1198
|
+
graphql-ruby — unbranded, no url, and it escaped `rescue GraphWeaver::Error`
|
|
1199
|
+
(a 404 on the same path was already branded and clear). Introspection now
|
|
1200
|
+
checks for `data.__schema` and raises `GraphWeaver::Error` naming the endpoint
|
|
1201
|
+
and the first 200 characters of what came back.
|
|
1202
|
+
|
|
1203
|
+
**A subgraph's own `FieldSet` / `Scope` / `Policy` type no longer collides with
|
|
1204
|
+
weaver's.** Loading subgraph SDL injects the federation directive definitions
|
|
1205
|
+
the file applies but doesn't declare, and the scalars they reference went in
|
|
1206
|
+
unnamespaced — so a subgraph that owns a type by one of those names either had
|
|
1207
|
+
it shadowed or failed to build, with advice pointing at the wrong file. Those
|
|
1208
|
+
three are now `federation__FieldSet` / `federation__Scope` /
|
|
1209
|
+
`federation__Policy`. `_Any` / `_Entity` / `_Service` keep their names — those
|
|
1210
|
+
are spec-mandated and queryable.
|
|
1211
|
+
|
|
1212
|
+
**`rake graph_weaver:queries:check` no longer compares an in-process app's
|
|
1213
|
+
schema against itself.** For an app whose schema is its own graphql-ruby class
|
|
1214
|
+
there is no server to re-introspect, so the check degraded to re-reading the
|
|
1215
|
+
committed dump — reporting phantom errors about the app's own schema, a field
|
|
1216
|
+
you just added reading as "doesn't exist". When `GraphWeaver.client` executes
|
|
1217
|
+
in-process (a `Client` wrapping a schema class, or the class itself), the check
|
|
1218
|
+
now validates against the live class. Network clients are unchanged.
|
|
1219
|
+
|
|
1220
|
+
**The two dead-end "records no source url" messages now say what to do.** A
|
|
1221
|
+
dump taken from a schema class is rebuilt from code, not re-fetched — both
|
|
1222
|
+
`schema:refresh` and `schema:diff` say that instead of naming a `URL=` that
|
|
1223
|
+
doesn't exist for you.
|
|
1224
|
+
|
|
1225
|
+
**Two rake tasks are renamed so each one names its own subject.** There were
|
|
1226
|
+
three checks and two of them were called `verify`, while the one people run
|
|
1227
|
+
most — "did schema drift break my queries?" — lived under `schema:` and doesn't
|
|
1228
|
+
check the schema. **Update your CI:**
|
|
1229
|
+
|
|
1230
|
+
| Was | Now | Asks |
|
|
1231
|
+
|---|---|---|
|
|
1232
|
+
| `graph_weaver:schema:check` | `graph_weaver:queries:check` | do my checked-in queries still validate? |
|
|
1233
|
+
| `graph_weaver:schema:verify` | `graph_weaver:schema:diff` | has the server drifted from the dump? |
|
|
1234
|
+
|
|
1235
|
+
`graph_weaver:verify` (is the committed Ruby fresh?) and
|
|
1236
|
+
`graph_weaver:schema:refresh` are unchanged. No aliases — the old names are
|
|
1237
|
+
gone.
|
|
1238
|
+
|
|
1239
|
+
**The instrumentation payload now carries `:status` in-process too.** `InProcess`
|
|
1240
|
+
brands a resolver raise as `ServerError(500)` precisely so callers needn't
|
|
1241
|
+
branch on which side of the seam a query ran — but the payload had no `:status`
|
|
1242
|
+
in-process and no `:schema` over the wire, so a subscriber had to branch
|
|
1243
|
+
anyway. A successful in-process execute now sets `:status` to 200; a failure
|
|
1244
|
+
still rides the exception the hook already sees.
|
|
1245
|
+
|
|
1246
|
+
**`extend_type` and `alias:` moved from `docs/scalars.md` to
|
|
1247
|
+
`docs/generated_modules.md`** — they decorate a generated struct, and now sit
|
|
1248
|
+
next to what a generated struct looks like. **Update any bookmark to
|
|
1249
|
+
`scalars.md#type-helpers-your-logic-on-generated-structs`**; it is
|
|
1250
|
+
`generated_modules.md#type-helpers` now. `scalars.md` still owns
|
|
1251
|
+
`register_scalar` and `register_enum`.
|
|
1252
|
+
|
|
1253
|
+
- **`Testing::Router#trace` accumulates across executes and is reset
|
|
1254
|
+
explicitly** — `router.reset_trace`. It used to clear itself at the top of
|
|
1255
|
+
every `execute`, which made it answer about the *last* query rather than the
|
|
1256
|
+
code path: a service object running two queries reported only the second's
|
|
1257
|
+
fetches, and an example that ran nothing read the previous example's, so an
|
|
1258
|
+
assertion could pass on another example's work and fail under `--order rand`.
|
|
1259
|
+
The rspec `graphql: :router` tag resets it per example. **An example that
|
|
1260
|
+
asserts on the trace after more than one `execute` now sees both**, and
|
|
1261
|
+
wants a `reset_trace` in between if it meant only the last one.
|
|
1262
|
+
- **The absent-subgraph refusal leads with the half that applies.** It opened
|
|
1263
|
+
with "Rails autoloads, so the class is probably just not loaded yet" — right
|
|
1264
|
+
often enough to lead with, except when eager loading is already on, and then
|
|
1265
|
+
the library can *ask* rather than send you to a setting you already have. It
|
|
1266
|
+
now checks `config.eager_load` / `config.rake_eager_load` and, when either is
|
|
1267
|
+
on, says the subgraph runs elsewhere and puts `=> :fake` first instead of at
|
|
1268
|
+
the end of a 60-word sentence.
|
|
1269
|
+
- **A refusal spells a nested `@key`/`@requires` field set the way your schema
|
|
1270
|
+
does** — `"origin { lat lon }"`, not `"origin.lat", "origin.lon"`. The dotted
|
|
1271
|
+
form is this library's parse of it and matches nothing you can grep for.
|
|
1272
|
+
- **One `@interfaceObject` no longer disables the whole router.** It refused at
|
|
1273
|
+
construction, for the entire supergraph, so a single directive made
|
|
1274
|
+
`Testing::Router` unusable even for queries that never touch the type — one
|
|
1275
|
+
corpus had to be split into two graphs over it. It is now a per-query refusal
|
|
1276
|
+
(`Unplannable#category` `:interface_object`) keyed on the types the query
|
|
1277
|
+
actually reaches, and `federation:coverage` counts it as one refusal among
|
|
1278
|
+
others rather than aborting. Routing an `@interfaceObject` is still not
|
|
1279
|
+
implemented; this only makes the refusal proportionate.
|
|
1280
|
+
`RoutingTable#unsupported` no longer lists them —
|
|
1281
|
+
`RoutingTable#interface_objects` does, as `{"Media" => ["catalog"]}`.
|
|
1282
|
+
- `docs/federation.md`'s refusal table now lists **every** `Unplannable`
|
|
1283
|
+
category, and a spec keeps it that way. Five were missing, `chained_requires`
|
|
1284
|
+
and `conditional_fragment` among them.
|
|
1285
|
+
- **`federation:coverage` counts what your suite can *run*, not only what
|
|
1286
|
+
plans.** `5/5 queries plannable locally (100%)` was optimistic in exactly the
|
|
1287
|
+
partly-local shape the docs call the usual migration one: a query resolving
|
|
1288
|
+
in a subgraph another service serves plans fine and a spec still can't run
|
|
1289
|
+
it. The headline now reads `…, 2 servable here`, and the queries reaching
|
|
1290
|
+
past what's loaded are listed with the subgraph each needs. Plan-only is
|
|
1291
|
+
still the design — with no subgraph loaded (the SDL-alone CI run) the second
|
|
1292
|
+
number is dropped and the report says it counted planning only.
|
|
1293
|
+
`Coverage#servable` and `#elsewhere` are the programmatic side, and
|
|
1294
|
+
`Coverage::Result` gained `absent` / `servable?`.
|
|
1295
|
+
- **`Testing::Failure.stale_schema` drops its `schema:` / `seed:` sampling.**
|
|
1296
|
+
It picked a random real type/field so a fabricated error string would look
|
|
1297
|
+
plausible — but no assertion can depend on which one it picks, so it was
|
|
1298
|
+
decoration with three kwargs and an RNG behind it. `stale_schema(type:,
|
|
1299
|
+
field:)` names the casualty when the message matters, and the bare call still
|
|
1300
|
+
trips `schema_stale?`. Passing `schema:`/`seed:` now raises `ArgumentError`.
|
|
1301
|
+
- **`rake graph_weaver:cassettes:check`** — replays every recording through the
|
|
1302
|
+
generated modules and fails when one no longer casts. A cassette is the only
|
|
1303
|
+
artifact recorded from a foreign server, and nothing else here notices when
|
|
1304
|
+
that server's answers drift out of the shape the structs were generated for:
|
|
1305
|
+
`verify`, `queries:check` and `schema:diff` all ask about the local side. It
|
|
1306
|
+
needs no network, so it belongs beside `verify` in a PR run. A recording no
|
|
1307
|
+
generated module sends is skipped and counted, and checking *none* of them
|
|
1308
|
+
fails, like `federation:diff`. `Testing::Cassette#check` is the programmatic
|
|
1309
|
+
side.
|
|
1310
|
+
- **A cast failure no longer prints sorbet-runtime's `Caller:` frame.**
|
|
1311
|
+
`GraphWeaver::TypeError`'s message ended with `Caller:
|
|
1312
|
+
.../sorbet-runtime/.../call_validation.rb:331` — a path into the gem, never
|
|
1313
|
+
into the code with the problem, and the only location the message offered.
|
|
1314
|
+
|
|
1315
|
+
### v0.4.6 (2026-07-30)
|
|
1316
|
+
Bug fixes from a full-library review (all with regression coverage):
|
|
1317
|
+
- alias: a nested-object/enum leaf (`meta.sub`) now qualifies its constant
|
|
1318
|
+
(`Meta::Sub`) instead of emitting a bare `Sub` that raised NameError; alias
|
|
1319
|
+
names/segments are validated as identifiers (were interpolated verbatim,
|
|
1320
|
+
allowing injection); `optional:` no longer swallows a reserved-name/collision
|
|
1321
|
+
mistake; a real field named `first`/`last` resolves as a field.
|
|
1322
|
+
- Shared unions: a hoisted member selecting a mapped enum now emits its
|
|
1323
|
+
`<NAME>_FROM_WIRE` table into `unions.rb` (was a NameError at `from_h`); a
|
|
1324
|
+
fragment whose name collides with `Result`/`QUERY` is refused.
|
|
1325
|
+
- A named interface fragment holding inline `... on X` conditions now dispatches
|
|
1326
|
+
instead of silently dropping those fields; fragment cycles raise a clear error
|
|
1327
|
+
in the FakeClient/Anonymizer walkers instead of `SystemStackError`.
|
|
1328
|
+
- Malformed responses/inputs stay under `GraphWeaver::Error`: a non-2xx
|
|
1329
|
+
`errors: null` body keeps its status; a 2xx non-object body is a `ServerError`;
|
|
1330
|
+
`data!` on null-data/no-errors raises `QueryError`; `coerce(non-Hash)` raises
|
|
1331
|
+
`InputError`.
|
|
1332
|
+
- Testing harness: FakeClient/Anonymizer merge duplicate result keys (were
|
|
1333
|
+
fabricating shapes the generated struct couldn't cast); `fail_at` fires every
|
|
1334
|
+
execute; symbol-keyed cassette variables no longer crash on reload; the
|
|
1335
|
+
Anonymizer keeps concrete-fragment fields when data lacks `__typename`.
|
|
1336
|
+
- Federation: a user type named `link` is no longer dropped; an all-`@inaccessible`
|
|
1337
|
+
schema raises a pointed error. Client accepts `retries: nil` on a schema
|
|
1338
|
+
source; `register_scalar` rejects an anonymous class.
|
|
1339
|
+
- FakeClient: an Integer `list_size` now means exactly that length (a Range
|
|
1340
|
+
randomizes within it). Codegen rejects two variables that underscore to the
|
|
1341
|
+
same kwarg (`$userId` + `$user_id`). The Faraday transport raises rather than
|
|
1342
|
+
silently dropping `headers:`/block when handed a prebuilt connection.
|
|
1343
|
+
|
|
1344
|
+
### v0.4.5 (2026-07-30)
|
|
1345
|
+
- `alias:` paths gain list-element selectors: a `first`/`last` segment picks one
|
|
1346
|
+
element out of a list hop, always nilable — `alias: { entity: "_entities.first" }`
|
|
1347
|
+
yields `def entity = _entities&.first`, and navigation continues into the
|
|
1348
|
+
element (`_entities.first.name`). Typed from the selection: a single inline
|
|
1349
|
+
fragment lands on the concrete member (`T.nilable(Widget)`), a multi-fragment
|
|
1350
|
+
selection on the union. Selectors are checked against the node shape — `.first`
|
|
1351
|
+
on a non-list raises. Cleanly retires the `result._entities&.first&.field`
|
|
1352
|
+
boilerplate of single-entity federation `_entities` queries.
|
|
1353
|
+
- `extend_type(..., optional: true)` makes its aliases lenient: a query whose
|
|
1354
|
+
selection doesn't fit the path omits the accessor instead of failing
|
|
1355
|
+
generation. For an alias on a universal type (a `Query` accessor a strict alias
|
|
1356
|
+
would force every query to satisfy), or one that only fits some selections.
|
|
1357
|
+
|
|
1
1358
|
### v0.4.4 (2026-07-30)
|
|
2
1359
|
- Supergraph loading now derives the **API schema**: `@inaccessible` elements
|
|
3
1360
|
(present in the federated graph but hidden from what the router serves) are
|