graph_weaver 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +128 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -1
- data/docs/cassettes.md +23 -3
- data/docs/errors.md +2 -0
- data/docs/federation.md +8 -7
- data/docs/generated_modules.md +2 -2
- data/docs/getting_started.md +1 -1
- data/docs/logging.md +1 -1
- data/docs/testing.md +8 -7
- data/docs/upgrading.md +34 -12
- data/graph_weaver.gemspec +16 -2
- data/lib/generators/graph_weaver/install_generator.rb +15 -15
- data/lib/graph_weaver/client.rb +6 -2
- data/lib/graph_weaver/codegen/aliases.rb +10 -4
- data/lib/graph_weaver/codegen/emit.rb +11 -3
- data/lib/graph_weaver/codegen/enum_type.rb +1 -3
- data/lib/graph_weaver/codegen/scalar_type.rb +5 -4
- data/lib/graph_weaver/codegen/type_helpers.rb +1 -3
- data/lib/graph_weaver/codegen.rb +99 -22
- data/lib/graph_weaver/errors.rb +27 -6
- data/lib/graph_weaver/federation.rb +4 -17
- data/lib/graph_weaver/parsing.rb +1 -9
- data/lib/graph_weaver/rspec.rb +13 -7
- data/lib/graph_weaver/schema_loader.rb +30 -6
- data/lib/graph_weaver/schemas.rb +4 -2
- data/lib/graph_weaver/tasks.rb +24 -21
- data/lib/graph_weaver/testing/cassette.rb +89 -20
- data/lib/graph_weaver/testing/coverage.rb +7 -12
- data/lib/graph_weaver/testing/failure.rb +4 -2
- data/lib/graph_weaver/testing/fake_client.rb +1 -1
- data/lib/graph_weaver/testing/router.rb +68 -47
- data/lib/graph_weaver/testing/subgraphs.rb +11 -7
- data/lib/graph_weaver/testing.rb +8 -2
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +39 -14
- metadata +8 -9
- data/CLAUDE.md +0 -161
- data/DECISIONS.md +0 -309
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -115
- data/REVIEW.md +0 -946
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af7804f6f324bf878d1b11ba4da8e2adc8e2ea4c055fb80a703e91d8ca4eb3cd
|
|
4
|
+
data.tar.gz: fe6fd6340203a8937c836281342c0efcdb701203295e8a4fcf37260892a5afec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1e5bd51d719bec6ddb81a75bab2bc68bb7b7ab9fd0c656fbbb0fd2497433ebe733893bda80e7d5dc1b4735487c1d787c5d10156dd7b0f8634e6740907af4fe01
|
|
7
|
+
data.tar.gz: 4fd87cae3d8c320c9dfb61b08f4464d3d2978c831a35c0b26c4262b27929e90da52e1a528d411b6b1689d45e24edca5d2727c3e9dc42bb5bf6202589d0973d71
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,131 @@
|
|
|
1
|
+
### v0.5.1 (2026-09-07)
|
|
2
|
+
- **A union's catch-all struct keeps the fields an interface fragment asked
|
|
3
|
+
for.** `... on Named { name }` under a union types `name` on every member the
|
|
4
|
+
query named, but the catch-all — the member it didn't name, including one the
|
|
5
|
+
schema grows later — carried only `__typename`, so a `name` the server
|
|
6
|
+
actually sent was dropped on the floor. Those fields are `T.nilable`: the
|
|
7
|
+
member that arrives needn't implement the interface. **Regenerate** to pick
|
|
8
|
+
it up.
|
|
9
|
+
- **`schema_stale?` now recognises the drift a graphql-ruby server reports.**
|
|
10
|
+
It matched Apollo's one flat code plus a message pattern, but graphql-ruby —
|
|
11
|
+
which this library ships as its in-process client — names the rule that fired
|
|
12
|
+
in `extensions.code`. So a renamed type, a removed argument, an argument that
|
|
13
|
+
became required and four other drift shapes all raised without the one
|
|
14
|
+
sentence telling you to refresh the dump and regenerate, and `to_h` reported
|
|
15
|
+
`"schema_stale": false`.
|
|
16
|
+
- **A cast failure keeps the server's own explanation.** A field that came back
|
|
17
|
+
null *with a reason* — a permission rule, a partial outage — raised only
|
|
18
|
+
Sorbet's nil complaint, because the generated envelope built `data` before it
|
|
19
|
+
read `errors`. The reason is now in the message. **Regenerate** to pick it up.
|
|
20
|
+
- **Cassette anonymization missed `errors` and `extensions`.** It walked
|
|
21
|
+
`data` and nothing else, so a rejected request's error message — which
|
|
22
|
+
routinely quotes the input that caused it — and whatever the server hangs
|
|
23
|
+
off `extensions` went to disk verbatim, under a flag that says real data
|
|
24
|
+
never reaches it. **Re-run `rake graph_weaver:cassettes:anonymize` on any
|
|
25
|
+
cassette you've already committed**, or re-record it. Neither key has a
|
|
26
|
+
schema behind it, so both are now walked by shape: keys, nesting, nulls and
|
|
27
|
+
booleans survive and every string and number is replaced, with `path`,
|
|
28
|
+
`locations` and an error's `extensions.code` kept because they describe the
|
|
29
|
+
request rather than the data.
|
|
30
|
+
- **Recording flags a credential that lands in the file.** A cassette gets
|
|
31
|
+
committed as written, and its query and variables can't be anonymized —
|
|
32
|
+
they're the key replay matches on. So the bytes headed for disk are checked
|
|
33
|
+
for the shapes that are unmistakable (a JWT, `AKIA…`, `ghp_…`, `xox…`,
|
|
34
|
+
`sk_live_…`, a PEM block, a `Bearer` header) and named on stderr once per
|
|
35
|
+
cassette. Nothing is rewritten; a password like `hunter2` has no shape, so a
|
|
36
|
+
quiet run still isn't a clean bill of health.
|
|
37
|
+
- **An anonymized cassette no longer drops a key selected twice.** The
|
|
38
|
+
anonymizer kept the last occurrence's sub-selection where codegen merges
|
|
39
|
+
them, so `a { x } a { y }` came back holding only `y` — a recording that
|
|
40
|
+
then failed its own `cassettes:check`.
|
|
41
|
+
- **Three generated types disagreed with the schema.** `srb tc` proves the
|
|
42
|
+
generated code is self-consistent, not that it is true, so each of these was a
|
|
43
|
+
lie the typechecker endorsed. **Regenerate** to pick them up:
|
|
44
|
+
- A field reached only through a `@skip`/`@include`-guarded occurrence, where
|
|
45
|
+
the same key is also selected unguarded, was typed as guaranteed. The server
|
|
46
|
+
legitimately omits it, so `from_h` raised `key not found` on a valid
|
|
47
|
+
response — and a union whose `__typename` arrived that way crashed its own
|
|
48
|
+
dispatch. Those children are nilable now, and the `__typename` case refuses
|
|
49
|
+
at generation as it already did for the plainer spelling.
|
|
50
|
+
- A narrowed abstract inside a non-null list made the *list* nilable
|
|
51
|
+
(`[Thing!]!` with `... on Widget` → `T.nilable(T::Array[…])`). Only the
|
|
52
|
+
elements can be nil; the array is always there.
|
|
53
|
+
- An input field the schema gives a default is optional, but a non-null one
|
|
54
|
+
emitted `const :x, String, default: nil` — so `x.upcase` typechecked and
|
|
55
|
+
was a `NoMethodError`.
|
|
56
|
+
- **`rake graph_weaver:federation:coverage` no longer counts a subgraph as
|
|
57
|
+
served here when two loaded schemas fit it.** `Router.new` refuses that case
|
|
58
|
+
— picking either would be a coin flip — so the report was promising a run
|
|
59
|
+
the suite can't have. It now lands in the "nothing here serves what they
|
|
60
|
+
reach" list, whose advice (name a schema for those subgraphs) is the fix.
|
|
61
|
+
- **A supergraph that renamed the join spec is now refused instead of read as
|
|
62
|
+
empty.** `@link(url: ".../join/v0.3", as: "j")` spells every marker `j__`,
|
|
63
|
+
which the routing table doesn't follow — so it reported a composed graph with
|
|
64
|
+
*no subgraphs* and nothing unsupported, and `Testing::Router` built happily on
|
|
65
|
+
top of that. It now lands in `unsupported`, so the router and
|
|
66
|
+
`federation:coverage` refuse at construction and say why.
|
|
67
|
+
- **`rake graph_weaver:cassettes:anonymize` now finds an SDL schema dump.** It
|
|
68
|
+
opened `GraphWeaver.schema_path` directly instead of locating the dump the
|
|
69
|
+
way every sibling task does, so an app whose committed dump is
|
|
70
|
+
`schema.graphql` (what the generator writes for a supergraph) got a raw
|
|
71
|
+
`Errno::ENOENT`. A missing dump now says so.
|
|
72
|
+
- **`rake graph_weaver:queries:check` now authenticates with the token your
|
|
73
|
+
app uses.** It re-introspects the url the dump records, but read
|
|
74
|
+
`GRAPHWEAVER_AUTH` rather than the ENV var the dump named — so an app
|
|
75
|
+
installed with `--auth MY_TOKEN` reached its private API while the check
|
|
76
|
+
401'd. Every re-introspection now goes through `SchemaLoader.source_transport`,
|
|
77
|
+
which is also now public.
|
|
78
|
+
- **A `graphql: :router` example now starts from the same fabricated data
|
|
79
|
+
whether it runs alone or after two hundred others.** The router is built once
|
|
80
|
+
for the suite, and a faked subgraph kept fabricating from wherever the last
|
|
81
|
+
example left its sequence — so `rspec spec/foo_spec.rb:12` disagreed with the
|
|
82
|
+
same example in a full run, which is the opposite of what `--seed` promises.
|
|
83
|
+
`Router#reset!` is the example boundary (`reset_trace` still clears only the
|
|
84
|
+
trace); the rspec tag calls it for you.
|
|
85
|
+
- **An override key given as a Symbol now pins.** `graphql_fake(overrides: { name: "Ada" })`
|
|
86
|
+
validated clean and silently fabricated a random value, because lookup is by
|
|
87
|
+
String — the exact silent-green failure the override validation exists to
|
|
88
|
+
prevent.
|
|
89
|
+
- **A helper no longer contradicts `config.default_mode`.** With
|
|
90
|
+
`default_mode = :fake`, an untagged example calling `graphql_in_process`
|
|
91
|
+
raised *"this example is tagged graphql: :fake"* — naming a tag that wasn't
|
|
92
|
+
there, and blocking the untagged form the docs recommend. Only an explicit
|
|
93
|
+
tag can disagree with a helper now.
|
|
94
|
+
- **A variable default that isn't a Boolean no longer reaches a subgraph.** An
|
|
95
|
+
enum or input-object default is a parser AST node; sending one put a
|
|
96
|
+
back-pointer to the lexer on the wire, and an input object raised
|
|
97
|
+
`NoMethodError` outside any rescue. Only `@skip`/`@include` read these, and
|
|
98
|
+
they read Booleans; a subgraph applies its own defaults from the variable
|
|
99
|
+
declaration it already receives.
|
|
100
|
+
- The local router asks the schema for one type by name rather than rebuilding
|
|
101
|
+
the whole type map, at four sites — one of them per response row. On a
|
|
102
|
+
1200-type supergraph a stitched query over 200 rows was **5x** slower than it
|
|
103
|
+
needed to be; the cost is now flat in schema size.
|
|
104
|
+
- **`graphql: :in_process` ran the committed schema dump instead of your live
|
|
105
|
+
schema class.** A dump loads as an anonymous `GraphQL::Schema` subclass, which
|
|
106
|
+
looks like a runnable class and has no resolvers — so every app that followed
|
|
107
|
+
`getting_started.md` (the generator always commits a dump) got a graphql-ruby
|
|
108
|
+
500 blaming its own resolver, and the error that was written to explain this
|
|
109
|
+
was unreachable. Nothing to do; if you worked around it with
|
|
110
|
+
`Testing.config.schema = MySchema`, that still works and still wins.
|
|
111
|
+
- **`GraphWeaver.parse(query:)` accepts a `Pathname`.** `schema:` already did,
|
|
112
|
+
so `GraphWeaver.parse(schema: Rails.root.join("schema.graphql"), query:
|
|
113
|
+
Rails.root.join("app/graphql/queries/person.graphql"))` died on the query
|
|
114
|
+
argument with `undefined method 'end_with?'`. Same for `client.parse(path)`.
|
|
115
|
+
- **`rake graph_weaver:cassettes:check` and `:anonymize` now find cassettes
|
|
116
|
+
from any working directory.** Both read `config.cassette_dir` raw, while
|
|
117
|
+
`Cassette.new` resolves it against `Rails.root` — so run from anywhere but
|
|
118
|
+
the app root, `check` aborted with "this checked nothing, so it proved
|
|
119
|
+
nothing" and `anonymize` silently did nothing, both while a spec run found
|
|
120
|
+
the same files fine. They now use the same resolution the recordings do.
|
|
121
|
+
- **An `extend_type(alias:)` name a struct instance already answers to now
|
|
122
|
+
refuses** instead of silently overriding it. `alias: { hash: "…" }` emitted
|
|
123
|
+
`def hash`, which breaks every `Hash` and `Set` holding that struct;
|
|
124
|
+
`inspect`, `to_s`, `method` and `class` were the same story. A *wire field*
|
|
125
|
+
by any of those names was already refused, so this is the same rule reaching
|
|
126
|
+
the same case from the other side. **If generation now refuses an alias you
|
|
127
|
+
had**, rename it — the accessor it generated was overriding a Ruby method.
|
|
128
|
+
|
|
1
129
|
### v0.5.0 (2026-09-07)
|
|
2
130
|
- **`graphql_in_process(SomeSchema)`** runs one example against that schema
|
|
3
131
|
class's real resolvers — the sibling of `graphql_fake`, and how a federated
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
graph_weaver (0.5.
|
|
4
|
+
graph_weaver (0.5.1)
|
|
5
5
|
graphql (>= 2.6.7)
|
|
6
6
|
sorbet-runtime
|
|
7
7
|
|
|
@@ -176,7 +176,7 @@ CHECKSUMS
|
|
|
176
176
|
google-protobuf (4.35.1-arm64-darwin) sha256=d9c957df04fa89c749fa9a72a7b383eb4296efc9b2303dc6fd6fbe39c698ad6b
|
|
177
177
|
google-protobuf (4.35.1-x86_64-darwin) sha256=66b62b4df00931018a692806df66393efa960d6d2b7da69735187249f950d3ee
|
|
178
178
|
google-protobuf (4.35.1-x86_64-linux-gnu) sha256=c786439087512a3fbd199e9897d265b855f951d4027e218ea55e858d45969edd
|
|
179
|
-
graph_weaver (0.5.
|
|
179
|
+
graph_weaver (0.5.1)
|
|
180
180
|
graphql (2.6.10) sha256=9b7c8633767f516ff9d48a8d6305b2a00a2101c82aa871b92e69086944f9f83e
|
|
181
181
|
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
|
|
182
182
|
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
|
data/README.md
CHANGED
|
@@ -97,7 +97,7 @@ fetches:
|
|
|
97
97
|
The trace is the query plan: every node at a level in one `_entities` call, so two
|
|
98
98
|
products cost one fetch. Anything it can't answer *faithfully* it refuses at plan
|
|
99
99
|
time rather than guessing — and it's diffed against a real `@apollo/gateway` over
|
|
100
|
-
the same supergraph, currently
|
|
100
|
+
the same supergraph, currently 72 queries identical, 2 refused, 0 wrong
|
|
101
101
|
([`spec/integration/router_parity_spec.rb`](spec/integration/router_parity_spec.rb)).
|
|
102
102
|
See [federation](docs/federation.md).
|
|
103
103
|
|
data/docs/cassettes.md
CHANGED
|
@@ -82,9 +82,29 @@ preserving everything that makes the recording faithful:
|
|
|
82
82
|
| enums, booleans, `__typename` | numbers, dates |
|
|
83
83
|
| id *relationships* (same original id → same fake id) | the id values themselves |
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
values are enums, dates, ids.
|
|
87
|
-
|
|
85
|
+
`data` is walked against the schema — which is why it needs one, to know which
|
|
86
|
+
values are enums, dates, ids. `errors` and `extensions` have none behind them,
|
|
87
|
+
so they're walked by shape instead: keys, nesting and structure survive, every
|
|
88
|
+
string and number is replaced. `path`, `locations` and an error's
|
|
89
|
+
`extensions.code` are kept, because they describe the request rather than the
|
|
90
|
+
data — and call sites branch on `code` the way they branch on an enum.
|
|
91
|
+
|
|
92
|
+
**The query and its variables are not anonymized.** They're the key replay
|
|
93
|
+
matches on, so scrubbing them would make the recording unfindable. A mutation's
|
|
94
|
+
input is often the sensitive part, so record with placeholder variables, or
|
|
95
|
+
don't record that request.
|
|
96
|
+
|
|
97
|
+
Recording says so when the bytes it wrote look like a credential:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
graph_weaver: spec/cassettes/github.yml contains a JWT, a GitHub token — a
|
|
101
|
+
cassette is committed as written, so review this one first. …
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
It recognizes tokens by shape — a JWT, `AKIA…`, `ghp_…`, `xox…`, `sk_live_…`, a
|
|
105
|
+
PEM block, a `Bearer` header — which is every credential that is unmistakable
|
|
106
|
+
and nothing else. A password like `hunter2` has no shape, so a quiet run is not
|
|
107
|
+
a clean bill of health: **read a cassette before committing it.**
|
|
88
108
|
|
|
89
109
|
For cassettes recorded before the flag was on:
|
|
90
110
|
|
data/docs/errors.md
CHANGED
|
@@ -36,8 +36,10 @@ subclass says where it failed:
|
|
|
36
36
|
| `TypeError` | the response wouldn't cast into the generated structs — `#struct`, `#cause` |
|
|
37
37
|
| `InputError` | the variables wouldn't build into the generated input structs — unknown/typo'd key, missing required field, out-of-range enum, wrong-typed field, wrong number of @oneOf fields — `#field`, `#struct` |
|
|
38
38
|
| `ValidationError` | build time: the query didn't validate against the schema |
|
|
39
|
+
| `Codegen::Aliases::UnknownSegment` | build time: an [`alias:`](generated_modules.md#flat-accessors-with-alias) path names a field no type here has — a typo, so `optional: true` won't skip it |
|
|
39
40
|
| `ConfigurationError` | setup judged against your schema — which Ruby schema serves which subgraph (`Testing::Router`, `federation:diff`) |
|
|
40
41
|
| `Testing::Unplannable` | the local test router won't plan this operation — `#category`, `#detail` |
|
|
42
|
+
| `Testing::MissingRecording` | a [cassette](cassettes.md) holds no entry for this request — the message prints the variables, and the ones it did record |
|
|
41
43
|
|
|
42
44
|
An argument that is wrong *on its face* raises a plain `ArgumentError` instead
|
|
43
45
|
(`pool_size: must be >= 1`, `cast: must be a Symbol, Proc, :itself, or nil`),
|
data/docs/federation.md
CHANGED
|
@@ -67,7 +67,7 @@ metadata.
|
|
|
67
67
|
|
|
68
68
|
The derivation is diffed against Apollo's own `composeServices` +
|
|
69
69
|
`toAPISchema()` in
|
|
70
|
-
[`spec/integration/api_schema_spec.rb`](
|
|
70
|
+
[`spec/integration/api_schema_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/integration/api_schema_spec.rb),
|
|
71
71
|
over composed supergraphs carrying `@interfaceObject`, `@join__unionMember`,
|
|
72
72
|
`@join__enumValue` and an aliased `@inaccessible` — identical in each.
|
|
73
73
|
|
|
@@ -224,10 +224,10 @@ is which subgraphs a code path touched and a service object rarely runs one
|
|
|
224
224
|
query. The rspec tag resets it before each example; outside rspec call
|
|
225
225
|
`router.reset_trace` around the code path you're measuring.
|
|
226
226
|
|
|
227
|
-
**[`examples/federation.rb`](
|
|
227
|
+
**[`examples/federation.rb`](https://github.com/dpep/graph_weaver/blob/main/examples/federation.rb)** is the whole shape
|
|
228
228
|
in one runnable file, and the only example that needs no network: three real
|
|
229
229
|
subgraphs, a boundary-crossing query through a generated module, the trace,
|
|
230
|
-
and a refusal. [`spec/router_spec.rb`](
|
|
230
|
+
and a refusal. [`spec/router_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/router_spec.rb) is the
|
|
231
231
|
exhaustive reference — every plan shape, every refusal, the partly-local
|
|
232
232
|
graph and the `:fake` opt-in, each as a named example.
|
|
233
233
|
|
|
@@ -435,7 +435,8 @@ $ rake graph_weaver:federation:coverage SUPERGRAPH=supergraph.graphql
|
|
|
435
435
|
the graph — could the router split this query faithfully at all. *Servable
|
|
436
436
|
here* is what your suite actually gets: every subgraph that plan reaches is one
|
|
437
437
|
this process serves. In a [partly-local supergraph](#a-supergraph-only-partly-local)
|
|
438
|
-
they differ, and the plannable number alone reads optimistically
|
|
438
|
+
they differ, and the plannable number alone reads optimistically — sketched
|
|
439
|
+
here on a graph whose `billing` and `shipping` run elsewhere:
|
|
439
440
|
|
|
440
441
|
```
|
|
441
442
|
5/5 queries plannable locally (100%), 2 servable here
|
|
@@ -454,14 +455,14 @@ the second number and says it counted planning only. The subgraph line says
|
|
|
454
455
|
which subgraphs each query touches, so a graph whose queries all sit in one is
|
|
455
456
|
visibly a different situation from one that stitches everywhere. Anything
|
|
456
457
|
refused is listed after it, grouped by category, so one glance says whether the
|
|
457
|
-
gap is one construct or many. The
|
|
458
|
+
gap is one construct or many. The first run above is the demo graph in
|
|
458
459
|
`spec/support/federation`, not a real app's mix.
|
|
459
460
|
|
|
460
461
|
### How the refusals are kept honest
|
|
461
462
|
|
|
462
463
|
A double that quietly answered *differently* from the router would be worse
|
|
463
464
|
than no double at all, so
|
|
464
|
-
[`spec/integration/router_parity_spec.rb`](
|
|
465
|
+
[`spec/integration/router_parity_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/integration/router_parity_spec.rb)
|
|
465
466
|
serves the demo subgraphs over HTTP, boots a real `@apollo/gateway` on the same
|
|
466
467
|
supergraph, and runs the whole corpus through both. Three outcomes, one of them
|
|
467
468
|
a defect: match, refuse, or answer differently — and the spec fails on the
|
|
@@ -547,4 +548,4 @@ And a `@key(..., resolvable: false)` declares a key this subgraph does *not*
|
|
|
547
548
|
answer for, so it builds nothing. Key fields typed as scalars get their
|
|
548
549
|
registered Ruby type; anything else (a nested selection) is an open `Hash` the
|
|
549
550
|
runtime narrows. Every shape above is a named example in
|
|
550
|
-
[`spec/federation_spec.rb`](
|
|
551
|
+
[`spec/federation_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/federation_spec.rb).
|
data/docs/generated_modules.md
CHANGED
|
@@ -409,7 +409,7 @@ The name is a function of that field's own position and nothing else, which is
|
|
|
409
409
|
the property that matters when generated code is checked in and referenced from
|
|
410
410
|
app code: **adding, removing, or reordering an unrelated selection can never
|
|
411
411
|
rename a struct you already use.**
|
|
412
|
-
[`spec/naming_spec.rb`](
|
|
412
|
+
[`spec/naming_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/naming_spec.rb) asserts each of those three
|
|
413
413
|
edits leaves the name alone.
|
|
414
414
|
|
|
415
415
|
The key is used verbatim — no pluralization heuristics, so a list field `pets`
|
|
@@ -521,7 +521,7 @@ the generated struct in your own file and add sig'd methods; Sorbet merges the
|
|
|
521
521
|
bodies.
|
|
522
522
|
|
|
523
523
|
Every form above, and every error it raises, is a named example in
|
|
524
|
-
[`spec/aliases_spec.rb`](
|
|
524
|
+
[`spec/aliases_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/aliases_spec.rb).
|
|
525
525
|
|
|
526
526
|
## Clients
|
|
527
527
|
|
data/docs/getting_started.md
CHANGED
|
@@ -41,7 +41,7 @@ initializer that fits:
|
|
|
41
41
|
|
|
42
42
|
| flag | |
|
|
43
43
|
|---|---|
|
|
44
|
-
| `--auth` | name of the ENV var holding the auth token — default `GRAPHWEAVER_AUTH`. Url only.
|
|
44
|
+
| `--auth` | name of the ENV var holding the auth token — default `GRAPHWEAVER_AUTH`. Url only. The name is recorded into the dump, so `schema:refresh`/`schema:diff`/`queries:check` read the same one the initializer does |
|
|
45
45
|
| `--no-schema` | skip writing the dump; `rake graph_weaver:schema:refresh URL=...` does it later |
|
|
46
46
|
|
|
47
47
|
Re-running is safe — every file goes through the usual Rails conflict
|
data/docs/logging.md
CHANGED
|
@@ -52,7 +52,7 @@ response. Its payload carries:
|
|
|
52
52
|
| Key | |
|
|
53
53
|
|-----|--|
|
|
54
54
|
| `:url` | the endpoint — nil in-process |
|
|
55
|
-
| `:schema` | the schema class, in-process only |
|
|
55
|
+
| `:schema` | the schema class's name, in-process only — a String, so a payload logs as it stands |
|
|
56
56
|
| `:operation` | the operation name sent with the request (a generated module always has one) — what a trace keys on |
|
|
57
57
|
| `:status` | the HTTP status, added once the response lands |
|
|
58
58
|
|
data/docs/testing.md
CHANGED
|
@@ -62,7 +62,7 @@ DashboardQuery.execute!.me.username
|
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
All three modes, tagged and running end to end, are
|
|
65
|
-
[`spec/rspec_spec.rb`](
|
|
65
|
+
[`spec/rspec_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/rspec_spec.rb) — the reference for anything
|
|
66
66
|
this page leaves out.
|
|
67
67
|
|
|
68
68
|
## Nothing to configure
|
|
@@ -72,9 +72,9 @@ looked for — rather than guessing**:
|
|
|
72
72
|
|
|
73
73
|
- **the schema** is `config.schema` if you set one, else the committed dump
|
|
74
74
|
at `GraphWeaver.schema_path`, else the schema `GraphWeaver.client` talks to.
|
|
75
|
-
(
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
(A federated app has no one schema class, so `config.schema` is the whole
|
|
76
|
+
graph's shape and an example names the subgraph whose resolvers it wants —
|
|
77
|
+
see `graphql_in_process` below.)
|
|
78
78
|
- **`:in_process`** needs the live schema *class*, since only that has
|
|
79
79
|
resolvers: the one your client already runs in-process, else the loaded
|
|
80
80
|
class that defines everything the schema declares — the same
|
|
@@ -310,9 +310,10 @@ them offline, above the transport (no HTTP interception):
|
|
|
310
310
|
client = GraphWeaver::Testing.cassette("github", client: live)
|
|
311
311
|
```
|
|
312
312
|
|
|
313
|
-
Re-record with `GRAPHWEAVER_RECORD=1`, and set `config.anonymize = true` so
|
|
314
|
-
|
|
315
|
-
|
|
313
|
+
Re-record with `GRAPHWEAVER_RECORD=1`, and set `config.anonymize = true` so the
|
|
314
|
+
response is scrubbed on its way to disk — the query and its variables are the
|
|
315
|
+
replay key and are recorded verbatim, so read a cassette before committing it.
|
|
316
|
+
The full workflow guide is **[cassettes](cassettes.md)**.
|
|
316
317
|
|
|
317
318
|
## Real resolvers, one schema — `graphql: :in_process`
|
|
318
319
|
|
data/docs/upgrading.md
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
# Upgrading to 0.5.0
|
|
2
2
|
|
|
3
|
-
0.5.0 is one large breaking release. Almost all of it is caught mechanically
|
|
4
|
-
|
|
3
|
+
0.5.0 is one large breaking release. Almost all of it is caught mechanically,
|
|
4
|
+
in this order:
|
|
5
5
|
|
|
6
6
|
```sh
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
# 1. rename the path settings first — generate won't load without them
|
|
8
|
+
# (queries_path -> queries_paths, generated_path -> generated_paths,
|
|
9
|
+
# fragments_path -> fragments_paths; see "One plurality rule" below)
|
|
10
|
+
|
|
11
|
+
bundle exec tapioca gem graph_weaver # 2. regenerate the RBI
|
|
12
|
+
rake graph_weaver:generate # 3. the emitted call shape changed
|
|
13
|
+
srb tc # 4. every call site that moved is an error
|
|
14
|
+
rake graph_weaver:verify # 5. fails until the tree is regenerated
|
|
10
15
|
```
|
|
11
16
|
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
**Step 2 is not optional.** Against the 0.4.6 RBI, `srb tc` reports errors
|
|
18
|
+
pointing into your `generated/` directory — `QueryModule`, `client_for`,
|
|
19
|
+
`check_envelope!` — which read as though codegen emitted broken Ruby. It
|
|
20
|
+
didn't; sorbet is checking new generated code against the old gem's types.
|
|
21
|
+
Regenerate the RBI and what remains is only your own call sites.
|
|
22
|
+
|
|
23
|
+
Generated code is `# typed: strict`, so step 4 finds those for you. The rest of
|
|
24
|
+
this page is what a typechecker can't see.
|
|
14
25
|
|
|
15
26
|
## `execute` means one thing now
|
|
16
27
|
|
|
@@ -30,7 +41,9 @@ GraphWeaver.run(source, query, **vars) # after
|
|
|
30
41
|
```
|
|
31
42
|
|
|
32
43
|
**This one is worth grepping for.** `Client#execute` still exists, so a stale
|
|
33
|
-
call fails at runtime rather than at typecheck
|
|
44
|
+
call fails at runtime rather than at typecheck — as do `GraphWeaver.execute`
|
|
45
|
+
and `GraphWeaver.reset_scalars!`, which are simply gone and will not be flagged
|
|
46
|
+
until the RBI is regenerated (step 2): `rg '\.execute!?\(' --type ruby`
|
|
34
47
|
and check each hit is passing `variables:` rather than loose kwargs.
|
|
35
48
|
|
|
36
49
|
A generated module takes its per-call client as a **keyword**:
|
|
@@ -69,16 +82,25 @@ GraphWeaver::Codegen.reset_scalars! # after (also reset_enums!, clear_scalar
|
|
|
69
82
|
|
|
70
83
|
| before | after |
|
|
71
84
|
|---|---|
|
|
72
|
-
| `response.ok?` | `response.success?` |
|
|
73
85
|
| `Testing.config.auto_fake = true` | `Testing.config.default_mode = :fake` |
|
|
74
86
|
| `register_scalar(…, coerce: :to_s)` | `coerce: true`, or a `cast:`/`serialize:` pair |
|
|
75
87
|
| a mutation's `…Query` module | `…Mutation` |
|
|
88
|
+
| `graphql: :none` (rspec tag) | `graphql: false` |
|
|
89
|
+
|
|
90
|
+
**The shared types module was three, and is now one.** `GraphQLInputs`,
|
|
91
|
+
`GraphQLEnums` and `GraphQLUnions` are all `GraphQLTypes`, and the files move
|
|
92
|
+
with them — `generated/inputs/` becomes `generated/types/`. The three settings
|
|
93
|
+
that named them (`inputs_module=`, `enums_module=`, `unions_module=`) are one
|
|
94
|
+
`types_module=`. Regenerating writes the new tree; delete the old directory,
|
|
95
|
+
which pruning leaves behind empty.
|
|
76
96
|
|
|
77
|
-
|
|
78
|
-
|
|
97
|
+
If your specs run one schema class in-process while your client points at a
|
|
98
|
+
different API, name it — per example, since a federated suite runs more than
|
|
99
|
+
one:
|
|
79
100
|
|
|
80
101
|
```ruby
|
|
81
|
-
|
|
102
|
+
graphql_in_process(MySchema) # in the example
|
|
103
|
+
GraphWeaver::Testing.config.schema = MySchema # or once, for the whole suite
|
|
82
104
|
```
|
|
83
105
|
|
|
84
106
|
## Registering from Rails
|
data/graph_weaver.gemspec
CHANGED
|
@@ -7,13 +7,27 @@ Gem::Specification.new do |s|
|
|
|
7
7
|
s.description = "Your .graphql files, compiled into Sorbet types — and the fakes to test them."
|
|
8
8
|
# ".yardopts" explicitly: `git ls-files *` skips dotfiles, and
|
|
9
9
|
# rubydoc.info needs it shipped to render docstrings as markdown
|
|
10
|
-
|
|
10
|
+
# CLAUDE.md/PLAN.md/REVIEW.md/NOTES.md/DECISIONS.md are written for whoever
|
|
11
|
+
# works on the gem, not whoever installs it — and REVIEW.md carries examples
|
|
12
|
+
# from before the API it describes was rewritten
|
|
13
|
+
s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' ':!:examples' \
|
|
14
|
+
':!:CLAUDE.md' ':!:PLAN.md' ':!:REVIEW.md' ':!:NOTES.md' \
|
|
15
|
+
':!:DECISIONS.md' ':!:Makefile' ':!:design'`.split("\n") + [".yardopts"]
|
|
11
16
|
s.homepage = "https://github.com/dpep/graph_weaver"
|
|
12
17
|
s.license = "MIT"
|
|
13
18
|
s.name = "graph_weaver"
|
|
14
|
-
|
|
19
|
+
# rubygems.org shows summary as the headline, description below it
|
|
20
|
+
s.summary = "A typed GraphQL client for Ruby"
|
|
15
21
|
s.version = GraphWeaver::VERSION
|
|
16
22
|
|
|
23
|
+
s.metadata = {
|
|
24
|
+
"bug_tracker_uri" => "#{s.homepage}/issues",
|
|
25
|
+
"changelog_uri" => "#{s.homepage}/blob/main/CHANGELOG.md",
|
|
26
|
+
"documentation_uri" => "#{s.homepage}/tree/main/docs",
|
|
27
|
+
"rubygems_mfa_required" => "true",
|
|
28
|
+
"source_code_uri" => s.homepage,
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
s.required_ruby_version = ">= 3.3"
|
|
18
32
|
|
|
19
33
|
# 2.6.7 fills defaulted directive arguments when building from SDL
|
|
@@ -31,17 +31,14 @@ module GraphWeaver
|
|
|
31
31
|
argument :source, type: :string, banner: "SOURCE",
|
|
32
32
|
desc: "what you'd pass to GraphWeaver.new: an endpoint url, a graphql-ruby schema class, or a schema dump path"
|
|
33
33
|
|
|
34
|
+
# the default is SchemaLoader's, not one restated here — an --auth the
|
|
35
|
+
# generator omits from the dump is one the schema tasks then can't find
|
|
34
36
|
class_option :auth, type: :string,
|
|
35
|
-
desc: "name of the ENV var holding the auth token (url only) —
|
|
37
|
+
desc: "name of the ENV var holding the auth token (url only) — " \
|
|
38
|
+
"default #{GraphWeaver::SchemaLoader::DEFAULT_AUTH_ENV}"
|
|
36
39
|
class_option :schema, type: :boolean, default: true,
|
|
37
40
|
desc: "write the schema dump codegen reads"
|
|
38
41
|
|
|
39
|
-
# a Ruby constant path names a schema class; anything that is neither
|
|
40
|
-
# this nor a url is taken as a path to a dump
|
|
41
|
-
CONSTANT = /\A[A-Z]\w*(::[A-Z]\w*)*\z/
|
|
42
|
-
|
|
43
|
-
DEFAULT_AUTH = "GRAPHWEAVER_AUTH"
|
|
44
|
-
|
|
45
42
|
# Before anything is written: a mistyped source or a flag that doesn't
|
|
46
43
|
# apply to it is a mistake in the command just typed, so say so there
|
|
47
44
|
# rather than at boot, three files later.
|
|
@@ -140,14 +137,16 @@ module GraphWeaver
|
|
|
140
137
|
end
|
|
141
138
|
end
|
|
142
139
|
|
|
143
|
-
# Which of GraphWeaver.new's source forms this is
|
|
144
|
-
#
|
|
145
|
-
#
|
|
140
|
+
# Which of GraphWeaver.new's source forms this is. Neither test is its
|
|
141
|
+
# own — a url is whatever the client calls one, a constant path whatever
|
|
142
|
+
# codegen will spell — so the generator can't disagree with either about
|
|
143
|
+
# what it just wrote an initializer for. Anything that is neither is
|
|
144
|
+
# taken as a path to a dump.
|
|
146
145
|
def form
|
|
147
146
|
@form ||=
|
|
148
147
|
if source.match?(GraphWeaver::Client::URL)
|
|
149
148
|
:url
|
|
150
|
-
elsif source.match?(
|
|
149
|
+
elsif source.match?(GraphWeaver::Codegen::CONSTANT_NAME)
|
|
151
150
|
:schema_class
|
|
152
151
|
else
|
|
153
152
|
:path
|
|
@@ -174,7 +173,7 @@ module GraphWeaver
|
|
|
174
173
|
# writes the conventional one.
|
|
175
174
|
def schema_path = (form == :path) ? source : GraphWeaver.schema_path
|
|
176
175
|
|
|
177
|
-
def auth_var = options[:auth] ||
|
|
176
|
+
def auth_var = options[:auth] || GraphWeaver::SchemaLoader::DEFAULT_AUTH_ENV
|
|
178
177
|
|
|
179
178
|
def refresh_command
|
|
180
179
|
(form == :url) ? "rake graph_weaver:schema:refresh" : "rails g graph_weaver:install #{source}"
|
|
@@ -243,15 +242,16 @@ module GraphWeaver
|
|
|
243
242
|
end
|
|
244
243
|
|
|
245
244
|
# fragments are in documents: too — without them an editor reports
|
|
246
|
-
# `Unknown fragment` on any query that spreads a shared one
|
|
245
|
+
# `Unknown fragment` on any query that spreads a shared one. The glob is
|
|
246
|
+
# codegen's, so the editor validates exactly the files codegen reads.
|
|
247
247
|
def editor_config
|
|
248
248
|
<<~YAML
|
|
249
249
|
# Autocomplete and validation for .graphql files in VS Code / RubyMine.
|
|
250
250
|
# https://github.com/dpep/graph_weaver/blob/main/docs/editors.md
|
|
251
251
|
schema: #{schema_path}
|
|
252
252
|
documents:
|
|
253
|
-
- #{GraphWeaver.queries_paths.first
|
|
254
|
-
- #{GraphWeaver.fragments_paths.first
|
|
253
|
+
- #{File.join(GraphWeaver.queries_paths.first, GraphWeaver::Codegen::DOCUMENT_GLOB)}
|
|
254
|
+
- #{File.join(GraphWeaver.fragments_paths.first, GraphWeaver::Codegen::DOCUMENT_GLOB)}
|
|
255
255
|
YAML
|
|
256
256
|
end
|
|
257
257
|
end
|
data/lib/graph_weaver/client.rb
CHANGED
|
@@ -36,12 +36,16 @@ class GraphWeaver::Client
|
|
|
36
36
|
|
|
37
37
|
URL = %r{\Ahttps?://}i
|
|
38
38
|
|
|
39
|
+
# refused from two branches — a url source, and a schema source with
|
|
40
|
+
# nothing to hand a context to — so the two can't word it differently
|
|
41
|
+
CONTEXT_IN_PROCESS = "context: applies to a schema class executing in-process"
|
|
42
|
+
|
|
39
43
|
def initialize(source, auth: nil, headers: {}, retries: false, transport: nil, cache: nil, ttl: nil,
|
|
40
44
|
open_timeout: nil, read_timeout: nil, context: nil, &middleware)
|
|
41
45
|
check_source!(source)
|
|
42
46
|
|
|
43
47
|
if source.is_a?(String) && source.match?(URL)
|
|
44
|
-
raise ArgumentError,
|
|
48
|
+
raise ArgumentError, CONTEXT_IN_PROCESS if context
|
|
45
49
|
|
|
46
50
|
built = build_transport(source, auth:, headers:, kind: transport, open_timeout:, read_timeout:, &middleware)
|
|
47
51
|
@transport = wrap_retries(built, retries)
|
|
@@ -65,7 +69,7 @@ class GraphWeaver::Client
|
|
|
65
69
|
if context && !(source.is_a?(Module) && transport.nil?)
|
|
66
70
|
# nothing would ever read it — a dump has no resolvers, and an
|
|
67
71
|
# explicit transport carries its own
|
|
68
|
-
raise ArgumentError,
|
|
72
|
+
raise ArgumentError, CONTEXT_IN_PROCESS
|
|
69
73
|
end
|
|
70
74
|
|
|
71
75
|
# InProcess adds context:, logging and branded errors to the bare
|
|
@@ -56,9 +56,14 @@ class GraphWeaver::Codegen
|
|
|
56
56
|
"#{@module_name}: #{message}"
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# An alias emits a plain instance method, so it is held to the same bar
|
|
60
|
+
# as a wire field's prop: a name the struct already answers to would be
|
|
61
|
+
# silently overridden, and `hash` or `inspect` breaks the object rather
|
|
62
|
+
# than the file.
|
|
59
63
|
def check_alias_name!(node, name)
|
|
60
64
|
taken = node.fields.any? { |f| f.prop == name } ||
|
|
61
|
-
|
|
65
|
+
STRUCT_METHODS.include?(name) || ALIAS_RESERVED.include?(name) ||
|
|
66
|
+
RUBY_KEYWORDS.include?(name)
|
|
62
67
|
return unless taken
|
|
63
68
|
|
|
64
69
|
raise GraphWeaver::Error,
|
|
@@ -70,9 +75,10 @@ class GraphWeaver::Codegen
|
|
|
70
75
|
GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
|
|
71
76
|
end
|
|
72
77
|
|
|
73
|
-
# methods
|
|
74
|
-
#
|
|
75
|
-
|
|
78
|
+
# The CLASS methods a generated struct defines; STRUCT_METHODS covers the
|
|
79
|
+
# instance side, and both are checked with RUBY_KEYWORDS alongside (all
|
|
80
|
+
# three are defined by the class this mixes into).
|
|
81
|
+
ALIAS_RESERVED = %w[from_h].to_set.freeze
|
|
76
82
|
# list selectors — pick one element out of a list-typed hop, always nilable
|
|
77
83
|
# (the list may be empty). Everything else is a field prop.
|
|
78
84
|
LIST_SELECTORS = %w[first last].freeze
|
|
@@ -500,9 +500,12 @@ class GraphWeaver::Codegen
|
|
|
500
500
|
out << " sig { params(response: T.untyped).returns(GraphWeaver::Response[Result]) }"
|
|
501
501
|
out << " def self.from_response(response)"
|
|
502
502
|
out << " raw = GraphWeaver.check_envelope!(response.to_h, Result)"
|
|
503
|
+
# errors first: a cast failure is usually a field the server nulled for
|
|
504
|
+
# a reason it stated, and that reason belongs in the raised error
|
|
505
|
+
out << " errors = (raw[\"errors\"] || []).map { |e| GraphWeaver::GraphQLError.from_h(e) }"
|
|
503
506
|
out << " GraphWeaver::Response[Result].new("
|
|
504
|
-
out << " data: (
|
|
505
|
-
out << " errors
|
|
507
|
+
out << " data: (GraphWeaver.cast_data(Result, raw[\"data\"], errors) if raw[\"data\"]),"
|
|
508
|
+
out << " errors:,"
|
|
506
509
|
out << " extensions: raw[\"extensions\"] || {},"
|
|
507
510
|
out << " )"
|
|
508
511
|
out << " end"
|
|
@@ -548,8 +551,13 @@ class GraphWeaver::Codegen
|
|
|
548
551
|
out << ""
|
|
549
552
|
end
|
|
550
553
|
node.fields.each do |field|
|
|
554
|
+
# A schema default makes the field optional here, so the prop has to
|
|
555
|
+
# admit the nil that omitting it leaves behind — the same widening
|
|
556
|
+
# execute's kwargs already get.
|
|
557
|
+
type = field.node.prop_type
|
|
558
|
+
type = "T.nilable(#{type})" if !field.required && field.node.non_null? && type != "T.untyped"
|
|
551
559
|
default = field.required ? "" : ", default: nil"
|
|
552
|
-
out << "#{pad} const :#{field.prop}, #{
|
|
560
|
+
out << "#{pad} const :#{field.prop}, #{type}#{default}"
|
|
553
561
|
end
|
|
554
562
|
out << ""
|
|
555
563
|
out << "#{pad} # (prop, wire, required, serializer, coercer) per field"
|
|
@@ -27,9 +27,7 @@ class GraphWeaver::Codegen
|
|
|
27
27
|
# autoloading is set up after those run. Say where it does resolve.
|
|
28
28
|
if type.is_a?(String)
|
|
29
29
|
raise ArgumentError, "type: is the T::Enum itself, not its name — " \
|
|
30
|
-
"register_enum(#{@graphql_name.inspect}, #{type}).
|
|
31
|
-
"resolvable while config/initializers run; register from a " \
|
|
32
|
-
"Rails.application.config.to_prepare block, which generation also runs first."
|
|
30
|
+
"register_enum(#{@graphql_name.inspect}, #{type}). #{GraphWeaver::Codegen::AUTOLOAD_HINT}"
|
|
33
31
|
end
|
|
34
32
|
unless type.is_a?(Class) && type < T::Enum
|
|
35
33
|
raise ArgumentError, "type: must be a T::Enum subclass, got #{type.inspect}"
|