graph_weaver 0.2.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdb688c1c86ee0e39da82446622479aeb99b887866326e892133efbabe8c5a3d
4
- data.tar.gz: 9c5514556bbfb08b337d09be7df71d081eafe7b6ee678f658f5bf6086367fada
3
+ metadata.gz: e66ac94243bdc7f065534845a722bfd209c8d7b23a3a5fa1eff6041e3f563655
4
+ data.tar.gz: 413217dbda71c297abe537dc996b8e8413821365d9e966493bf7301d6351a6c7
5
5
  SHA512:
6
- metadata.gz: 752fb13032272b5a9da29f549474444865a34552c336a32bb10316ec2c3bd38dd2996cc57604b30ca505ea35045f9abbd6d0bdefd0507d19714f3ab75f7dbc4d
7
- data.tar.gz: ebce80c4ad9c3c471e086616f48afb83ea6e6741e7f1c44986bb5b7ba9f57112d144443e15a5b2b78e26297c20d2e2f8a9a4777559242a77f21ca2d4d25d1031
6
+ metadata.gz: 62dab00fca85973dbff0e695dcd9970a554ca98b6a717a375fa7f2cde349b2967672ce541bf108d3b058b54b47ce7d274f6c8f11f069360a59e95fcb86ff8ae5
7
+ data.tar.gz: 3a11e52459c3b0158d831c8815ced9c4ca5cd323435ffac4e36a8648e83179dd87763a4570aaa56b240d0d7d77d3b91e43d1706f40112d3eae2fa608950b797c
data/CHANGELOG.md CHANGED
@@ -1,3 +1,25 @@
1
+ ### v0.3.0 (2026-07-28)
2
+ - Renamed `register_type` to `extend_type` to disambiguate intent: it
3
+ *decorates* a generated struct with mixin modules/helpers — it doesn't
4
+ define or replace a type's deserialization (that's `register_scalar` /
5
+ `register_enum`, for leaf types, whose Ruby shape is fixed; a composite's
6
+ shape is per-query, so there's nothing fixed to replace). No deprecation —
7
+ the old name is dropped.
8
+ - Invalid query input now raises `GraphWeaver::InputError` (under the
9
+ `GraphWeaver::Error` umbrella) instead of a raw `ArgumentError` /
10
+ `KeyError` / sorbet `TypeError`: an unknown or typo'd input key, a
11
+ missing required field, an out-of-range enum, or a wrong-typed field
12
+ when an input object is built from a hash through `coerce`. Carries
13
+ `#field` / `#struct` and a JSON-ready `#to_h` — one rescue point for
14
+ returning a 422 at an API boundary. Top-level *scalar* kwargs still
15
+ fail like any Ruby method call (sorbet `TypeError` / `ArgumentError`).
16
+
17
+ ### v0.2.2 (2026-07-22)
18
+ - Generated modules expose from_response / from_response! alongside
19
+ execute / execute!: deserialize a raw GraphQL response (fetched by any
20
+ client) into the typed envelope without going through the transport.
21
+ execute now delegates to from_response
22
+
1
23
  ### v0.2.1 (2026-07-13)
2
24
  - Conventional paths are appendable lists: queries_paths /
3
25
  generated_paths (singular accessors read the first entry, so existing
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.2.1)
4
+ graph_weaver (0.3.0)
5
5
  graphql (>= 2)
6
6
  sorbet-runtime
7
7
 
@@ -19,7 +19,6 @@ GEM
19
19
  irb (~> 1.10)
20
20
  reline (>= 0.3.8)
21
21
  diff-lcs (1.6.2)
22
- docile (1.4.1)
23
22
  erb (6.0.4)
24
23
  erubi (1.13.1)
25
24
  faker (3.8.0)
@@ -43,7 +42,7 @@ GEM
43
42
  google-protobuf (4.35.1-x86_64-linux-gnu)
44
43
  bigdecimal
45
44
  rake (~> 13.3)
46
- graphql (2.6.5)
45
+ graphql (2.6.6)
47
46
  base64
48
47
  fiber-storage
49
48
  logger
@@ -100,21 +99,16 @@ GEM
100
99
  rubydex (0.2.7-arm64-darwin)
101
100
  rubydex (0.2.7-x86_64-darwin)
102
101
  rubydex (0.2.7-x86_64-linux)
103
- simplecov (0.22.0)
104
- docile (~> 1.1)
105
- simplecov-html (~> 0.11)
106
- simplecov_json_formatter (~> 0.1)
107
- simplecov-html (0.13.2)
108
- simplecov_json_formatter (0.1.4)
109
- sorbet (0.6.13327)
110
- sorbet-static (= 0.6.13327)
111
- sorbet-runtime (0.6.13327)
112
- sorbet-static (0.6.13327-aarch64-linux)
113
- sorbet-static (0.6.13327-universal-darwin)
114
- sorbet-static (0.6.13327-x86_64-linux)
115
- sorbet-static-and-runtime (0.6.13327)
116
- sorbet (= 0.6.13327)
117
- sorbet-runtime (= 0.6.13327)
102
+ simplecov (1.0.2)
103
+ sorbet (0.6.13347)
104
+ sorbet-static (= 0.6.13347)
105
+ sorbet-runtime (0.6.13347)
106
+ sorbet-static (0.6.13347-aarch64-linux)
107
+ sorbet-static (0.6.13347-universal-darwin)
108
+ sorbet-static (0.6.13347-x86_64-linux)
109
+ sorbet-static-and-runtime (0.6.13347)
110
+ sorbet (= 0.6.13347)
111
+ sorbet-runtime (= 0.6.13347)
118
112
  spoom (1.8.3)
