graph_weaver 0.2.2 → 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: 7673fd6efc0459bdb3097e73e1c3f8fe265910bfdaf349bf7a358adc1ece1830
4
- data.tar.gz: f5ce9f4c484e1fb8f5eb074eaaac502ca62fb3ecf6ab90d9157a2a055e88c3b3
3
+ metadata.gz: e66ac94243bdc7f065534845a722bfd209c8d7b23a3a5fa1eff6041e3f563655
4
+ data.tar.gz: 413217dbda71c297abe537dc996b8e8413821365d9e966493bf7301d6351a6c7
5
5
  SHA512:
6
- metadata.gz: a75dd5a723dd52108e2f449bd1c5f0e1dc9a71065106614c57854f318024f700e16596cd4764cf9768f02d536cc040bd0554bdd75762eeba23c09b71a7051cf5
7
- data.tar.gz: 377c07a20d2cf47873425c72ad84709c36555396fea1a2102c7bdc3f79e07e8e823ed9639238aecac8066f352e65599a10ae15a71013a7de3a0a4d3675bf169c
6
+ metadata.gz: 62dab00fca85973dbff0e695dcd9970a554ca98b6a717a375fa7f2cde349b2967672ce541bf108d3b058b54b47ce7d274f6c8f11f069360a59e95fcb86ff8ae5
7
+ data.tar.gz: 3a11e52459c3b0158d831c8815ced9c4ca5cd323435ffac4e36a8648e83179dd87763a4570aaa56b240d0d7d77d3b91e43d1706f40112d3eae2fa608950b797c
data/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
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
+
1
17
  ### v0.2.2 (2026-07-22)
2
18
  - Generated modules expose from_response / from_response! alongside
3
19
  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.2.2)
4
+ graph_weaver (0.3.0)
5
5
  graphql (>= 2)
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.2.2)
179
+ graph_weaver (0.3.0)
180
180
  graphql (2.6.6) sha256=7438e2036b571c884377eb1529225470ba959365f26dc47152953c213bc38c31
181
181
  i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
182
182
  io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
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.
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)
@@ -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.2"
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.2
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