graph_weaver 0.2.2 → 0.4.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 +65 -0
- data/CLAUDE.md +70 -0
- data/Gemfile.lock +21 -21
- data/README.md +2 -0
- data/docs/errors.md +25 -5
- data/docs/federation.md +100 -0
- data/docs/generated_modules.md +7 -4
- data/docs/getting_started.md +52 -0
- data/docs/scalars.md +15 -2
- data/lib/graph_weaver/client.rb +4 -3
- data/lib/graph_weaver/codegen/emit.rb +46 -10
- data/lib/graph_weaver/codegen/enum_type.rb +5 -5
- data/lib/graph_weaver/codegen/nodes.rb +21 -1
- data/lib/graph_weaver/codegen.rb +201 -13
- data/lib/graph_weaver/errors.rb +145 -19
- data/lib/graph_weaver/hints.rb +6 -1
- data/lib/graph_weaver/input_struct.rb +8 -0
- data/lib/graph_weaver/response.rb +2 -2
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +109 -41
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a608ee344d2e8ebf8ff0e847a34005114cfd1efd5a44c7aa66ef81c457706526
|
|
4
|
+
data.tar.gz: 4622816580d4158d4b84140b85876f00582ac85521686da4417f1945cdb4c276
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4d496923726cf4296f048f7f60305045aa1bfe042c007bcf10b87d34673bc451af85bf6165187e7f8a7b606dd0176ad54648c0ea504f69cb966d9addc9ba4503
|
|
7
|
+
data.tar.gz: ae5b74bd0de58a9f89fbc218e0103ab805c2b62bb5a9172bff6ebd38d890ad4804429789877b7a7c9ce343cb0d24f969fe8eb5785222736c042bc56008a2648a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,68 @@
|
|
|
1
|
+
### v0.4.0 (2026-07-28)
|
|
2
|
+
- Shared unions: when a named shared fragment is the whole selection on a union
|
|
3
|
+
field (`feed { ...FeedItemFields }`), its type is hoisted once into a
|
|
4
|
+
`GraphQLUnions` module and every query that spreads it aliases the same type —
|
|
5
|
+
so a union selected across many queries is one Ruby type family (one
|
|
6
|
+
exhaustive `case … T.absurd`), not a fresh dispatch module per query. No flag:
|
|
7
|
+
the shared fragment is the opt-in. Triggers only for an exact lone spread;
|
|
8
|
+
mixing other fields, or shadowing with a query-local fragment, keeps the union
|
|
9
|
+
inlined. Module name derives from the output path (override with
|
|
10
|
+
`GraphWeaver.unions_module=`); dynamic `parse` still inlines.
|
|
11
|
+
- Removed the `shared_inputs:` option from `generate!` / `verify_generated!`.
|
|
12
|
+
Directory-based generation always emits each input type once into a shared
|
|
13
|
+
module — the self-contained-module opt-out added complexity for little value.
|
|
14
|
+
Single-query `parse` / `Codegen.generate` still inline their types (there's
|
|
15
|
+
no set to share against). Only affects callers who passed
|
|
16
|
+
`shared_inputs: false`.
|
|
17
|
+
|
|
18
|
+
### v0.3.4 (2026-07-29)
|
|
19
|
+
- Shared fragments: define reusable named fragments once (default
|
|
20
|
+
`app/graphql/fragments`, configurable via `GraphWeaver.fragments_paths`) and
|
|
21
|
+
spread them from any query. Each query inlines only the fragments it
|
|
22
|
+
transitively spreads, so the sent query stays self-contained. Fragment files
|
|
23
|
+
hold only fragments; names are unique across them. Works in `generate!` and
|
|
24
|
+
dynamic `parse`.
|
|
25
|
+
|
|
26
|
+
### v0.3.3 (2026-07-29)
|
|
27
|
+
- Union member-type dedup: a union selected more than once on a struct now
|
|
28
|
+
collapses to one Ruby type family instead of a distinct per-field family with
|
|
29
|
+
identical members — so a consumer gets a single exhaustive
|
|
30
|
+
`case … T.absurd` across every field of that union. Structurally different
|
|
31
|
+
selections stay distinct types. (First cut: same-struct siblings; regenerate
|
|
32
|
+
checked-in modules to pick it up.)
|
|
33
|
+
|
|
34
|
+
### v0.3.2 (2026-07-29)
|
|
35
|
+
- `register_scalar` accepts a `Type.field` coordinate to override how one
|
|
36
|
+
field's scalar deserializes — so the same scalar can be different Ruby types
|
|
37
|
+
across fields (`register_scalar("User.birthday", Date)` while
|
|
38
|
+
`ISO8601DateTime` stays a `Time` elsewhere). Field overrides win over the
|
|
39
|
+
scalar-name registration; both stack global-then-client. The coordinate is
|
|
40
|
+
validated against the schema (a typo'd or non-scalar field raises). Same
|
|
41
|
+
method, same signature — a `.` in the name selects the field form.
|
|
42
|
+
|
|
43
|
+
### v0.3.1 (2026-07-28)
|
|
44
|
+
- `GraphWeaver.extend_t_sig` controls whether generated modules/structs emit
|
|
45
|
+
`extend T::Sig`. Default (`nil`) auto-detects a global T::Sig injection
|
|
46
|
+
(`class Module; include T::Sig`) and skips the now-redundant `extend` — so
|
|
47
|
+
generated code stays clean under rubocop's `Sorbet/RedundantExtendTSig`.
|
|
48
|
+
Force with `true`/`false`; `false` requires the global include.
|
|
49
|
+
|
|
50
|
+
### v0.3.0 (2026-07-28)
|
|
51
|
+
- Renamed `register_type` to `extend_type` to disambiguate intent: it
|
|
52
|
+
*decorates* a generated struct with mixin modules/helpers — it doesn't
|
|
53
|
+
define or replace a type's deserialization (that's `register_scalar` /
|
|
54
|
+
`register_enum`, for leaf types, whose Ruby shape is fixed; a composite's
|
|
55
|
+
shape is per-query, so there's nothing fixed to replace). No deprecation —
|
|
56
|
+
the old name is dropped.
|
|
57
|
+
- Invalid query input now raises `GraphWeaver::InputError` (under the
|
|
58
|
+
`GraphWeaver::Error` umbrella) instead of a raw `ArgumentError` /
|
|
59
|
+
`KeyError` / sorbet `TypeError`: an unknown or typo'd input key, a
|
|
60
|
+
missing required field, an out-of-range enum, or a wrong-typed field
|
|
61
|
+
when an input object is built from a hash through `coerce`. Carries
|
|
62
|
+
`#field` / `#struct` and a JSON-ready `#to_h` — one rescue point for
|
|
63
|
+
returning a 422 at an API boundary. Top-level *scalar* kwargs still
|
|
64
|
+
fail like any Ruby method call (sorbet `TypeError` / `ArgumentError`).
|
|
65
|
+
|
|
1
66
|
### v0.2.2 (2026-07-22)
|
|
2
67
|
- Generated modules expose from_response / from_response! alongside
|
|
3
68
|
execute / execute!: deserialize a raw GraphQL response (fetched by any
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
A typed GraphQL client for Ruby: generates `# typed: strict` Ruby (nested
|
|
8
|
+
`T::Struct`s + a typed `execute`) from your queries, checked against the schema
|
|
9
|
+
at generation time. Sorbet is core to the product.
|
|
10
|
+
|
|
11
|
+
## Sorbet typing policy — type by value, not for coverage
|
|
12
|
+
|
|
13
|
+
Sorbet being core does **not** mean every file should be `# typed: strict`. Type
|
|
14
|
+
where it pays off in developer experience; leave the rest at `# typed: true`.
|
|
15
|
+
|
|
16
|
+
- **Strict (full sigs) — developer-facing contracts.** The types users touch:
|
|
17
|
+
`response.rb` (the envelope every `execute` returns), the error hierarchy
|
|
18
|
+
(`errors.rb`), and the **generated code** (emitted `# typed: strict`). Concrete
|
|
19
|
+
types here give downstream apps real call-site checking + autocomplete — that's
|
|
20
|
+
the product.
|
|
21
|
+
- **`# typed: true` (loose) — dynamic / boundary internals.** The codegen
|
|
22
|
+
(`codegen.rb`, `codegen/nodes.rb`, `codegen/emit.rb`, `codegen/scalar_type.rb`,
|
|
23
|
+
`codegen/enum_type.rb`) walks graphql-ruby's approximately-typed AST and builds
|
|
24
|
+
modules/strings dynamically; `client.rb` wraps a graphql-ruby schema and a
|
|
25
|
+
duck-typed transport. Strict here is ~all `T.untyped` — paperwork that documents
|
|
26
|
+
shape without catching anything. **Don't promote these to strict.**
|
|
27
|
+
- Rule of thumb: if a sig would be mostly `T.untyped`, it isn't worth writing.
|
|
28
|
+
Concrete types = value; `T.untyped` sigs = paperwork.
|
|
29
|
+
- `railtie.rb` / `tasks.rb` are `# typed: ignore` (Rails/Rake DSL);
|
|
30
|
+
`directive_defaults_patch.rb` is `# typed: false` (a prepended monkeypatch).
|
|
31
|
+
|
|
32
|
+
## Design invariants (don't "fix" these)
|
|
33
|
+
|
|
34
|
+
- **The client slot is duck-typed.** A transport, `Retry`, a live graphql-ruby
|
|
35
|
+
schema class, or a test fake all satisfy one contract —
|
|
36
|
+
`execute(query, variables:) => {"data" => ..., "errors" => ...}` — with no
|
|
37
|
+
shared base class. **Don't formalize it as a strict Sorbet interface**: a
|
|
38
|
+
graphql-ruby `Schema` class fits the slot without inheriting anything, and a
|
|
39
|
+
strict interface would exclude it. This is why the transport/client seams stay
|
|
40
|
+
loosely typed.
|
|
41
|
+
- **Codegen is query-driven.** Structs are generated per selection set, only for
|
|
42
|
+
the types a query actually touches — not the whole schema (so extra schema
|
|
43
|
+
types, e.g. federation `join__*`, generate no code).
|
|
44
|
+
- **Leaf codecs vs composite decoration.** `register_scalar` / `register_enum`
|
|
45
|
+
*define/replace* how a leaf deserializes (its Ruby shape is fixed);
|
|
46
|
+
`extend_type` only *decorates* a generated composite struct with mixins — it
|
|
47
|
+
can't replace one, because a composite's shape varies per query. Don't add a
|
|
48
|
+
"replace a composite's deserializer" path.
|
|
49
|
+
|
|
50
|
+
## Green before commit
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
bundle exec rspec # full suite
|
|
54
|
+
bundle exec srb tc # Sorbet typecheck (CI gates on this too)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Both must pass. Sorbet sigs are runtime-checked by sorbet-runtime, so a wrong
|
|
58
|
+
sig surfaces as an rspec failure, not only a `srb tc` error — a green suite
|
|
59
|
+
validates the sigs against real usage.
|
|
60
|
+
|
|
61
|
+
## Version bumps
|
|
62
|
+
|
|
63
|
+
Bump `lib/graph_weaver/version.rb` and, in the **same commit**:
|
|
64
|
+
|
|
65
|
+
- update `Gemfile.lock` (the gem pins its own version there; CI runs a frozen
|
|
66
|
+
`bundle install`, which fails at the *setup* step with exit code 16 — before
|
|
67
|
+
tests — if the lock is stale), and
|
|
68
|
+
- add a `CHANGELOG.md` entry.
|
|
69
|
+
|
|
70
|
+
`gem push` (the actual RubyGems release) is a separate, manual step.
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
graph_weaver (0.
|
|
4
|
+
graph_weaver (0.4.0)
|
|
5
5
|
graphql (>= 2)
|
|
6
6
|
sorbet-runtime
|
|
7
7
|
|
|
@@ -42,7 +42,7 @@ GEM
|
|
|
42
42
|
google-protobuf (4.35.1-x86_64-linux-gnu)
|
|
43
43
|
bigdecimal
|
|
44
44
|
rake (~> 13.3)
|
|
45
|
-
graphql (2.6.
|
|
45
|
+
graphql (2.6.7)
|
|
46
46
|
base64
|
|
47
47
|
fiber-storage
|
|
48
48
|
logger
|
|
@@ -99,16 +99,16 @@ GEM
|
|
|
99
99
|
rubydex (0.2.7-arm64-darwin)
|
|
100
100
|
rubydex (0.2.7-x86_64-darwin)
|
|
101
101
|
rubydex (0.2.7-x86_64-linux)
|
|
102
|
-
simplecov (1.0.
|
|
103
|
-
sorbet (0.6.
|
|
104
|
-
sorbet-static (= 0.6.
|
|
105
|
-
sorbet-runtime (0.6.
|
|
106
|
-
sorbet-static (0.6.
|
|
107
|
-
sorbet-static (0.6.
|
|
108
|
-
sorbet-static (0.6.
|
|
109
|
-
sorbet-static-and-runtime (0.6.
|
|
110
|
-
sorbet (= 0.6.
|
|
111
|
-
sorbet-runtime (= 0.6.
|
|
102
|
+
simplecov (1.0.3)
|
|
103
|
+
sorbet (0.6.13365)
|
|
104
|
+
sorbet-static (= 0.6.13365)
|
|
105
|
+
sorbet-runtime (0.6.13365)
|
|
106
|
+
sorbet-static (0.6.13365-aarch64-linux)
|
|
107
|
+
sorbet-static (0.6.13365-universal-darwin)
|
|
108
|
+
sorbet-static (0.6.13365-x86_64-linux)
|
|
109
|
+
sorbet-static-and-runtime (0.6.13365)
|
|
110
|
+
sorbet (= 0.6.13365)
|
|
111
|
+
sorbet-runtime (= 0.6.13365)
|
|
112
112
|
spoom (1.8.3)
|
|
113
113
|
erubi (>= 1.10.0)
|
|
114
114
|
prism (>= 0.28.0)
|
|
@@ -176,8 +176,8 @@ 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.
|
|
180
|
-
graphql (2.6.
|
|
179
|
+
graph_weaver (0.4.0)
|
|
180
|
+
graphql (2.6.7) sha256=759755ce5819c965b6459c5039605cf2e4d876bb8ba5e03451676870ea787c45
|
|
181
181
|
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
|
|
182
182
|
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
|
|
183
183
|
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
|
|
@@ -206,13 +206,13 @@ CHECKSUMS
|
|
|
206
206
|
rubydex (0.2.7-arm64-darwin) sha256=f0d28bbf4153568be79b671642424750053e0bea971b60ddf5cec19bf4563990
|
|
207
207
|
rubydex (0.2.7-x86_64-darwin) sha256=b002b259d118ac69de44470eff1597143318402c45630c47371f9542631447dc
|
|
208
208
|
rubydex (0.2.7-x86_64-linux) sha256=dacfade9fa42ce4469618da6dac07e69d5f3ac6a313b4caced5234c8f052419a
|
|
209
|
-
simplecov (1.0.
|
|
210
|
-
sorbet (0.6.
|
|
211
|
-
sorbet-runtime (0.6.
|
|
212
|
-
sorbet-static (0.6.
|
|
213
|
-
sorbet-static (0.6.
|
|
214
|
-
sorbet-static (0.6.
|
|
215
|
-
sorbet-static-and-runtime (0.6.
|
|
209
|
+
simplecov (1.0.3) sha256=38ef0514f16ae7562f0d0f4df02610071115103d301b6de7dacbcc000082e39b
|
|
210
|
+
sorbet (0.6.13365) sha256=3a642fe7afb031ad670c2e6161b3d5492dd67705cbcafb132c9e87ded7ccf676
|
|
211
|
+
sorbet-runtime (0.6.13365) sha256=0657cddfd2319c9695a0b6e13aa7a7c7b093149e5589448c03b911adf1e243cb
|
|
212
|
+
sorbet-static (0.6.13365-aarch64-linux) sha256=14b7bf3a227ee102c119763158618bc6b677994cebfda2705b94bca56e4c25e1
|
|
213
|
+
sorbet-static (0.6.13365-universal-darwin) sha256=0f6033dde8a0dc7b7e72ada3ce73e1e3097843be16e473fb8bb2b8fb6199212b
|
|
214
|
+
sorbet-static (0.6.13365-x86_64-linux) sha256=f94daadcae55f2e0f7797f8fdc8a250c89ec62e11f2d41905ed6fb1e60ff708e
|
|
215
|
+
sorbet-static-and-runtime (0.6.13365) sha256=9768c114686f7cac0a6f2e222db4213887d294df6059802242ba6e4e08adbad9
|
|
216
216
|
spoom (1.8.3) sha256=32871fa189bbfa49cf557a50f819f23cc9a6ceefd0346caa7a6adc193becd5dd
|
|
217
217
|
tapioca (0.19.2) sha256=938731b07811aee8d23871b1aee8861d464fbaf2cfffbf79a62b0c869a5120ec
|
|
218
218
|
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
|
data/README.md
CHANGED
|
@@ -113,6 +113,8 @@ api.execute!("query($id: ID!) { person(id: $id) { name } }", id: "1")
|
|
|
113
113
|
`@skip`/`@include`, naming, clients, dynamic mode
|
|
114
114
|
- **[Against a real API](docs/real_world.md)** — the exploratory tour:
|
|
115
115
|
introspect a live endpoint (GitHub end to end), dynamic mode, schema caching
|
|
116
|
+
- **[Federation](docs/federation.md)** — Apollo Federation: supergraph vs API
|
|
117
|
+
schema, feeding weaver a composed graph, the `@inaccessible` caveat
|
|
116
118
|
- **[Transports](docs/transports.md)** — clients, the execute contract,
|
|
117
119
|
Faraday, retries and backoff
|
|
118
120
|
- **[Custom scalars](docs/scalars.md)** — the registry: codec inference,
|
data/docs/errors.md
CHANGED
|
@@ -31,6 +31,7 @@ it failed:
|
|
|
31
31
|
| `ServerError` | reached it, non-2xx HTTP — `#status`, `#body` |
|
|
32
32
|
| `QueryError` | 200 body with top-level GraphQL errors — `#errors`, `#data`, `#extensions`, `#codes` |
|
|
33
33
|
| `TypeError` | the response wouldn't cast into the generated structs — `#struct`, `#cause` |
|
|
34
|
+
| `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 — `#field`, `#struct` |
|
|
34
35
|
| `ValidationError` | build time: the query didn't validate against the schema |
|
|
35
36
|
|
|
36
37
|
```ruby
|
|
@@ -64,11 +65,30 @@ Defaults match the rescue block above: transport failures always retry,
|
|
|
64
65
|
override with `retry_if:`), and GraphQL-level codes only when listed in
|
|
65
66
|
`retry_codes:`. Exhausting `tries:` re-raises the last error.
|
|
66
67
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
raises sorbet-runtime's
|
|
70
|
-
T.nilable(Integer), got type
|
|
71
|
-
|
|
68
|
+
**Top-level scalar variables** fail like any Ruby method call, *outside* the
|
|
69
|
+
hierarchy on purpose — passing the wrong Ruby type for a scalar kwarg is a
|
|
70
|
+
programming bug, not caller input: a wrong-typed one raises sorbet-runtime's
|
|
71
|
+
`TypeError` ("Parameter 'page': Expected type T.nilable(Integer), got type
|
|
72
|
+
String"), a missing required one a plain `ArgumentError` ("missing keyword: :id").
|
|
73
|
+
|
|
74
|
+
**Input-object variables** are the caller-input case, so they're *inside* the
|
|
75
|
+
hierarchy. When you pass an input object as a hash (or struct) it's built
|
|
76
|
+
through the generated `coerce`, and any problem there — an unknown/typo'd key, a
|
|
77
|
+
missing required field, an out-of-range enum, a wrong-typed field — raises
|
|
78
|
+
`GraphWeaver::InputError`. That's one rescue point for turning invalid input
|
|
79
|
+
into a 422:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
rescue GraphWeaver::InputError => e
|
|
83
|
+
render json: e.to_h, status: :unprocessable_entity
|
|
84
|
+
# { "error" => "GraphWeaver::InputError",
|
|
85
|
+
# "message" => "unknown key(s) for …Input: staus (did you mean 'status'?)",
|
|
86
|
+
# "field" => "staus", "struct" => "…Input" }
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A nested filter reports the innermost input type, so the error points at the
|
|
91
|
+
input that actually held the bad field.
|
|
72
92
|
|
|
73
93
|
Business/validation failures returned *as data* (Shopify-style `userErrors { field
|
|
74
94
|
message code }`) aren't errors here — they're just fields you selected, so they
|
data/docs/federation.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Federation
|
|
2
|
+
|
|
3
|
+
GraphWeaver generates a client for the **whole** graph, so with Apollo
|
|
4
|
+
Federation you point it at the *composed* schema — the supergraph or the API
|
|
5
|
+
schema — not at individual subgraph files.
|
|
6
|
+
|
|
7
|
+
## The schema you feed it
|
|
8
|
+
|
|
9
|
+
Three artifacts, easy to mix up:
|
|
10
|
+
|
|
11
|
+
| Artifact | What it is | Feed to weaver? |
|
|
12
|
+
|----------|------------|-----------------|
|
|
13
|
+
| **Subgraph SDL** | one service's `.graphql`, with `@key`/`@shareable`/`extend schema @link` | No — it's a fragment of the graph, and its federation directives are declared elsewhere (imported via `@link`), so plain SDL loading rejects them |
|
|
14
|
+
| **Supergraph** | the composed graph, annotated with `@join__*`/`@link` machinery | Yes — self-contained (see below) |
|
|
15
|
+
| **API schema** | the supergraph with federation internals stripped — exactly what the router serves and what an introspection query returns | Yes — the exact client contract |
|
|
16
|
+
|
|
17
|
+
`SchemaLoader.load` (and `Client.new(path_or_sdl)`) take the supergraph or the
|
|
18
|
+
API schema as an SDL file or introspection dump; or point weaver at the router
|
|
19
|
+
URL to introspect the API schema live.
|
|
20
|
+
|
|
21
|
+
## Pointing weaver at a supergraph
|
|
22
|
+
|
|
23
|
+
A supergraph SDL works as-is. It declares its own `@join__*`/`@link` directives
|
|
24
|
+
and `join__*` types, so graphql-ruby builds it; weaver reads field types and
|
|
25
|
+
args, not directives, so the join plumbing is ignored; and because codegen is
|
|
26
|
+
**query-driven**, the federation-internal types generate no code unless a query
|
|
27
|
+
names them (none would). Field shapes — nullability, args, enums, inputs — are
|
|
28
|
+
identical to the API schema, so your generated structs are correct.
|
|
29
|
+
|
|
30
|
+
The one caveat: a supergraph is a **superset** of the API schema, so weaver can
|
|
31
|
+
only ever **over-permit** — it will never reject a valid query, but it won't
|
|
32
|
+
flag one that selects a field the API *hides*. In practice that's exactly one
|
|
33
|
+
thing: `@inaccessible`.
|
|
34
|
+
|
|
35
|
+
### `@inaccessible`
|
|
36
|
+
|
|
37
|
+
A federation-v2 directive marking an element as *present in the federated graph
|
|
38
|
+
but removed from the public API schema*. Its common use is safely rolling out a
|
|
39
|
+
change to a **shared type**: add the field to one subgraph marked
|
|
40
|
+
`@inaccessible` (so composition doesn't require every subgraph to have it yet),
|
|
41
|
+
roll it out to the rest, then drop the directive to publish it. (Apollo
|
|
42
|
+
contracts also pair `@tag` + `@inaccessible` to build filtered API variants.)
|
|
43
|
+
It's a fed-v2 feature — common in mature, multi-team graphs with lots of shared
|
|
44
|
+
types, rare in small or young ones, and targeted where present (a handful of
|
|
45
|
+
elements, not every field).
|
|
46
|
+
|
|
47
|
+
Reading the raw supergraph, weaver would let you select an `@inaccessible`
|
|
48
|
+
field — but the router serves the API schema and rejects it at runtime (a
|
|
49
|
+
"field doesn't exist" error, surfaced as [`QueryError` / `schema_stale?`](errors.md)).
|
|
50
|
+
So the gap is narrow (you'd have to query a hidden field on purpose) and fails
|
|
51
|
+
loudly, not silently.
|
|
52
|
+
|
|
53
|
+
One grep says whether it affects you at all:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
grep -c '@inaccessible' supergraph.graphql
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Zero, and the supergraph *is* the API schema for weaver's purposes — feed it
|
|
60
|
+
directly.
|
|
61
|
+
|
|
62
|
+
Other federation directives hide nothing from the schema:
|
|
63
|
+
`@requiresScopes` / `@policy` / `@authenticated` keep the field and enforce
|
|
64
|
+
access at runtime; `@tag` / `@requires` / `@provides` / `@external` are metadata
|
|
65
|
+
weaver ignores.
|
|
66
|
+
|
|
67
|
+
## The exact contract: the API schema
|
|
68
|
+
|
|
69
|
+
To make codegen match the router precisely (no over-permit), feed weaver the
|
|
70
|
+
**API schema** instead of the raw supergraph. Deriving it is a *subtraction*
|
|
71
|
+
from the composed graph — not composition — done with Apollo's tooling:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
rover supergraph compose --config supergraph.yaml > supergraph.graphql
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
// then, in JS, strip to the API schema:
|
|
79
|
+
import { Supergraph } from '@apollo/federation-internals';
|
|
80
|
+
import { printSchema } from 'graphql';
|
|
81
|
+
|
|
82
|
+
const api = Supergraph.fromString(supergraphSdl).apiSchema().toGraphQLJSSchema();
|
|
83
|
+
process.stdout.write(printSchema(api)); // api.graphql — feed this to weaver
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Check the API SDL in (many teams already emit it in CI) and point weaver at it:
|
|
87
|
+
codegen then validates against exactly what clients can query, with no live
|
|
88
|
+
gateway involved.
|
|
89
|
+
|
|
90
|
+
## Which to use
|
|
91
|
+
|
|
92
|
+
- **No `@inaccessible`** → point weaver at the supergraph and move on.
|
|
93
|
+
- **Uses `@inaccessible`, and you want codegen to catch hidden-field mistakes** →
|
|
94
|
+
feed the derived API schema (above), or introspect the live router.
|
|
95
|
+
|
|
96
|
+
A large real supergraph carries more constructs than a toy one (interface
|
|
97
|
+
objects via `@join__type(isInterfaceObject:)`, `@join__unionMember`, enum join
|
|
98
|
+
directives). The mechanism holds, but the honest check is to run codegen against
|
|
99
|
+
your actual composed schema plus a couple of representative queries before
|
|
100
|
+
relying on it.
|
data/docs/generated_modules.md
CHANGED
|
@@ -210,9 +210,8 @@ emitted **once per schema** — one file per type under
|
|
|
210
210
|
named from the output path: the conventional layout gets
|
|
211
211
|
`GraphQLInputs`, while a multi-schema layout names each schema's module
|
|
212
212
|
after its directory (`app/graphql/github/generated` → `GithubInputs`).
|
|
213
|
-
Override globally with `GraphWeaver.inputs_module=` or per run
|
|
214
|
-
`generate!(inputs_module:)
|
|
215
|
-
`generate!(shared_inputs: false)`. Per-type files keep schema drift
|
|
213
|
+
Override the module name globally with `GraphWeaver.inputs_module=` or per run
|
|
214
|
+
with `generate!(inputs_module:)`. Per-type files keep schema drift
|
|
216
215
|
reviewable: a migration diffs exactly the types it touched, and types
|
|
217
216
|
the schema drops are pruned on regeneration (`verify` flags strays).
|
|
218
217
|
Query modules alias what they touch,
|
|
@@ -254,7 +253,11 @@ mod.execute!(where:)
|
|
|
254
253
|
interface-level fields only → one shared struct; a single `... on X`
|
|
255
254
|
condition and nothing else → `X`'s struct, always nilable — a
|
|
256
255
|
non-matching runtime type comes back as `nil`, so narrowing doubles as
|
|
257
|
-
filtering.
|
|
256
|
+
filtering. When a whole union field is selected as one named *shared*
|
|
257
|
+
fragment (`{ ...FeedItemFields }`), that type is hoisted once into the
|
|
258
|
+
`GraphQLUnions` module and each query aliases it — so the same union is one
|
|
259
|
+
Ruby type family across queries, not a fresh dispatch module per query. Like
|
|
260
|
+
shared inputs, it's a `generate!`-directory concern; dynamic `parse` inlines.
|
|
258
261
|
- **`@skip` / `@include`** — a directive-conditional field may be absent from
|
|
259
262
|
the response regardless of schema nullability, so its generated type is
|
|
260
263
|
always nilable.
|
data/docs/getting_started.md
CHANGED
|
@@ -86,6 +86,51 @@ reviewed like any other code — and never edited by hand.
|
|
|
86
86
|
PersonQuery.execute!(id: "1").person&.name # typed, via GraphWeaver.client
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
+
### Shared fragments
|
|
90
|
+
|
|
91
|
+
Define reusable fragments once and spread them from any query:
|
|
92
|
+
|
|
93
|
+
```graphql
|
|
94
|
+
# app/graphql/fragments/person_fields.graphql
|
|
95
|
+
fragment PersonFields on Person { name birthday }
|
|
96
|
+
|
|
97
|
+
# app/graphql/queries/person.graphql
|
|
98
|
+
query($id: ID!) { person(id: $id) { ...PersonFields } }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Each query inlines only the fragments it (transitively) spreads, so the sent
|
|
102
|
+
`QUERY` stays self-contained — the server never needs your fragment library.
|
|
103
|
+
Fragment files hold only fragments (no operations), and names are unique across
|
|
104
|
+
them. Point elsewhere with `GraphWeaver.fragments_paths` (an appendable list,
|
|
105
|
+
default `app/graphql/fragments`).
|
|
106
|
+
|
|
107
|
+
### Shared unions
|
|
108
|
+
|
|
109
|
+
When a shared fragment *is* the whole selection on a union field, its type is
|
|
110
|
+
hoisted once into a `GraphQLUnions` module and every query that spreads it
|
|
111
|
+
aliases the same type — so a `union` selected across many queries becomes one
|
|
112
|
+
Ruby type family, and you write one exhaustive `case … when … T.absurd` that
|
|
113
|
+
works everywhere:
|
|
114
|
+
|
|
115
|
+
```graphql
|
|
116
|
+
# app/graphql/fragments/feed_item.graphql
|
|
117
|
+
fragment FeedItemFields on FeedItem {
|
|
118
|
+
__typename
|
|
119
|
+
... on Post { title }
|
|
120
|
+
... on Photo { url }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# any query
|
|
124
|
+
query { feed { ...FeedItemFields } } # feed : T::Array[FeedItemFields::Type]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Hoisting is what the shared fragment buys you — there's no flag. It triggers
|
|
128
|
+
only when the union field's selection is exactly that one spread (mix in other
|
|
129
|
+
fields, or shadow the fragment with a query-local one of the same name, and the
|
|
130
|
+
union stays inlined in that query). Named like the inputs module from the output
|
|
131
|
+
path (`GraphQLUnions`, or `GithubUnions` in a multi-schema layout); override
|
|
132
|
+
with `GraphWeaver.unions_module=`.
|
|
133
|
+
|
|
89
134
|
## 6. Test against fakes
|
|
90
135
|
|
|
91
136
|
```ruby
|
|
@@ -127,6 +172,13 @@ generated files — dynamic `parse` is invisible to `srb tc`. Everything
|
|
|
127
172
|
works without Sorbet; codegen plus Sorbet is what moves type errors from
|
|
128
173
|
runtime to CI.
|
|
129
174
|
|
|
175
|
+
If your app globally injects `T::Sig` (`class Module; include T::Sig`), the
|
|
176
|
+
per-struct `extend T::Sig` in generated files is redundant — rubocop's
|
|
177
|
+
`Sorbet/RedundantExtendTSig` flags it. GraphWeaver auto-detects that at
|
|
178
|
+
generation time and skips the `extend`; override with
|
|
179
|
+
`GraphWeaver.extend_t_sig = true`/`false`. (Generated code is machine-generated
|
|
180
|
+
and marked "do not edit," so excluding `generated/**` from rubocop is also fine.)
|
|
181
|
+
|
|
130
182
|
## Not Rails?
|
|
131
183
|
|
|
132
184
|
Everything above works the same, minus the Railtie conveniences: add
|
data/docs/scalars.md
CHANGED
|
@@ -14,6 +14,19 @@ them: `client.register_scalar(...)` overlays the global registry for that
|
|
|
14
14
|
client's generation only — so two servers can disagree about what a
|
|
15
15
|
`DateTime` is, and neither leaks into the other.
|
|
16
16
|
|
|
17
|
+
Pass a `Type.field` **coordinate** instead of a scalar name to override just
|
|
18
|
+
that one field — so the same scalar can deserialize as different Ruby types
|
|
19
|
+
across fields:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
GraphWeaver.register_scalar("ISO8601DateTime", Time) # the default, everywhere
|
|
23
|
+
GraphWeaver.register_scalar("User.birthday", Date) # this field only
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
A field override wins over the scalar-name registration; both stack the same
|
|
27
|
+
global-then-client way. (GraphQL names can't contain `.`, so the coordinate is
|
|
28
|
+
unambiguous — and it's validated against the schema, so a typo'd field raises.)
|
|
29
|
+
|
|
17
30
|
Pass a real class as `type:` and the cast/serialize are **inferred** from it by
|
|
18
31
|
probing the deserialize side and pairing its serializer:
|
|
19
32
|
|
|
@@ -155,7 +168,7 @@ module PetHelpers
|
|
|
155
168
|
def display_name = adult? ? "#{name} 🦴" : "#{name} 🐶"
|
|
156
169
|
end
|
|
157
170
|
|
|
158
|
-
GraphWeaver.
|
|
171
|
+
GraphWeaver.extend_type("Pet", PetHelpers) # or api.extend_type(...)
|
|
159
172
|
|
|
160
173
|
pet.display_name # => "Shelby 🦴"
|
|
161
174
|
pet.name # => "Shelby" — the wire value stays honest
|
|
@@ -173,7 +186,7 @@ For quick decoration, build the mixin inline — the block is
|
|
|
173
186
|
`GraphWeaver::TypeHelpers` so generated files can reference it:
|
|
174
187
|
|
|
175
188
|
```ruby
|
|
176
|
-
api.
|
|
189
|
+
api.extend_type("Pet") do
|
|
177
190
|
def display_name = "#{name} 🐶"
|
|
178
191
|
end
|
|
179
192
|
```
|
data/lib/graph_weaver/client.rb
CHANGED
|
@@ -76,7 +76,8 @@ class GraphWeaver::Client
|
|
|
76
76
|
|
|
77
77
|
# Client-scoped scalar registration: consulted before the global
|
|
78
78
|
# registry when this client generates code, so two clients can map the
|
|
79
|
-
# same scalar name onto different Ruby types.
|
|
79
|
+
# same scalar name onto different Ruby types. A `Type.field` coordinate
|
|
80
|
+
# (e.g. "User.birthday") overrides just that field. Same signature as
|
|
80
81
|
# GraphWeaver.register_scalar.
|
|
81
82
|
def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
|
|
82
83
|
validate_registration!("scalar", graphql_name.to_s)
|
|
@@ -101,8 +102,8 @@ class GraphWeaver::Client
|
|
|
101
102
|
# Client-scoped type helpers: include app-owned modules into every
|
|
102
103
|
# struct this client generates from the named GraphQL type — pass
|
|
103
104
|
# modules, or a block to build one inline. Additive with global
|
|
104
|
-
# registrations (see GraphWeaver.
|
|
105
|
-
def
|
|
105
|
+
# registrations (see GraphWeaver.extend_type).
|
|
106
|
+
def extend_type(graphql_name, *mixins, requires: nil, &block)
|
|
106
107
|
validate_registration!("type", graphql_name.to_s)
|
|
107
108
|
entry = @types[graphql_name.to_s] ||= { mixins: [], requires: [] }
|
|
108
109
|
GraphWeaver::Codegen.add_type_helpers(entry, graphql_name, mixins, requires, block)
|
|
@@ -115,10 +115,10 @@ class GraphWeaver::Codegen
|
|
|
115
115
|
names.uniq
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
-
def emit_shared_aliases(out, names)
|
|
118
|
+
def emit_shared_aliases(out, names, namespace = @inputs_namespace)
|
|
119
119
|
return if names.empty?
|
|
120
120
|
|
|
121
|
-
names.each { |name| out << " #{name} = #{
|
|
121
|
+
names.each { |name| out << " #{name} = #{namespace}::#{name}" }
|
|
122
122
|
out << ""
|
|
123
123
|
end
|
|
124
124
|
|
|
@@ -175,6 +175,35 @@ class GraphWeaver::Codegen
|
|
|
175
175
|
files
|
|
176
176
|
end
|
|
177
177
|
|
|
178
|
+
# The shared unions artifact as a single file: every hoisted union as a
|
|
179
|
+
# <module_name>::<Name> module. Unions don't cross-reference (each is a
|
|
180
|
+
# self-contained fragment), so there's no need for per-type files or the
|
|
181
|
+
# forward declarations recursive input types require.
|
|
182
|
+
def emit_unions_file(unions)
|
|
183
|
+
out = []
|
|
184
|
+
out << "# typed: strict"
|
|
185
|
+
out << "# frozen_string_literal: true"
|
|
186
|
+
out << ""
|
|
187
|
+
out << "# Generated by GraphWeaver — do not edit. Shared union types for this"
|
|
188
|
+
out << "# schema (named fragments on union fields); query modules alias what they use."
|
|
189
|
+
out << ""
|
|
190
|
+
requires = @requires.uniq.sort
|
|
191
|
+
if requires.any?
|
|
192
|
+
requires.each { |req| out << "require #{req.inspect}" }
|
|
193
|
+
out << ""
|
|
194
|
+
end
|
|
195
|
+
out << "module #{@module_name}"
|
|
196
|
+
out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
197
|
+
unions.each do |union|
|
|
198
|
+
emit_union(union, out, 1)
|
|
199
|
+
out << ""
|
|
200
|
+
end
|
|
201
|
+
out.pop if out.last == ""
|
|
202
|
+
out << "end"
|
|
203
|
+
|
|
204
|
+
{ "unions.rb" => out.join("\n") + "\n" }
|
|
205
|
+
end
|
|
206
|
+
|
|
178
207
|
# one type per file, wrapped in the namespace so bare sibling
|
|
179
208
|
# references resolve lexically
|
|
180
209
|
def inputs_file(files, name)
|
|
@@ -200,6 +229,9 @@ class GraphWeaver::Codegen
|
|
|
200
229
|
def emit_module(root, variables)
|
|
201
230
|
flatten = flatten_input(variables)
|
|
202
231
|
aliases = @inputs_namespace ? shared_alias_names(variables, flatten) : []
|
|
232
|
+
# hoisted unions the result tree references, aliased so <Name>::Type and
|
|
233
|
+
# <Name>.from_h resolve to the shared module
|
|
234
|
+
union_aliases = @used_unions.map { |name| camelize(name) }.uniq.sort
|
|
203
235
|
|
|
204
236
|
out = []
|
|
205
237
|
out << "# typed: strict"
|
|
@@ -218,9 +250,12 @@ class GraphWeaver::Codegen
|
|
|
218
250
|
out << "require_relative \"inputs\""
|
|
219
251
|
out << ""
|
|
220
252
|
end
|
|
253
|
+
if union_aliases.any?
|
|
254
|
+
out << "require_relative \"unions\""
|
|
255
|
+
out << ""
|
|
256
|
+
end
|
|
221
257
|
out << "module #{@module_name}"
|
|
222
|
-
out << " extend T::Sig"
|
|
223
|
-
out << ""
|
|
258
|
+
out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
224
259
|
# a GraphQL block string could contain a bare GRAPHQL line, which
|
|
225
260
|
# would terminate the heredoc early — pick a delimiter the query
|
|
226
261
|
# can't collide with
|
|
@@ -235,6 +270,7 @@ class GraphWeaver::Codegen
|
|
|
235
270
|
else
|
|
236
271
|
emit_variable_types(out)
|
|
237
272
|
end
|
|
273
|
+
emit_shared_aliases(out, union_aliases, @unions_namespace)
|
|
238
274
|
emit_nested(root, out, 1)
|
|
239
275
|
out << ""
|
|
240
276
|
emit_execute(out, variables, flatten:)
|
|
@@ -282,14 +318,16 @@ class GraphWeaver::Codegen
|
|
|
282
318
|
pad = " " * indent
|
|
283
319
|
|
|
284
320
|
out << "#{pad}class #{node.class_name} < T::Struct"
|
|
285
|
-
out << "#{pad} extend T::Sig"
|
|
321
|
+
out << "#{pad} extend T::Sig" if GraphWeaver.extend_t_sig?
|
|
286
322
|
out << "#{pad} include GraphWeaver::Hints"
|
|
287
323
|
node.mixins.each do |mixin|
|
|
288
324
|
out << "#{pad} include #{mixin} # registered for #{node.graphql_type}"
|
|
289
325
|
end
|
|
290
326
|
out << ""
|
|
291
327
|
|
|
292
|
-
|
|
328
|
+
# uniq by object identity: deduped sibling unions share one node, so the
|
|
329
|
+
# shared type is emitted once (both fields' consts already reference it).
|
|
330
|
+
node.fields.filter_map { |field| field.node.nested }.uniq.each do |child|
|
|
293
331
|
emit_nested(child, out, indent + 1)
|
|
294
332
|
out << ""
|
|
295
333
|
end
|
|
@@ -318,8 +356,7 @@ class GraphWeaver::Codegen
|
|
|
318
356
|
pad = " " * indent
|
|
319
357
|
|
|
320
358
|
out << "#{pad}module #{node.class_name}"
|
|
321
|
-
out << "#{pad} extend T::Sig"
|
|
322
|
-
out << ""
|
|
359
|
+
out << "#{pad} extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
323
360
|
|
|
324
361
|
node.members.each_value do |member|
|
|
325
362
|
emit_object(member, out, indent + 1)
|
|
@@ -346,8 +383,7 @@ class GraphWeaver::Codegen
|
|
|
346
383
|
out << " @client = T.let(nil, T.untyped)"
|
|
347
384
|
out << ""
|
|
348
385
|
out << " class << self"
|
|
349
|
-
out << " extend T::Sig"
|
|
350
|
-
out << ""
|
|
386
|
+
out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
351
387
|
out << " sig { params(client: T.untyped).void }"
|
|
352
388
|
out << " attr_writer :client"
|
|
353
389
|
out << ""
|