119
113
  erubi (>= 1.10.0)
120
114
  prism (>= 0.28.0)
@@ -172,7 +166,6 @@ CHECKSUMS
172
166
  concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
173
167
  debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6
174
168
  diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
175
- docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
176
169
  erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9
177
170
  erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
178
171
  faker (3.8.0) sha256=c147b308df73a90f27a4fc84f18d4c22ef0ad9c2a64b2b61c86fd0ca71753efc
@@ -183,8 +176,8 @@ CHECKSUMS
183
176
  google-protobuf (4.35.1-arm64-darwin) sha256=d9c957df04fa89c749fa9a72a7b383eb4296efc9b2303dc6fd6fbe39c698ad6b
184
177
  google-protobuf (4.35.1-x86_64-darwin) sha256=66b62b4df00931018a692806df66393efa960d6d2b7da69735187249f950d3ee
185
178
  google-protobuf (4.35.1-x86_64-linux-gnu) sha256=c786439087512a3fbd199e9897d265b855f951d4027e218ea55e858d45969edd
186
- graph_weaver (0.2.1)
187
- graphql (2.6.5) sha256=1051825bfb4aedb4293cdbcd9726c3150e69e3512556609a32c2ca19d4be3d00
179
+ graph_weaver (0.3.0)
180
+ graphql (2.6.6) sha256=7438e2036b571c884377eb1529225470ba959365f26dc47152953c213bc38c31
188
181
  i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
189
182
  io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
190
183
  irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
@@ -213,15 +206,13 @@ CHECKSUMS
213
206
  rubydex (0.2.7-arm64-darwin) sha256=f0d28bbf4153568be79b671642424750053e0bea971b60ddf5cec19bf4563990
214
207
  rubydex (0.2.7-x86_64-darwin) sha256=b002b259d118ac69de44470eff1597143318402c45630c47371f9542631447dc
215
208
  rubydex (0.2.7-x86_64-linux) sha256=dacfade9fa42ce4469618da6dac07e69d5f3ac6a313b4caced5234c8f052419a
216
- simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5
217
- simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246
218
- simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
219
- sorbet (0.6.13327) sha256=4b56cb04542661c6bf5075f1ca38ca00506045325fd689f460aee8466dd3d7a3
220
- sorbet-runtime (0.6.13327) sha256=86f32b2df6d266bf5c0e0eb8ea81d0376ba36eece4c8610938174e403222948d
221
- sorbet-static (0.6.13327-aarch64-linux) sha256=da8a575785d34641095631c50fe1d1f645ee8782c8e2aa5a0ecfd12d1fa22a8a
222
- sorbet-static (0.6.13327-universal-darwin) sha256=a2a8c6b624aada3f6ef68852398de756248f71b3ea691119873515847010dcda
223
- sorbet-static (0.6.13327-x86_64-linux) sha256=f3a915bab4af1979145c793a08d3b1db207526d5e2f332513da10a95e70f9028
224
- sorbet-static-and-runtime (0.6.13327) sha256=7b039be023b6a7d2d17b1f751191f32307d9ace4170b6a192bb0e005d222e00e
209
+ simplecov (1.0.2) sha256=c6459434efe4b948b46477cc2df2faa73ab365f83a33c7c17f81262f4f7f1244
210
+ sorbet (0.6.13347) sha256=2830946e6efda8dc732c5d703350bed17f437fbea1ccfbef543a65e33ae445c1
211
+ sorbet-runtime (0.6.13347) sha256=54e7221d9f4b63f58aec79f3043752a9ba7810347c6ae0d903531591cc2d5f01
212
+ sorbet-static (0.6.13347-aarch64-linux) sha256=9bb60509bf9a8715d93e77cd55f96659575d81a5f47b52bd4f66b3dfe6f2182e
213
+ sorbet-static (0.6.13347-universal-darwin) sha256=d12678a45369dba9db724103d8badda995e66a83ae35798f68d804c46a6d3e45
214
+ sorbet-static (0.6.13347-x86_64-linux) sha256=5a4803d2ba4fc1964e23f3aa4073b224c345c2a72a5f5317623a4bae7ce2c8da
215
+ sorbet-static-and-runtime (0.6.13347) sha256=2130e3c20326948a3edd20385b444345cb7327eea9b141f373486e7c087a498d
225
216
  spoom (1.8.3) sha256=32871fa189bbfa49cf557a50f819f23cc9a6ceefd0346caa7a6adc193becd5dd
226
217
  tapioca (0.19.2) sha256=938731b07811aee8d23871b1aee8861d464fbaf2cfffbf79a62b0c869a5120ec
227
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
- Two deliberate exceptions live *outside* the hierarchy, because typed
68
- kwargs should fail like any Ruby method call: a wrong-typed variable
69
- raises sorbet-runtime's `TypeError` ("Parameter 'page': Expected type
70
- T.nilable(Integer), got type String"), and a missing required variable
71
- raises a plain `ArgumentError` ("missing keyword: :id").
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
@@ -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.
@@ -119,6 +119,9 @@ module PersonQuery
119
119
  def self.client ... # default client (see below)
