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 +4 -4
- data/CHANGELOG.md +16 -0
- data/CLAUDE.md +70 -0
- data/Gemfile.lock +2 -2
- data/README.md +2 -0
- data/docs/errors.md +25 -5
- data/docs/federation.md +100 -0
- data/docs/scalars.md +2 -2
- data/lib/graph_weaver/client.rb +2 -2
- data/lib/graph_weaver/codegen/enum_type.rb +5 -5
- data/lib/graph_weaver/codegen/nodes.rb +1 -1
- data/lib/graph_weaver/codegen.rb +3 -1
- 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 +5 -5
- 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: e66ac94243bdc7f065534845a722bfd209c8d7b23a3a5fa1eff6041e3f563655
|
|
4
|
+
data.tar.gz: 413217dbda71c297abe537dc996b8e8413821365d9e966493bf7301d6351a6c7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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/scalars.md
CHANGED
|
@@ -155,7 +155,7 @@ module PetHelpers
|
|
|
155
155
|
def display_name = adult? ? "#{name} 🦴" : "#{name} 🐶"
|
|
156
156
|
end
|
|
157
157
|
|
|
158
|
-
GraphWeaver.
|
|
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.
|
|
176
|
+
api.extend_type("Pet") do
|
|
177
177
|
def display_name = "#{name} 🐶"
|
|
178
178
|
end
|
|
179
179
|
```
|
data/lib/graph_weaver/client.rb
CHANGED
|
@@ -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.
|
|
105
|
-
def
|
|
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.
|
|
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.
|
|
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
|
|
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#
|
|
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 (
|
|
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.
|
|
150
|
+
# helper modules to include (see Codegen.extend_type)
|
|
151
151
|
attr_accessor :graphql_type, :mixins
|
|
152
152
|
|
|
153
153
|
def initialize(class_name)
|
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/graph_weaver/errors.rb
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# typed:
|
|
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) —
|
|
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 ||=
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
180
|
+
p = path
|
|
181
|
+
return unless p
|
|
131
182
|
|
|
132
|
-
named =
|
|
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
|
|
189
|
+
loc = locations.first
|
|
138
190
|
at = loc ? " at #{loc["line"]}:#{loc["column"]}" : ""
|
|
139
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
279
|
+
path = error.path
|
|
280
|
+
return unless node && path
|
|
213
281
|
|
|
214
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
data/lib/graph_weaver/hints.rb
CHANGED
|
@@ -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
|
|
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]) }
|
data/lib/graph_weaver/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
299
|
-
def
|
|
300
|
-
Codegen.
|
|
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.
|
|
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
|