graph_weaver 0.0.1 → 0.2.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/.yardopts +6 -0
- data/CHANGELOG.md +279 -0
- data/Gemfile.lock +75 -17
- data/Makefile +11 -1
- data/NOTES.md +1 -1
- data/PLAN.md +85 -22
- data/README.md +86 -26
- data/docs/cassettes.md +76 -0
- data/docs/errors.md +134 -0
- data/docs/generated_modules.md +229 -0
- data/docs/getting_started.md +134 -0
- data/docs/logging.md +33 -0
- data/docs/real_world.md +53 -0
- data/docs/scalars.md +183 -0
- data/docs/testing.md +103 -0
- data/docs/transports.md +124 -0
- data/graph_weaver.gemspec +10 -1
- data/lib/graph_weaver/client.rb +200 -0
- data/lib/graph_weaver/codegen/emit.rb +286 -0
- data/lib/graph_weaver/codegen/enum_type.rb +149 -0
- data/lib/graph_weaver/codegen/nodes.rb +356 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +256 -0
- data/lib/graph_weaver/codegen.rb +396 -401
- data/lib/graph_weaver/errors.rb +322 -0
- data/lib/graph_weaver/hints.rb +63 -0
- data/lib/graph_weaver/inflect.rb +19 -0
- data/lib/graph_weaver/logging.rb +41 -0
- data/lib/graph_weaver/railtie.rb +29 -0
- data/lib/graph_weaver/response.rb +55 -0
- data/lib/graph_weaver/retry.rb +97 -0
- data/lib/graph_weaver/rspec.rb +59 -0
- data/lib/graph_weaver/schema_loader.rb +208 -8
- data/lib/graph_weaver/selection.rb +68 -0
- data/lib/graph_weaver/tasks.rb +71 -0
- data/lib/graph_weaver/testing/cassette.rb +224 -0
- data/lib/graph_weaver/testing/failure.rb +109 -0
- data/lib/graph_weaver/testing/fake_client.rb +228 -0
- data/lib/graph_weaver/testing/values.rb +98 -0
- data/lib/graph_weaver/testing.rb +106 -0
- data/lib/graph_weaver/transport/faraday.rb +56 -0
- data/lib/graph_weaver/transport/http.rb +87 -0
- data/lib/graph_weaver/transport.rb +120 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +268 -4
- metadata +132 -2
- data/lib/graph_weaver/http_executor.rb +0 -31
data/PLAN.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
# Project Plan — typed GraphQL
|
|
1
|
+
# Project Plan — GraphWeaver, typed GraphQL client for Ruby/Sorbet
|
|
2
2
|
|
|
3
|
-
_Resume-from-here notes.
|
|
4
|
-
the plan. Update
|
|
3
|
+
_Resume-from-here notes. README documents the product, NOTES.md is the
|
|
4
|
+
research notebook this grew out of; this is the plan. Update on change._
|
|
5
5
|
|
|
6
6
|
## Vision
|
|
7
7
|
|
|
@@ -12,21 +12,40 @@ shape of every query result. Dynamic (eval) mode for development, build
|
|
|
12
12
|
step for CI/static checking. Runtime deps: graphql + sorbet-runtime only
|
|
13
13
|
(graphql-client is NOT a dependency; the exploration outgrew it).
|
|
14
14
|
|
|
15
|
-
## State:
|
|
15
|
+
## State: v0.0.1 on rubygems; v0.0.2 accumulating on main
|
|
16
16
|
|
|
17
|
-
`
|
|
18
|
-
|
|
17
|
+
`make check` = bin/generate (spec fixture regeneration; parity specs
|
|
18
|
+
enforce freshness) + rspec + srb tc.
|
|
19
19
|
|
|
20
|
-
Language coverage: queries, mutations, typed variables (kwargs on
|
|
21
|
-
optional-when-defaulted
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
Language coverage: queries, mutations, typed variables (kwargs on
|
|
21
|
+
execute, optional-when-defaulted), fragments (inline, named, interface
|
|
22
|
+
conditions), union- AND interface-typed fields (__typename dispatch,
|
|
23
|
+
required at generation time), enums (T::Enum), custom scalars via the
|
|
24
|
+
ScalarType registry (register_scalar: codec inference off .parse/.load,
|
|
25
|
+
requires:, opt-in input coercion incl. built-ins).
|
|
25
26
|
Sources: live schema / introspection JSON / SDL — byte-identical output
|
|
26
27
|
(enum values + abstract-type members sorted for determinism).
|
|
27
|
-
Transport: executor
|
|
28
|
-
|
|
28
|
+
Transport: executor precedence per call → per module → baked const →
|
|
29
|
+
GraphWeaver.executor; Transport::HTTP (zero-dep) + opt-in Transport::Faraday
|
|
30
|
+
(url / block middleware / ready connection), e2e specs against WEBrick.
|
|
31
|
+
Errors: typed Response envelope (partial data + extensions survive) and
|
|
32
|
+
a GraphWeaver::Error hierarchy (Transport/Server/Query/Validation/Type)
|
|
33
|
+
with extensible transport-error classification, schema_stale? staleness
|
|
34
|
+
detection, errors_at/each_error/report field-level surfacing (entity
|
|
35
|
+
ids resolved from partial data), and #to_h machine output throughout.
|
|
36
|
+
Dynamic mode: GraphWeaver.parse (paths or raw strings, derived names,
|
|
37
|
+
container-scoped constants) and GraphWeaver.execute one-shots.
|
|
38
|
+
Schema fetching: SchemaLoader.introspect(executor, cache:, ttl:) off
|
|
39
|
+
live endpoints; load takes paths, content, or Hashes.
|
|
40
|
+
Testing (graph_weaver/testing + graph_weaver/rspec): FakeExecutor
|
|
41
|
+
(schema-correct castable fakes, faker semantics, GraphQL-name
|
|
42
|
+
overrides, corrupt:, fail_at: with null propagation), Failure canned
|
|
43
|
+
executors + SequenceExecutor, cassettes with shape-preserving
|
|
44
|
+
anonymization, rspec seed + auto_fake integration. Selection module is
|
|
45
|
+
the single shared query-walk (codegen/fake/anonymizer).
|
|
46
|
+
Federation supergraph SDL loads transparently (needs
|
|
29
47
|
directive_defaults_patch until upstream fix ships).
|
|
48
|
+
Live validation: make integration (GitHub + Countries APIs).
|
|
30
49
|
|
|
31
50
|
## Next steps (in rough order)
|
|
32
51
|
|
|
@@ -38,21 +57,32 @@ NOTES.md. Prior-art check partially answered: graphql-client PR #7
|
|
|
38
57
|
Jan 2024 with users asking; schema-wide typing can't catch
|
|
39
58
|
unfetched-field bugs or type unions/interfaces — the niche looks open.
|
|
40
59
|
|
|
60
|
+
~~Input objects~~ DONE 2026-07-11: module-level T::Structs, serialize/
|
|
61
|
+
to_h, hash coercion at the execute boundary.
|
|
62
|
+
~~Release~~ 0.1.0 cut 2026-07-11 (breaking: execute returns the
|
|
63
|
+
Response envelope; execute! for raise-or-result).
|
|
64
|
+
|
|
41
65
|
1. Stable class naming design — names come from GraphQL type names per
|
|
42
66
|
selection site; must not shift when unrelated selections are added
|
|
43
67
|
(generated code is app-code API). Current: one-level field-name
|
|
44
|
-
disambiguation, then raise.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
3. CLI entrypoint (graph_weaver generate --schema X --queries dir) —
|
|
68
|
+
disambiguation, then raise. Shipped in 0.1.0 as-is — a naming change
|
|
69
|
+
is fair game pre-1.0 but should land early.
|
|
70
|
+
2. CLI entrypoint (graph_weaver generate --schema X --queries dir) —
|
|
48
71
|
bin/generate is spec-fixture tooling, not shipped.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
72
|
+
3. Subscriptions (unsupported; raise). Recursive input types (raise).
|
|
73
|
+
6. Parse/execute memoization: repeated GraphWeaver.parse/execute of the
|
|
74
|
+
same [schema, query] re-generates and re-evals every call (~3x the
|
|
75
|
+
cost of a cached module; benchmarked 2026-07-09) — memo keyed on
|
|
76
|
+
schema/query/name/executor, minding shared executor= mutation.
|
|
77
|
+
(Schema-side caching landed: SchemaLoader.introspect cache:/ttl: +
|
|
78
|
+
introspection_result primitive for Rails.cache et al. Possible
|
|
79
|
+
follow-up: re-introspect + retry once on validation-shaped
|
|
80
|
+
QueryErrors, since GraphQL has no standard schema-version signal.)
|
|
81
|
+
7. Nice-to-haves: __typename auto-injection (currently required manually
|
|
52
82
|
on abstract selections), fragment reuse across queries, directives on
|
|
53
83
|
selections (@skip/@include make non-null fields nullable).
|
|
54
|
-
|
|
55
|
-
PR #7): RBI the GraphWeaver
|
|
84
|
+
8. Tapioca DSL compiler over dynamic mode (idea from graphql-client
|
|
85
|
+
PR #7): RBI the GraphWeaver.parse-eval'd modules so development mode
|
|
56
86
|
gets static types without the bin/generate build step — tapioca is
|
|
57
87
|
already in every Sorbet shop's workflow. Upstream's
|
|
58
88
|
Tapioca::Dsl::Helpers::GraphqlTypeHelper is prior art for type mapping.
|
|
@@ -72,3 +102,36 @@ unfetched-field bugs or type unions/interfaces — the niche looks open.
|
|
|
72
102
|
resolvers — codegen must stay name-keyed, never call schema runtime hooks
|
|
73
103
|
- graphql-client (the gem) casts scalars via coerce_isolated_input and
|
|
74
104
|
only with a live schema class — documented in the early specs
|
|
105
|
+
|
|
106
|
+
## From the field-test experiments (2026-07-12)
|
|
107
|
+
|
|
108
|
+
A junior + senior agent pair exercised the repo cold (clone, examples,
|
|
109
|
+
extensions, a Pokedex app against Hasura's 4,441-type PokeAPI schema).
|
|
110
|
+
Fixed same-day: snake_case type names generated invalid constants
|
|
111
|
+
(camelize), GraphQL::ParseError/NotImplementedError escaping the Error
|
|
112
|
+
umbrella, HttpExecutor timeouts, GitHub's top-level "type" error codes,
|
|
113
|
+
typo'd client registrations silently no-oping. Still open:
|
|
114
|
+
|
|
115
|
+
~~Recursive input types~~ DONE 2026-07-12: register-before-walk breaks
|
|
116
|
+
the recursion; emission dependency-orders the structs and, for cycles,
|
|
117
|
+
forward-declares the classes in an eval (srb rejects reopening a
|
|
118
|
+
T::Struct statically, but adding props at runtime works — the full
|
|
119
|
+
bodies below the eval are all srb sees). Verified live against Hasura
|
|
120
|
+
bool_exp.
|
|
121
|
+
|
|
122
|
+
~~Connection reuse in Transport::HTTP~~ DONE 2026-07-12: persistent
|
|
123
|
+
keep-alive connection behind a mutex, dropped on any failure; net/http's
|
|
124
|
+
keep_alive_timeout handles idle expiry. Faraday remains the pooling
|
|
125
|
+
answer.
|
|
126
|
+
|
|
127
|
+
- Subscriptions; @defer/@stream (routers send multipart responses — the
|
|
128
|
+
transport classifies them as ServerError today, no incremental support).
|
|
129
|
+
- Shared input-type structs across generated modules: one Hasura
|
|
130
|
+
bool_exp variable pulls its whole recursive closure into EVERY module
|
|
131
|
+
(~28k lines each) — correct but heavy in PRs; needs cross-module
|
|
132
|
+
sharing or selection-based pruning (field-test round 2).
|
|
133
|
+
- Structured logging: log_tag pairs lines and names operations now, but
|
|
134
|
+
events are prose — an optional {event:, url:, ms:} payload contract, a
|
|
135
|
+
scrub_variables hook, and cache-age on hit lines (field-test round 2).
|
|
136
|
+
- Cut 0.1.1 — RubyGems 0.1.0 is materially behind main (snake_case fix,
|
|
137
|
+
recursive inputs, keep-alive, logging, error umbrella).
|
data/README.md
CHANGED
|
@@ -19,49 +19,113 @@ query($id: ID!) {
|
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
```ruby
|
|
22
|
-
result = PersonQuery.execute(id: "1")
|
|
22
|
+
result = PersonQuery.execute!(id: "1") # typed result, or raises on errors (execute returns an envelope)
|
|
23
23
|
|
|
24
24
|
result.person&.name # => "Daniel" (typed String)
|
|
25
25
|
result.person&.birthday # => Date (custom scalars deserialize)
|
|
26
26
|
result.person&.nmae # => srb tc: Method `nmae` does not exist
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
New here? The **[getting started](docs/getting_started.md)** guide walks the
|
|
30
|
+
production setup end to end — initializer, codegen, fakes, CI. Or run the
|
|
31
|
+
**[examples](examples/)**, smallest first: `examples/countries.rb` (public
|
|
32
|
+
API, no auth, all dynamic), `examples/rick_and_morty.rb` (filtering,
|
|
33
|
+
pagination, a block-built type helper), and `examples/github/run.rb`
|
|
34
|
+
(auth + checked-in generated modules; it stars this repo ⭐ and introduces
|
|
35
|
+
you to your fellow stargazers).
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
- **Fragments** (inline, named, interface conditions), **unions and interfaces** (member structs, `__typename` dispatch), **enums** (`T::Enum`), **custom scalars** (pluggable registry)
|
|
33
|
-
- **Any schema source**: live schema class, introspection JSON, or SDL — including Apollo Federation supergraph SDL
|
|
34
|
-
- **Any transport**: in-process schema execution (perfect for tests) or HTTP via the bundled executor — swap per call with `executor:`
|
|
35
|
-
- **Dynamic mode** for development: `GraphWeaver::Codegen.load(...)` generates and evals on the fly, no build step
|
|
37
|
+
#### Features
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
- **Queries and mutations** with typed variable kwargs — enums as `T::Enum`s, input objects as `T::Struct`s, required vs optional falling out of nullability and defaults
|
|
40
|
+
- **Fragments** (inline, named, type conditions), **unions and interfaces** (member structs, `__typename` dispatch), **custom scalars** (pluggable registry), `@skip`/`@include` nullability
|
|
41
|
+
- **Any schema source**: live schema class, introspection JSON, or SDL — including Apollo Federation supergraph SDL; introspect live endpoints with caching
|
|
42
|
+
- **Any transport**: in-process schema execution, the zero-dependency HTTP executor, or Faraday with your own middleware — plus a composable `Retry` (exponential/linear/custom backoff, jitter, retry-by-error-class or GraphQL code) — swap per call with `executor:`
|
|
43
|
+
- **Structured errors**: a typed response envelope (partial data + extensions survive), an error hierarchy split by failure site, field-level reports with entity ids, and `schema_stale?` detection — every error dual-surfaced as a human message plus JSON-ready `#to_h`
|
|
44
|
+
- **Testing built in**: schema-correct fakes, failure simulation, record/replay cassettes with anonymization, rspec integration
|
|
45
|
+
- **Dynamic mode** for development: `GraphWeaver.parse(...)` generates and evals on the fly, no build step
|
|
46
|
+
|
|
47
|
+
#### Usage
|
|
48
|
+
|
|
49
|
+
Three ways to run a query — pick by context:
|
|
50
|
+
|
|
51
|
+
| Context | Use |
|
|
52
|
+
|---------|-----|
|
|
53
|
+
| Production | checked-in codegen (`rake graph_weaver:generate`) — reviewed, `srb tc`-checked |
|
|
54
|
+
| Development, consoles | `client.parse` / `client.load_queries!` — no build step |
|
|
55
|
+
| Scripts, one-offs | `client.execute!` — no module at all |
|
|
56
|
+
|
|
57
|
+
The production path assembled is the [getting started](docs/getting_started.md);
|
|
58
|
+
the pieces:
|
|
38
59
|
|
|
39
60
|
```ruby
|
|
40
61
|
require "graph_weaver"
|
|
41
62
|
|
|
42
|
-
#
|
|
43
|
-
|
|
63
|
+
# a client for one server: transport (Faraday when loaded), auth, and a
|
|
64
|
+
# lazily introspected schema. The first argument is a url or any schema
|
|
65
|
+
# source — a live schema class, or a .json/.graphql dump
|
|
66
|
+
api = GraphWeaver.new("https://api.example.com/graphql", auth: ENV["API_TOKEN"], cache: true)
|
|
44
67
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
68
|
+
# make it the app default — generated modules execute through it
|
|
69
|
+
GraphWeaver.client = api
|
|
70
|
+
|
|
71
|
+
# generate checked-in typed modules (rake graph_weaver:generate, or directly)
|
|
72
|
+
source = GraphWeaver::Codegen.generate(
|
|
73
|
+
schema: api.schema,
|
|
48
74
|
query: File.read("queries/person.graphql"),
|
|
49
75
|
module_name: "PersonQuery",
|
|
50
|
-
)
|
|
51
|
-
|
|
76
|
+
)
|
|
52
77
|
File.write("app/queries/person_query.rb", source)
|
|
53
78
|
|
|
54
79
|
# at runtime
|
|
55
|
-
|
|
56
|
-
PersonQuery.execute(id: "1"
|
|
80
|
+
PersonQuery.execute(id: "1") # via GraphWeaver.client
|
|
81
|
+
PersonQuery.execute(other_client, id: "1") # or per call
|
|
57
82
|
```
|
|
58
83
|
|
|
59
|
-
|
|
84
|
+
Module names derive from the operation name (`query GetPerson` →
|
|
85
|
+
`GetPerson`) or, for `parse` on a `.graphql` file, from the file name;
|
|
86
|
+
pass `module_name:`/`name:` to override. Pass `client:` (a constant) to
|
|
87
|
+
bake a default client into the generated module. Prefer Faraday? It's
|
|
88
|
+
opt-in (`gem "faraday"`), and the client picks it up when loaded —
|
|
89
|
+
middleware blocks and ready connections in [transports](docs/transports.md).
|
|
90
|
+
|
|
91
|
+
In development, skip the build step entirely — modules from `client.parse`
|
|
92
|
+
carry the client's transport, no global wiring needed:
|
|
60
93
|
|
|
61
94
|
```ruby
|
|
62
|
-
|
|
95
|
+
# parse a query into a typed module on the fly — a .graphql path or a raw string
|
|
96
|
+
PersonQuery = api.parse("queries/person.graphql")
|
|
97
|
+
PersonQuery.execute(id: "1")
|
|
98
|
+
|
|
99
|
+
# or every query file at once (queries_path convention), named like generation would
|
|
100
|
+
api.load_queries!
|
|
101
|
+
|
|
102
|
+
# or one-shot, no module at all — variables are plain kwargs
|
|
103
|
+
api.execute!("query($id: ID!) { person(id: $id) { name } }", id: "1")
|
|
63
104
|
```
|
|
64
105
|
|
|
106
|
+
|
|
107
|
+
#### Dig deeper
|
|
108
|
+
|
|
109
|
+
- **[Getting started](docs/getting_started.md)** — the production path in Rails,
|
|
110
|
+
step by step: initializer, rake tasks, fakes, CI, Sorbet or not
|
|
111
|
+
- **[Generated modules](docs/generated_modules.md)** — module anatomy, typed
|
|
112
|
+
variables (enums, input objects), fragments/unions/interfaces,
|
|
113
|
+
`@skip`/`@include`, naming, clients, dynamic mode
|
|
114
|
+
- **[Against a real API](docs/real_world.md)** — the exploratory tour:
|
|
115
|
+
introspect a live endpoint (GitHub end to end), dynamic mode, schema caching
|
|
116
|
+
- **[Transports](docs/transports.md)** — clients, the execute contract,
|
|
117
|
+
Faraday, retries and backoff
|
|
118
|
+
- **[Custom scalars](docs/scalars.md)** — the registry: codec inference,
|
|
119
|
+
requires, input coercion
|
|
120
|
+
- **[Errors](docs/errors.md)** — the Response envelope, the error hierarchy,
|
|
121
|
+
field-level reports with entity ids, stale-schema detection
|
|
122
|
+
- **[Logging](docs/logging.md)** — point `GraphWeaver.logger` at any Logger:
|
|
123
|
+
wire traffic at debug, introspection/cache/codegen at info, errors at warn
|
|
124
|
+
- **[Testing](docs/testing.md)** — schema-correct fakes, failure simulation,
|
|
125
|
+
rspec integration
|
|
126
|
+
- **[Cassettes](docs/cassettes.md)** — capture and replay real API
|
|
127
|
+
responses; anonymized recording (`GRAPHWEAVER_RECORD=1`, rake tasks)
|
|
128
|
+
|
|
65
129
|
----
|
|
66
130
|
## Installation
|
|
67
131
|
|
|
@@ -72,16 +136,12 @@ gem "graph_weaver"
|
|
|
72
136
|
|
|
73
137
|
or
|
|
74
138
|
|
|
75
|
-
```
|
|
139
|
+
```sh
|
|
76
140
|
gem install graph_weaver
|
|
77
141
|
```
|
|
78
142
|
|
|
79
143
|
----
|
|
80
144
|
## Development
|
|
81
145
|
|
|
82
|
-
`make check` — regenerate spec fixtures, run specs, typecheck
|
|
83
|
-
|
|
84
|
-
See `PLAN.md` for roadmap and `NOTES.md` for the research notebook this
|
|
85
|
-
gem grew out of (an exploration of graphql-client internals — GraphWeaver
|
|
86
|
-
is a standalone client, not an extension; it depends only on `graphql`
|
|
87
|
-
and `sorbet-runtime`).
|
|
146
|
+
- `make check` — regenerate spec fixtures, run specs, typecheck
|
|
147
|
+
- `make integration` — one-off checks against the live GitHub and Countries APIs
|
data/docs/cassettes.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Cassettes: capture and replay
|
|
2
|
+
|
|
3
|
+
Cassettes record real API responses and replay them in tests — above the
|
|
4
|
+
transport (a client wrapping a client), so there's no HTTP
|
|
5
|
+
interception and they work identically over HTTP, Faraday, or in-process
|
|
6
|
+
execution. A cassette is a YAML file of `{query, variables, response}`
|
|
7
|
+
entries, matched on the normalized query + variables.
|
|
8
|
+
|
|
9
|
+
## The workflow
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# spec: replay when the cassette exists, record against `live` when not
|
|
13
|
+
cassette = GraphWeaver::Testing::Cassette.use("github", client: live)
|
|
14
|
+
result = RepoQuery.execute!(cassette, owner: "dpep", name: "graph_weaver")
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
1. **Record** — first run hits the live API and writes
|
|
18
|
+
`spec/cassettes/github.yml` (`Testing.config.cassette_dir` resolves bare
|
|
19
|
+
names).
|
|
20
|
+
2. **Anonymize** — cassettes hold real data; scrub before committing (below).
|
|
21
|
+
3. **Commit** — tests now run offline, fast, deterministic.
|
|
22
|
+
4. **Re-record** when the API's real behavior changes:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
GRAPHWEAVER_RECORD=1 bundle exec rspec # every Cassette.use records afresh
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
(`Testing.config.record = true` is the programmatic equivalent.)
|
|
29
|
+
|
|
30
|
+
Replaying an unrecorded request raises `MissingRecording` with the query
|
|
31
|
+
and the path — no silent fabrication.
|
|
32
|
+
|
|
33
|
+
## Anonymization
|
|
34
|
+
|
|
35
|
+
Anonymizing rewrites recorded values through the same engine
|
|
36
|
+
[FakeClient](testing.md) uses, while preserving everything that makes
|
|
37
|
+
the recording faithful:
|
|
38
|
+
|
|
39
|
+
| preserved | replaced |
|
|
40
|
+
|-----------|----------|
|
|
41
|
+
| shape: keys, list lengths, null positions | strings (semantically: emails look like emails) |
|
|
42
|
+
| enums, booleans, `__typename` | numbers, dates |
|
|
43
|
+
| id *relationships* (same original id → same fake id) | the id values themselves |
|
|
44
|
+
|
|
45
|
+
Three ways to run it:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# 1. as recordings happen — assertions you write against the recording
|
|
49
|
+
# run hold on replay, and real data never touches disk
|
|
50
|
+
GraphWeaver::Testing.configure do |config|
|
|
51
|
+
config.schema = MySchema
|
|
52
|
+
config.anonymize = true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 2. after the fact, per cassette
|
|
56
|
+
GraphWeaver::Testing::Cassette.new("spec/cassettes/github.yml").anonymize!(schema:)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
# 3. the whole cassette_dir at once
|
|
61
|
+
rake graph_weaver:cassettes:anonymize
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Anonymization needs the schema (it walks each recorded query's selections
|
|
65
|
+
to know which values are enums, dates, ids...). Variables are NOT
|
|
66
|
+
anonymized — they're the replay matching key; don't record with secret
|
|
67
|
+
variables.
|
|
68
|
+
|
|
69
|
+
## When to use what
|
|
70
|
+
|
|
71
|
+
- **FakeClient** — no recording needed; schema-correct random data.
|
|
72
|
+
Best default for unit tests.
|
|
73
|
+
- **Cassettes** — real response *shapes* from a real API (pagination
|
|
74
|
+
quirks, actual union members, servers' null habits). Best for
|
|
75
|
+
integration-ish tests and regression pinning.
|
|
76
|
+
- **Anonymized cassettes** — cassette fidelity, committable without PII.
|
data/docs/errors.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Errors
|
|
2
|
+
|
|
3
|
+
`execute` returns a typed **`Response` envelope** rather than raising on GraphQL
|
|
4
|
+
errors — so partial data and top-level `extensions` (cost, throttle) survive.
|
|
5
|
+
`execute!` is the shortcut when you just want the result:
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
PersonQuery.execute!(id: "1") # => Result, or raises QueryError (== execute(...).data!)
|
|
9
|
+
|
|
10
|
+
response = PersonQuery.execute(id: "1") # => GraphWeaver::Response[Result]
|
|
11
|
+
response.data # T.nilable(Result) — typed, present even on partial success
|
|
12
|
+
response.errors # Array[GraphWeaver::GraphQLError]
|
|
13
|
+
response.errors? # any top-level errors?
|
|
14
|
+
response.extensions # { "cost" => … } — rides on success too
|
|
15
|
+
response.data! # the Result, or raise GraphWeaver::QueryError
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The envelope is a single generic `GraphWeaver::Response[Result]` — `response.data`
|
|
19
|
+
stays fully typed to *this* query's result, no per-query wrapper class.
|
|
20
|
+
|
|
21
|
+
Every `GraphQLError` exposes `#message`, `#locations`, `#path`, `#extensions`,
|
|
22
|
+
and `#code` (`extensions["code"]`) — match on the **code**, not the message
|
|
23
|
+
string (`response.errors.first.code == "THROTTLED"`).
|
|
24
|
+
|
|
25
|
+
Everything GraphWeaver raises descends from `GraphWeaver::Error`, split by where
|
|
26
|
+
it failed:
|
|
27
|
+
|
|
28
|
+
| Class | When |
|
|
29
|
+
|-------|------|
|
|
30
|
+
| `TransportError` | never reached the server — DNS, connection refused, TLS, timeout |
|
|
31
|
+
| `ServerError` | reached it, non-2xx HTTP — `#status`, `#body` |
|
|
32
|
+
| `QueryError` | 200 body with top-level GraphQL errors — `#errors`, `#data`, `#extensions`, `#codes` |
|
|
33
|
+
| `TypeError` | the response wouldn't cast into the generated structs — `#struct`, `#cause` |
|
|
34
|
+
| `ValidationError` | build time: the query didn't validate against the schema |
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
begin
|
|
38
|
+
person = PersonQuery.execute!(id: "1").person
|
|
39
|
+
rescue GraphWeaver::TransportError
|
|
40
|
+
retry # network blip
|
|
41
|
+
rescue GraphWeaver::ServerError => e
|
|
42
|
+
e.status >= 500 ? backoff : raise # retry 5xx; a 4xx is our bug
|
|
43
|
+
rescue GraphWeaver::QueryError => e
|
|
44
|
+
e.codes.include?("THROTTLED") ? backoff : raise
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or skip the hand-rolling — `Retry` wraps any transport with
|
|
49
|
+
configurable retries:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
transport = GraphWeaver::Retry.new(
|
|
53
|
+
GraphWeaver::Transport::HTTP.new(url),
|
|
54
|
+
tries: 5, # total attempts
|
|
55
|
+
backoff: :exponential, # or :linear, or ->(attempt) { seconds }
|
|
56
|
+
base: 0.5, max: 30, # seconds, clamped at max:
|
|
57
|
+
jitter: true, # randomize each delay by 50-100%
|
|
58
|
+
retry_codes: ["THROTTLED"], # also retry GraphQL errors by code
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Defaults match the rescue block above: transport failures always retry,
|
|
63
|
+
`ServerError` only on 5xx (a 4xx is your bug — retrying won't fix it;
|
|
64
|
+
override with `retry_if:`), and GraphQL-level codes only when listed in
|
|
65
|
+
`retry_codes:`. Exhausting `tries:` re-raises the last error.
|
|
66
|
+
|
|
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").
|
|
72
|
+
|
|
73
|
+
Business/validation failures returned *as data* (Shopify-style `userErrors { field
|
|
74
|
+
message code }`) aren't errors here — they're just fields you selected, so they
|
|
75
|
+
deserialize onto `response.data` like anything else and you inspect them there.
|
|
76
|
+
|
|
77
|
+
The one-shot `GraphWeaver.execute` / `execute!` mirror this: `execute` returns
|
|
78
|
+
the envelope, `execute!` the result-or-raise.
|
|
79
|
+
|
|
80
|
+
## Extending TransportError
|
|
81
|
+
|
|
82
|
+
What counts as a `TransportError` is an **extensible set** — each transport
|
|
83
|
+
seeds its own network exceptions (`Errno::*`, `SocketError`, timeouts, TLS; the
|
|
84
|
+
Faraday transport adds its own), and you can register more so a custom adapter's
|
|
85
|
+
or connection pool's failure gets the same treatment:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
GraphWeaver.register_transport_error(ConnectionPool::TimeoutError)
|
|
89
|
+
GraphWeaver.transport_errors << MyAdapter::ResetError # it's just a Set
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
## Programmatic surfacing
|
|
94
|
+
|
|
95
|
+
Every error is dual-surface: `#message` for humans, `#to_h` for machines — a
|
|
96
|
+
JSON-ready hash (error class, per-error `path`/`code`/`locations`/`extensions`)
|
|
97
|
+
you can nest straight into a log line or an API response.
|
|
98
|
+
|
|
99
|
+
Field-level tooling lives on both `Response` and `QueryError`:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
response.errors_at("person.email") # errors touching a path (prefix match)
|
|
103
|
+
response.each_error do |field, errors| # grouped by index-stripped field
|
|
104
|
+
form.add_error(field, errors.map(&:message))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
response.report
|
|
108
|
+
# { "person.pets.name" => {
|
|
109
|
+
# "messages" => ["name hidden"], "codes" => ["PRIVATE"],
|
|
110
|
+
# "entity_ids" => ["7", "9"], # resolved by walking paths through partial data
|
|
111
|
+
# "errors" => [ ...full to_h detail... ] },
|
|
112
|
+
# nil => { "codes" => ["DOWN"], ... } } # global errors under nil
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`GraphQLError#field` strips list indices (`people.3.email` → `people.email`) —
|
|
116
|
+
the stable grouping key; the raw `#path` keeps indices for exact location.
|
|
117
|
+
|
|
118
|
+
## Stale schemas
|
|
119
|
+
|
|
120
|
+
GraphQL has no schema-version signal, so a schema change surfaces as the
|
|
121
|
+
server rejecting your query's shape. `response.schema_stale?` /
|
|
122
|
+
`QueryError#schema_stale?` detect validation-shaped rejections (Apollo's
|
|
123
|
+
`GRAPHQL_VALIDATION_FAILED` code, or the message patterns graphql-ruby and
|
|
124
|
+
GitHub use), and the raised message says what to do: regenerate modules and/or
|
|
125
|
+
refresh the schema cache.
|
|
126
|
+
|
|
127
|
+
## Cast failures
|
|
128
|
+
|
|
129
|
+
When wire data disagrees with the types the schema promised at generation time
|
|
130
|
+
(a nil where non-null was declared, a malformed scalar, an unknown enum value),
|
|
131
|
+
casting raises `GraphWeaver::TypeError` naming the failing generated struct,
|
|
132
|
+
with the original exception as `#cause`. Simulate one in tests with
|
|
133
|
+
`GraphWeaver::Testing::FakeClient.new(schema:, corrupt: "Person.birthday")` — see
|
|
134
|
+
[testing](testing.md).
|