120
120
  def self.execute(client = nil, id:) # -> GraphWeaver::Response[Result]
121
121
  def self.execute!(client = nil, id:) # -> Result, or raises QueryError
122
+
123
+ def self.from_response(response) # deserialize a raw hash -> Response[Result]
124
+ def self.from_response!(response) # -> Result, or raises QueryError
122
125
  end
123
126
  ```
124
127
 
@@ -127,6 +130,38 @@ end
127
130
  cost/throttle metadata survive.
128
131
  - `execute!` is the shortcut: the typed result or a raised
129
132
  `GraphWeaver::QueryError`.
133
+ - `from_response` / `from_response!` are the **network-free half** of the
134
+ pair — same envelope, but from a response hash you already have (see below).
135
+
136
+ ## Deserializing a response from another client
137
+
138
+ `execute` is two steps: make the request, then cast the JSON into the typed
139
+ structs. Only the second step is GraphWeaver-specific, and it's exposed on its
140
+ own — so you can fetch with any GraphQL client (Apollo, a raw `Net::HTTP` post,
141
+ a batching layer, a recorded fixture) and hand the result to GraphWeaver:
142
+
143
+ ```ruby
144
+ raw = my_graphql_client.post(PersonQuery::QUERY, id: "1")
145
+ # => {"data" => {"person" => {...}}, "errors" => [...], "extensions" => {...}}
146
+
147
+ response = PersonQuery.from_response(raw) # GraphWeaver::Response[Result]
148
+ person = response.data!.person # typed, no network
149
+
150
+ # or skip the envelope:
151
+ person = PersonQuery.from_response!(raw).person
152
+ ```
153
+
154
+ `from_response` builds the exact same `GraphWeaver::Response[Result]` envelope
155
+ `execute` does — in fact `execute` *is* `from_response(transport.execute(...))`.
156
+ Errors and extensions are preserved; `#data!` / `from_response!` raise
157
+ `QueryError` on top-level errors.
158
+
159
+ The one requirement: pass the response **verbatim** — a hash (or anything with
160
+ `#to_h`) with the standard GraphQL shape and **wire-cased string keys**
161
+ (`"person"`, `"nameWithOwner"`), the top-level `"data"` / `"errors"` /
162
+ `"extensions"` keys included. Don't symbolize or snake_case it first;
163
+ `from_response` reads `raw["data"]` and the casting reads camelCase field keys.
164
+ The module must, of course, be generated for the query you ran.
130
165
 
131
166
  ## Variables become typed kwargs
132
167
 
data/docs/scalars.md CHANGED
@@ -155,7 +155,7 @@ module PetHelpers
155
155
  def display_name = adult? ? "#{name} 🦴" : "#{name} 🐶"
156
156
  end
157
157
 
158
- GraphWeaver.register_type("Pet", PetHelpers) # or api.register_type(...)
158
+ GraphWeaver.extend_type("Pet", PetHelpers) # or api.extend_type(...)
159
159
 
160
160
  pet.display_name # => "Shelby 🦴"
161
161
  pet.name # => "Shelby" — the wire value stays honest
@@ -173,7 +173,7 @@ For quick decoration, build the mixin inline — the block is
173
173
  `GraphWeaver::TypeHelpers` so generated files can reference it:
174
174
 
175
175
  ```ruby
176
- api.register_type("Pet") do
176
+ api.extend_type("Pet") do
177
177
  def display_name = "#{name} 🐶"
178
178
  end
179
179
  ```
@@ -101,8 +101,8 @@ class GraphWeaver::Client
101
101
  # Client-scoped type helpers: include app-owned modules into every
102
102
  # struct this client generates from the named GraphQL type — pass
103
103
  # modules, or a block to build one inline. Additive with global
104
- # registrations (see GraphWeaver.register_type).
105
- def register_type(graphql_name, *mixins, requires: nil, &block)
104
+ # registrations (see GraphWeaver.extend_type).
105
+ def extend_type(graphql_name, *mixins, requires: nil, &block)
106
106
  validate_registration!("type", graphql_name.to_s)
107
107
  entry = @types[graphql_name.to_s] ||= { mixins: [], requires: [] }
108
108
  GraphWeaver::Codegen.add_type_helpers(entry, graphql_name, mixins, requires, block)
@@ -409,7 +409,30 @@ class GraphWeaver::Codegen
409
409
 
410
410
  out << ""
411
411
  out << " transport = GraphWeaver.resolve_transport(client || self.client)"
412
- out << " raw = transport.execute(QUERY, variables: variables).to_h"
412
+ out << " from_response(transport.execute(QUERY, variables: variables))"
413
+ out << " end"
414
+ out << ""
415
+ out << " sig { params(#{sig_params.join(", ")}).returns(Result) }"
416
+ out << " def self.execute!(#{kwargs.join(", ")})"
417
+ out << " execute(#{forward}).data!"
418
+ out << " end"
419
+ out << ""
420
+ emit_from_response(out)
421
+ end
422
+
423
+ # The network-free half of execute: deserialize a raw GraphQL response
424
+ # into the typed envelope. For responses fetched by any other client —
425
+ # execute delegates here after the transport call. Accepts the response
426
+ # hash (or anything with #to_h, e.g. a schema result): {"data" => ...,
427
+ # "errors" => ..., "extensions" => ...} with wire-cased string keys.
428
+ def emit_from_response(out)
429
+ out << " # Deserialize a raw GraphQL response into the typed envelope — the"
430
+ out << " # network-free half of execute, for responses fetched by any client."
431
+ out << " # Takes the response hash (or anything with #to_h): {\"data\" => ...,"
432
+ out << " # \"errors\" => ..., \"extensions\" => ...} with wire-cased string keys."
433
+ out << " sig { params(response: T.untyped).returns(GraphWeaver::Response[Result]) }"
434
+ out << " def self.from_response(response)"
435
+ out << " raw = response.to_h"
413
436
  out << " GraphWeaver::Response[Result].new("
414
437
  out << " data: (Result.from_h(raw[\"data\"]) if raw[\"data\"]),"
415
438
  out << " errors: (raw[\"errors\"] || []).map { |e| GraphWeaver::GraphQLError.from_h(e) },"
@@ -417,9 +440,10 @@ class GraphWeaver::Codegen
417
440
  out << " )"
418
441
  out << " end"
419
442
  out << ""
420
- out << " sig { params(#{sig_params.join(", ")}).returns(Result) }"
421
- out << " def self.execute!(#{kwargs.join(", ")})"
422
- out << " execute(#{forward}).data!"
443
+ out << " # from_response + data! — the typed result, or a raised QueryError."
444
+ out << " sig { params(response: T.untyped).returns(Result) }"
445
+ out << " def self.from_response!(response)"
446
+ out << " from_response(response).data!"
423
447
  out << " end"
424
448
  end
425
449
 
@@ -90,19 +90,19 @@ class GraphWeaver::Codegen
90
90
  # Attach app-owned helper modules to every struct generated from a
91
91
  # GraphQL type — the logic stays in your code, generation wires it in:
92
92
  #
93
- # GraphWeaver.register_type("Pet", PetHelpers)
93
+ # GraphWeaver.extend_type("Pet", PetHelpers)
94
94
  #
95
95
  # Or build the mixin inline — the block is module_eval'd into a fresh
96
96
  # module auto-named GraphWeaver::TypeHelpers::<Type>. Handy for quick
97
97
  # decoration; srb tc can't see into block-defined methods, so prefer
98
98
  # a named module where static checking matters:
99
99
  #
100
- # GraphWeaver.register_type("Pet") do
100
+ # GraphWeaver.extend_type("Pet") do
101
101
  # def display_name = "#{name} the pet"
102
102
  # end
103
103
  #
104
104
  # Additive: repeated registrations (and client-scoped ones) stack.
105
- def register_type(graphql_name, *mixins, requires: nil, &block)
105
+ def extend_type(graphql_name, *mixins, requires: nil, &block)
106
106
  entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [] }
107
107
  add_type_helpers(entry, graphql_name, mixins, requires, block)
108
108
  end
@@ -111,7 +111,7 @@ class GraphWeaver::Codegen
111
111
  @type_registry ||= {}
112
112
  end
113
113
 
114
- # shared with Client#register_type: build/validate the mixins and
114
+ # shared with Client#extend_type: build/validate the mixins and
115
115
  # append them to a registry entry
116
116
  def add_type_helpers(entry, graphql_name, mixins, requires, block)
117
117
  mixins = mixins.dup
@@ -143,7 +143,7 @@ class GraphWeaver::Codegen
143
143
  end
144
144
 
145
145
  module GraphWeaver
146
- # Home of block-built type helpers (register_type with a block), which
146
+ # Home of block-built type helpers (extend_type with a block), which
147
147
  # need constant names so generated files can reference them.
148
148
  module TypeHelpers; end
149
149
  end
@@ -147,7 +147,7 @@ class GraphWeaver::Codegen
147
147
 
148
148
  attr_reader :class_name, :fields
149
149
  # the GraphQL type this struct was generated from, and any registered
150
- # helper modules to include (see Codegen.register_type)
150
+ # helper modules to include (see Codegen.extend_type)
151
151
  attr_accessor :graphql_type, :mixins
152
152
 
153
153
  def initialize(class_name)
@@ -219,7 +219,9 @@ class GraphWeaver::Codegen
219
219
  suggestion = defined?(DidYouMean::SpellChecker) &&
220
220
  DidYouMean::SpellChecker.new(dictionary: schema.types.keys).correct(name).first
221
221
  hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
222
- raise GraphWeaver::Error, "register_#{kind}(#{name.inspect}) matches no type in this schema#{hint}"
222
+ # the type registry is reached via extend_type; scalars/enums via register_*
223
+ method = kind == "type" ? "extend_type" : "register_#{kind}"
224
+ raise GraphWeaver::Error, "#{method}(#{name.inspect}) matches no type in this schema#{hint}"
223
225
  end
224
226
 
225
227
  # Structured shape for a schema-validation error: message plus its first
@@ -1,4 +1,4 @@
1
- # typed: true
1
+ # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "sorbet-runtime"
@@ -12,16 +12,20 @@ module GraphWeaver
12
12
  # structured failures to users. One subclass per failure site —
13
13
  # {TransportError} (never reached the server), {ServerError} (non-2xx),
14
14
  # {QueryError} (GraphQL-level errors), {TypeError} (response wouldn't
15
- # cast), {ValidationError} (build time) — each merging its specifics
16
- # into #to_h.
15
+ # cast), {InputError} (bad variables), {ValidationError} (build time) —
16
+ # each merging its specifics into #to_h.
17
17
  class Error < StandardError
18
+ extend T::Sig
19
+
18
20
  # every GraphWeaver error surfaces on the logger too (see
19
21
  # GraphWeaver.logger) — construction here means a raise
22
+ sig { params(args: T.untyped).void }
20
23
  def initialize(*args)
21
24
  super
22
25
  GraphWeaver.log(:warn) { "#{self.class.name}: #{message}" }
23
26
  end
24
27
 
28
+ sig { overridable.returns(T::Hash[String, T.untyped]) }
25
29
  def to_h
26
30
  { "error" => self.class.name, "message" => message }
27
31
  end
@@ -31,12 +35,17 @@ module GraphWeaver
31
35
  # TLS handshake, timeout. The original exception is preserved as #cause.
32
36
  # Generally retriable.
33
37
  class TransportError < Error
38
+ extend T::Sig
39
+
40
+ sig { override.returns(T::Hash[String, T.untyped]) }
34
41
  def to_h
35
42
  super.merge("cause" => cause&.class&.name)
36
43
  end
37
44
  end
38
45
 
39
46
  class << self
47
+ extend T::Sig
48
+
40
49
  # The exception classes the bundled transports reclassify as
41
50
  # TransportError — network-level failures where the request never
42
51
  # reached the server. A mutable Set: each transport contributes its own
@@ -48,11 +57,16 @@ module GraphWeaver
48
57
  #
49
58
  # SystemCallError covers every Errno::* (connection refused/reset, host
50
59
  # unreachable); SocketError covers DNS.
60
+ sig { returns(T::Set[T.class_of(Exception)]) }
51
61
  def transport_errors
52
- @transport_errors ||= Set[SocketError, SystemCallError, IOError]
62
+ @transport_errors ||= T.let(
63
+ Set[SocketError, SystemCallError, IOError],
64
+ T.nilable(T::Set[T.class_of(Exception)]),
65
+ )
53
66
  end
54
67
 
55
68
  # Add one or more exception classes to the transport-error set.
69
+ sig { params(classes: T.class_of(Exception)).returns(T::Array[T.class_of(Exception)]) }
56
70
  def register_transport_error(*classes)
57
71
  transport_errors.merge(classes)
58
72
  classes
@@ -63,8 +77,15 @@ module GraphWeaver
63
77
  # that exploded, a 502 from a proxy, a 401, etc. Distinct from a GraphQL
64
78
  # error: we got an HTTP response, it just wasn't success.
65
79
  class ServerError < Error
66
- attr_reader :status, :body
80
+ extend T::Sig
67
81
 
82
+ sig { returns(Integer) }
83
+ attr_reader :status
84
+
85
+ sig { returns(T.untyped) }
86
+ attr_reader :body
87
+
88
+ sig { params(status: Integer, body: T.untyped).void }
68
89
  def initialize(status:, body: nil)
69
90
  @status = status
70
91
  @body = body
@@ -72,6 +93,7 @@ module GraphWeaver
72
93
  super("HTTP #{status}#{snippet}")
73
94
  end
74
95
 
96
+ sig { override.returns(T::Hash[String, T.untyped]) }
75
97
  def to_h
76
98
  super.merge("status" => status)
77
99
  end
@@ -81,16 +103,38 @@ module GraphWeaver
81
103
  # object (not raised) — the response envelope and QueryError carry these.
82
104
  # Match on #code (extensions["code"]) rather than the message string.
83
105
  class GraphQLError
84
- attr_reader :message, :locations, :path, :extensions
106
+ extend T::Sig
107
+
108
+ sig { returns(String) }
109
+ attr_reader :message
110
+
111
+ sig { returns(T::Array[T.untyped]) }
112
+ attr_reader :locations
85
113
 
114
+ sig { returns(T.nilable(T::Array[T.untyped])) }
115
+ attr_reader :path
116
+
117
+ sig { returns(T::Hash[String, T.untyped]) }
118
+ attr_reader :extensions
119
+
120
+ sig do
121
+ params(
122
+ message: String,
123
+ locations: T::Array[T.untyped],
124
+ path: T.nilable(T::Array[T.untyped]),
125
+ extensions: T::Hash[String, T.untyped],
126
+ type: T.nilable(String),
127
+ ).void
128
+ end
86
129
  def initialize(message:, locations: [], path: nil, extensions: {}, type: nil)
87
130
  @message = message
88
131
  @locations = locations
89
132
  @path = path
90
133
  @extensions = extensions
91
- @error_type = type
134
+ @error_type = T.let(type, T.nilable(String))
92
135
  end
93
136
 
137
+ sig { params(hash: T::Hash[String, T.untyped]).returns(GraphQLError) }
94
138
  def self.from_h(hash)
95
139
  new(
96
140
  message: hash["message"] || "(no message)",
@@ -105,6 +149,7 @@ module GraphWeaver
105
149
  # branch on. Read from extensions.code (the Apollo/spec-adjacent
106
150
  # convention) or a top-level "type" (GitHub's dialect: NOT_FOUND,
107
151
  # FORBIDDEN). nil when the server sent neither.
152
+ sig { returns(T.nilable(String)) }
108
153
  def code
109
154
  extensions["code"] || @error_type
110
155
  end
@@ -113,11 +158,15 @@ module GraphWeaver
113
158
  # (unknown field/type/argument). Heuristic by necessity: only Apollo
114
159
  # sets a standard code (GRAPHQL_VALIDATION_FAILED); graphql-ruby and
115
160
  # GitHub speak in messages.
116
- VALIDATION_MESSAGE = /doesn't exist|Cannot query field|Unknown (field|type|argument)|isn't defined|undefined (field|type)/i
161
+ VALIDATION_MESSAGE = T.let(
162
+ /doesn't exist|Cannot query field|Unknown (field|type|argument)|isn't defined|undefined (field|type)/i,
163
+ Regexp,
164
+ )
117
165
 
118
166
  # True when this error looks like the server rejected the query's
119
167
  # shape — for a generated module that usually means the schema
120
168
  # changed after generation.
169
+ sig { returns(T::Boolean) }
121
170
  def validation?
122
171
  code == "GRAPHQL_VALIDATION_FAILED" || VALIDATION_MESSAGE.match?(message)
123
172
  end
@@ -126,17 +175,21 @@ module GraphWeaver
126
175
  # indices stripped — ["people", 3, "email"] => "people.email". The
127
176
  # parseable key for grouping/reporting (the raw #path keeps indices).
128
177
  # nil for global errors with no path.
178
+ sig { returns(T.nilable(String)) }
129
179
  def field
130
- return unless path
180
+ p = path
181
+ return unless p
131
182
 
132
- named = path.reject { |seg| seg.is_a?(Integer) || seg.to_s.match?(/\A\d+\z/) }
183
+ named = p.reject { |seg| seg.is_a?(Integer) || seg.to_s.match?(/\A\d+\z/) }
133
184
  named.join(".") unless named.empty?
134
185
  end
135
186
 
187
+ sig { returns(String) }
136
188
  def to_s
137
- loc = locations&.first
189
+ loc = locations.first
138
190
  at = loc ? " at #{loc["line"]}:#{loc["column"]}" : ""
139
- where = path ? " (path: #{path.join(".")})" : ""
191
+ p = path
192
+ where = p ? " (path: #{p.join(".")})" : ""
140
193
  tag = code ? " [#{code}]" : ""
141
194
  "#{message}#{at}#{where}#{tag}"
142
195
  end
@@ -144,6 +197,7 @@ module GraphWeaver
144
197
  # JSON-ready: the problematic field (both forms — #field for grouping,
145
198
  # #path with indices for exact location), the machine code, and the
146
199
  # server's full extensions.
200
+ sig { returns(T::Hash[String, T.untyped]) }
147
201
  def to_h
148
202
  {
149
203
  "message" => message,
@@ -158,15 +212,18 @@ module GraphWeaver
158
212
  end
159
213
 
160
214
  # Shared filtering over a collection of GraphQLErrors, for surfacing
161
- # field-level failures programmatically. Host must define #errors.
215
+ # field-level failures programmatically. Host must define #errors / #data.
162
216
  module ErrorFiltering
217
+ extend T::Sig
163
218
  include Kernel # for sorbet: hosts are Objects
164
219
 
165
220
  # the host's interface, overridden by its attr_readers
221
+ sig { overridable.returns(T::Array[GraphQLError]) }
166
222
  def errors
167
223
  raise NotImplementedError, "#{self.class} must define #errors"
168
224
  end
169
225
 
226
+ sig { overridable.returns(T.untyped) }
170
227
  def data
171
228
  raise NotImplementedError, "#{self.class} must define #data"
172
229
  end
@@ -174,15 +231,20 @@ module GraphWeaver
174
231
  # Errors touching a field path — "user.email" or ["user", "email"];
175
232
  # prefix match, so deeper errors count too. List indices appear as
176
233
  # path segments ("people.0.email").
234
+ sig { params(path: T.any(String, T::Array[T.untyped])).returns(T::Array[GraphQLError]) }
177
235
  def errors_at(path)
178
236
  want = (path.is_a?(String) ? path.split(".") : path).map(&:to_s)
179
- errors.select { |error| error.path && error.path.map(&:to_s).first(want.size) == want }
237
+ errors.select do |error|
238
+ p = error.path
239
+ p && p.map(&:to_s).first(want.size) == want
240
+ end
180
241
  end
181
242
 
182
243
  # True when any error looks like the server rejected the query's
183
244
  # shape — the schema has likely changed since the module was
184
245
  # generated. Refresh the schema dump and regenerate (rake
185
246
  # graph_weaver:schema:refresh && rake graph_weaver:generate).
247
+ sig { returns(T::Boolean) }
186
248
  def schema_stale?
187
249
  errors.any?(&:validation?)
188
250
  end
@@ -193,25 +255,31 @@ module GraphWeaver
193
255
  # response.each_error do |field, errors|
194
256
  # form.add_error(field, errors.map(&:message))
195
257
  # end
258
+ sig { returns(T::Hash[T.nilable(String), T::Array[GraphQLError]]) }
196
259
  def errors_by_field
197
260
  errors.group_by(&:field)
198
261
  end
199
262
 
263
+ sig do
264
+ params(block: T.proc.params(field: T.nilable(String), errors: T::Array[GraphQLError]).void).void
265
+ end
200
266
  def each_error(&block)
201
- errors_by_field.each(&block)
267
+ errors_by_field.each { |field, errs| block.call(field, errs) }
202
268
  end
203
269
 
204
270
  # The id of the record an error points into, resolved by walking the
205
271
  # error's path through the (partial) typed data: an error at
206
272
  # ["people", 3, "email"] resolves to people[3].id. nil when the data
207
273
  # is missing, the path doesn't walk, or the record has no id field.
274
+ sig { params(error: GraphQLError).returns(T.untyped) }
208
275
  def entity_id(error)
209
276
  # untyped by nature: the walk traverses whatever structs this query
210
277
  # generated, reassigning across types at each step
211
278
  node = T.let(data, T.untyped)
212
- return unless node && error.path
279
+ path = error.path
280
+ return unless node && path
213
281
 
214
- error.path[0..-2].each do |segment|
282
+ (path[0..-2] || []).each do |segment|
215
283
  node = if segment.is_a?(Integer) || segment.to_s.match?(/\A\d+\z/)
216
284
  node.is_a?(Array) ? node[segment.to_i] : nil
217
285
  else
@@ -230,6 +298,7 @@ module GraphWeaver
230
298
  #
231
299
  # { "people.email" => { "messages" => [...], "codes" => [...],
232
300
  # "entity_ids" => ["7", "9"], "errors" => [full to_h...] } }
301
+ sig { returns(T::Hash[T.nilable(String), T::Hash[String, T.untyped]]) }
233
302
  def report
234
303
  errors_by_field.to_h do |field, field_errors|
235
304
  [field, {
@@ -247,10 +316,25 @@ module GraphWeaver
247
316
  # Carries the structured errors, any partial data, and top-level
248
317
  # extensions (cost/throttle metadata).
249
318
  class QueryError < Error
319
+ extend T::Sig
250
320
  include ErrorFiltering
251
321
 
252
- attr_reader :errors, :data, :extensions
322
+ sig { override.returns(T::Array[GraphQLError]) }
323
+ attr_reader :errors
324
+
325
+ sig { override.returns(T.untyped) }
326
+ attr_reader :data
253
327
 
328
+ sig { returns(T::Hash[String, T.untyped]) }
329
+ attr_reader :extensions
330
+
331
+ sig do
332
+ params(
333
+ errors: T::Array[GraphQLError],
334
+ data: T.untyped,
335
+ extensions: T::Hash[String, T.untyped],
336
+ ).void
337
+ end
254
338
  def initialize(errors, data: nil, extensions: {})
255
339
  @errors = errors
256
340
  @data = data
@@ -259,12 +343,14 @@ module GraphWeaver
259
343
  end
260
344
 
261
345
  # All non-nil error codes — handy for `codes.include?("THROTTLED")`.
346
+ sig { returns(T::Array[String]) }
262
347
  def codes
263
- errors.map(&:code).compact
348
+ errors.filter_map(&:code)
264
349
  end
265
350
 
266
351
  # The machine side: every error with its path/code/extensions, plus
267
352
  # the drift verdict — nest this straight into a JSON response.
353
+ sig { override.returns(T::Hash[String, T.untyped]) }
268
354
  def to_h
269
355
  super.merge(
270
356
  "schema_stale" => schema_stale?,
@@ -276,6 +362,7 @@ module GraphWeaver
276
362
 
277
363
  private
278
364
 
365
+ sig { returns(String) }
279
366
  def summary
280
367
  first = errors.first
281
368
  more = errors.size > 1 ? " (and #{errors.size - 1} more)" : ""
@@ -291,30 +378,69 @@ module GraphWeaver
291
378
  # #cause carries the original TypeError/KeyError with the offending
292
379
  # prop in its message.
293
380
  class TypeError < Error
381
+ extend T::Sig
382
+
383
+ sig { returns(T.untyped) }
294
384
  attr_reader :struct
295
385
 
386
+ sig { params(struct: T.untyped, error: T.nilable(Exception), message: T.nilable(String)).void }
296
387
  def initialize(struct:, error: nil, message: nil)
297
388
  @struct = struct
298
389
  super("failed to cast response into #{struct}: #{message || error&.message}")
299
390
  end
300
391
 
392
+ sig { override.returns(T::Hash[String, T.untyped]) }
301
393
  def to_h
302
394
  super.merge("struct" => struct.to_s, "cause" => cause&.message)
303
395
  end
304
396
  end
305
397
 
398
+ # Raised when the variables passed to a query or mutation can't be built
399
+ # into the generated input structs — an unknown or typo'd input key, a
400
+ # missing required input field, an out-of-range enum, or a wrong-typed
401
+ # field. This is the "the caller's input was invalid" error: rescue it at
402
+ # an API boundary to return a 422. #field names the offending input field
403
+ # when known, #struct the input type being built; the underlying
404
+ # TypeError/KeyError/ArgumentError is preserved as #cause.
405
+ class InputError < Error
406
+ extend T::Sig
407
+
408
+ sig { returns(T.nilable(String)) }
409
+ attr_reader :field
410
+
411
+ sig { returns(T.untyped) }
412
+ attr_reader :struct
413
+
414
+ sig { params(message: String, field: T.nilable(String), struct: T.untyped).void }
415
+ def initialize(message, field: nil, struct: nil)
416
+ @field = field
417
+ @struct = struct
418
+ super(message)
419
+ end
420
+
421
+ sig { override.returns(T::Hash[String, T.untyped]) }
422
+ def to_h
423
+ super.merge("field" => field, "struct" => struct&.to_s).compact
424
+ end
425
+ end
426
+
306
427
  # Build-time: the query didn't validate against the schema. Carries the
307
428
  # structured validation errors (message + line/column) rather than a
308
429
  # joined string. Under the Error umbrella like everything else raised
309
430
  # here (through 0.1.0 it was an ArgumentError instead).
310
431
  class ValidationError < Error
432
+ extend T::Sig
433
+
434
+ sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
311
435
  attr_reader :errors
312
436
 
437
+ sig { params(errors: T::Array[T::Hash[Symbol, T.untyped]]).void }
313
438
  def initialize(errors)
314
439
  @errors = errors
315
440
  super("invalid query: #{errors.map { |e| e[:message] }.join("; ")}")
316
441
  end
317
442
 
443
+ sig { override.returns(T::Hash[String, T.untyped]) }
318
444
  def to_h
319
445
  super.merge("errors" => errors.map { |e| e.transform_keys(&:to_s) })
320
446
  end
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "sorbet-runtime"
5
5
 
6
+ require_relative "errors"
6
7
  require_relative "inflect"
7
8
 
8
9
  module GraphWeaver
@@ -32,7 +33,11 @@ module GraphWeaver
32
33
  end
33
34
  suggestion ? "#{key} (did you mean '#{suggestion}'?)" : key
34
35
  end
35
- raise ArgumentError, "unknown key(s) for #{struct}: #{hints.join(", ")}"
36
+ raise GraphWeaver::InputError.new(
37
+ "unknown key(s) for #{struct}: #{hints.join(", ")}",
38
+ field: unknown.join(", "),
39
+ struct: struct,
40
+ )
36
41
  end
37
42
 
38
43
  def method_missing(name, *args, &block)
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "sorbet-runtime"
5
5
 
6
+ require_relative "errors"
6
7
  require_relative "hints"
7
8
 
8
9
  module GraphWeaver
@@ -53,6 +54,13 @@ module GraphWeaver
53
54
  raw = value.key?(field.prop) ? value[field.prop] : value[field.prop.to_s]
54
55
  [field.prop, raw.nil? || field.coercer.nil? ? raw : field.coercer.call(raw)]
55
56
  end)
57
+ rescue GraphWeaver::InputError
58
+ raise # already contextualized by a nested input / enum coercion
59
+ rescue ::TypeError, ::ArgumentError, KeyError => e
60
+ # a wrong-typed field, a missing required field, or an out-of-range
61
+ # enum — surface one branded, structured error for a 422. (`::` so the
62
+ # rescue catches Ruby's TypeError, not GraphWeaver::TypeError.)
63
+ raise GraphWeaver::InputError.new("invalid input for #{self}: #{e.message}", struct: self)
56
64
  end
57
65
  end
58
66
  end
@@ -19,10 +19,10 @@ module GraphWeaver
19
19
 
20
20
  Data = type_member
21
21
 
22
- sig { returns(T.nilable(Data)) }
22
+ sig { override.returns(T.nilable(Data)) }
23
23
  attr_reader :data
24
24
 
25
- sig { returns(T::Array[GraphWeaver::GraphQLError]) }
25
+ sig { override.returns(T::Array[GraphWeaver::GraphQLError]) }
26
26
  attr_reader :errors
27
27
 
28
28
  sig { returns(T::Hash[String, T.untyped]) }
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/graph_weaver.rb CHANGED
@@ -285,19 +285,19 @@ module GraphWeaver
285
285
  # GraphQL type — derived values live as methods next to the honest
286
286
  # wire data, and srb tc checks them against each query's selection:
287
287
  #
288
- # GraphWeaver.register_type("Pet", PetHelpers)
288
+ # GraphWeaver.extend_type("Pet", PetHelpers)
289
289
  #
290
290
  # Or build the mixin inline with a block (module_eval'd into an
291
291
  # auto-named module — quick, but invisible to srb tc):
292
292
  #
293
- # GraphWeaver.register_type("Pet") do
293
+ # GraphWeaver.extend_type("Pet") do
294
294
  # def display_name = "#{name} the pet"
295
295
  # end
296
296
  #
297
297
  # Additive (repeated and client-scoped registrations stack). Global;
298
- # client.register_type scopes to one client.
299
- def register_type(graphql_name, *mixins, requires: nil, &block)
300
- Codegen.register_type(graphql_name, *mixins, requires:, &block)
298
+ # client.extend_type scopes to one client.
299
+ def extend_type(graphql_name, *mixins, requires: nil, &block)
300
+ Codegen.extend_type(graphql_name, *mixins, requires:, &block)
301
301
  end
302
302
 
303
303
  # Restore the built-in scalars, dropping every custom registration —
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graph_weaver
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Pepper
@@ -227,6 +227,7 @@ extra_rdoc_files: []
227
227
  files:
228
228
  - ".yardopts"
229
229
  - CHANGELOG.md
230
+ - CLAUDE.md
230
231
  - Gemfile
231
232
  - Gemfile.lock
232
233
  - LICENSE.txt
@@ -236,6 +237,7 @@ files:
236
237
  - README.md
237
238
  - docs/cassettes.md
238
239
  - docs/errors.md
240
+ - docs/federation.md
239
241
  - docs/generated_modules.md
240
242
  - docs/getting_started.md
241
243
  - docs/logging.md