graph_weaver 0.4.6 → 0.5.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 +1314 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -154
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -258
- data/lib/graph_weaver/codegen/enum_type.rb +27 -124
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +593 -334
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +9 -1
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +14 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +7 -0
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +794 -59
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +43 -8
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +160 -61
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +181 -21
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +28 -10
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +67 -14
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +389 -170
- metadata +20 -3
data/docs/cassettes.md
CHANGED
|
@@ -1,76 +1,103 @@
|
|
|
1
1
|
# Cassettes: capture and replay
|
|
2
2
|
|
|
3
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
transport (a client wrapping a client), so there's no HTTP interception and
|
|
5
|
+
they work identically over HTTP, Faraday, or in-process execution. A cassette
|
|
6
|
+
is a YAML list of `{query, variables, operationName, response}` entries,
|
|
7
|
+
matched on everything but the response — the request's identity as the server
|
|
8
|
+
sees it.
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
`Testing.cassette(name, client:)` returns a client that replays
|
|
11
|
+
`spec/cassettes/<name>.yml`, recording it through `client:` first if the file
|
|
12
|
+
doesn't exist yet.
|
|
10
13
|
|
|
11
14
|
```ruby
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
result = RepoQuery.execute!(cassette, owner: "dpep", name: "graph_weaver")
|
|
15
|
+
client = GraphWeaver::Testing.cassette("github", client: live)
|
|
16
|
+
result = RepoQuery.execute!(client:, owner: "dpep", name: "graph_weaver")
|
|
15
17
|
```
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
3. **Commit** — tests now run offline, fast, deterministic.
|
|
22
|
-
4. **Re-record** when the API's real behavior changes:
|
|
19
|
+
That first run writes `spec/cassettes/github.yml` (`Testing.config.cassette_dir`
|
|
20
|
+
resolves bare names). Commit it — with anonymization on (below), since
|
|
21
|
+
recordings hold real data — and the suite runs offline from then on. Re-record
|
|
22
|
+
when the API's real behavior changes:
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
```sh
|
|
25
|
+
GRAPHWEAVER_RECORD=1 bundle exec rspec # every Testing.cassette records afresh
|
|
26
|
+
```
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
(`Testing.config.record = true` is the programmatic equivalent.) A call with no
|
|
29
|
+
`client:` raises there, rather than quietly replaying the recording it was told
|
|
30
|
+
to refresh. A *request* with no recording raises
|
|
31
|
+
`GraphWeaver::Testing::MissingRecording`, naming the variables it was called
|
|
32
|
+
with and the ones recorded for that same query — what usually differs.
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
and the path — no silent fabrication.
|
|
34
|
+
## Has a recording gone stale?
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
A cassette is the one artifact here recorded from *someone else's* server, and
|
|
37
|
+
none of the other checks can see it drift: `verify` asks whether the generated
|
|
38
|
+
Ruby is fresh, `queries:check` whether a query still validates, `schema:diff`
|
|
39
|
+
whether the server's schema moved. When the recorded *answers* stop fitting the
|
|
40
|
+
structs your schema generated — a field that was `Int!` when you recorded and
|
|
41
|
+
is `String!` now — nothing notices until a spec dies mid-run on a cast error
|
|
42
|
+
naming a struct and nothing else.
|
|
34
43
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
```sh
|
|
45
|
+
rake graph_weaver:cassettes:check
|
|
46
|
+
```
|
|
38
47
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
It replays every recording through the generated modules — no network — so it
|
|
49
|
+
belongs in the normal PR run beside `verify`, and exits non-zero on drift:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
spec/cassettes/dashboard.yml: 1 stale (3 checked, 1 not sent by any query module)
|
|
53
|
+
DashboardQuery {"id" => "b1"}
|
|
54
|
+
failed to cast response into DashboardQuery::Result::Me::Reviews::Book: Parameter 'price_cents': Can't set …price_cents to 4200 (instance of Integer) - need a String
|
|
55
|
+
```
|
|
44
56
|
|
|
45
|
-
|
|
57
|
+
A recording is matched to the module that sends its query, so one written by
|
|
58
|
+
hand is skipped and counted rather than guessed at. Checking **none** of them
|
|
59
|
+
fails too: a green run that compared nothing would pass whatever the recordings
|
|
60
|
+
said. The fix is a re-record (`GRAPHWEAVER_RECORD=1`, with a live `client:`) —
|
|
61
|
+
or `rake graph_weaver:generate`, if it was the schema dump that moved first.
|
|
62
|
+
|
|
63
|
+
## Anonymization
|
|
64
|
+
|
|
65
|
+
Cassettes hold real responses, so scrub them as they're recorded: real data
|
|
66
|
+
never reaches disk, and the caller sees the anonymized response too, so
|
|
67
|
+
assertions written during the recording run still hold on replay.
|
|
46
68
|
|
|
47
69
|
```ruby
|
|
48
|
-
# 1. as recordings happen — assertions you write against the recording
|
|
49
|
-
# run hold on replay, and real data never touches disk
|
|
50
70
|
GraphWeaver::Testing.configure do |config|
|
|
51
71
|
config.schema = MySchema
|
|
52
72
|
config.anonymize = true
|
|
53
73
|
end
|
|
54
|
-
|
|
55
|
-
# 2. after the fact, per cassette
|
|
56
|
-
GraphWeaver::Testing::Cassette.new("spec/cassettes/github.yml").anonymize!(schema:)
|
|
57
74
|
```
|
|
58
75
|
|
|
76
|
+
Values are rewritten through the same engine [FakeClient](testing.md) uses,
|
|
77
|
+
preserving everything that makes the recording faithful:
|
|
78
|
+
|
|
79
|
+
| preserved | replaced |
|
|
80
|
+
|-----------|----------|
|
|
81
|
+
| shape: keys, list lengths, null positions | strings (semantically: emails look like emails) |
|
|
82
|
+
| enums, booleans, `__typename` | numbers, dates |
|
|
83
|
+
| id *relationships* (same original id → same fake id) | the id values themselves |
|
|
84
|
+
|
|
85
|
+
It needs the schema — it walks each recorded query's selections to know which
|
|
86
|
+
values are enums, dates, ids. Variables are NOT anonymized: they're the replay
|
|
87
|
+
matching key, so don't record with secret variables.
|
|
88
|
+
|
|
89
|
+
For cassettes recorded before the flag was on:
|
|
90
|
+
|
|
59
91
|
```sh
|
|
60
|
-
#
|
|
61
|
-
rake graph_weaver:cassettes:anonymize
|
|
92
|
+
rake graph_weaver:cassettes:anonymize # every cassette in cassette_dir, in place
|
|
62
93
|
```
|
|
63
94
|
|
|
64
|
-
Anonymization
|
|
65
|
-
|
|
66
|
-
anonymized — they're the replay matching key; don't record with secret
|
|
67
|
-
variables.
|
|
95
|
+
Anonymization preserves shape, so an anonymized cassette still passes
|
|
96
|
+
`cassettes:check`.
|
|
68
97
|
|
|
69
|
-
##
|
|
98
|
+
## Cassette or FakeClient?
|
|
70
99
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
integration-ish tests and regression pinning.
|
|
76
|
-
- **Anonymized cassettes** — cassette fidelity, committable without PII.
|
|
100
|
+
[FakeClient](testing.md) needs no recording and is the better default for unit
|
|
101
|
+
tests. Reach for a cassette when the *shape* of a real API's answers is the
|
|
102
|
+
point — pagination quirks, which union member came back, where that server puts
|
|
103
|
+
its nulls — and for pinning a regression.
|
data/docs/editors.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Editor support: five lines of YAML
|
|
2
|
+
|
|
3
|
+
Your `.graphql` files are plain GraphQL documents and your schema dump is a
|
|
4
|
+
plain introspection result, so the whole JavaScript GraphQL editor toolchain
|
|
5
|
+
works on a Ruby repo — **with no JS project, no `package.json`, and no `npm
|
|
6
|
+
install`**. It just needs one config file telling it where the two live.
|
|
7
|
+
|
|
8
|
+
Ruby developers mostly don't know this, which is the only reason it's worth a
|
|
9
|
+
page.
|
|
10
|
+
|
|
11
|
+
## The file
|
|
12
|
+
|
|
13
|
+
```yaml
|
|
14
|
+
# graphql.config.yml — repo root
|
|
15
|
+
schema: app/graphql/schema.json
|
|
16
|
+
documents:
|
|
17
|
+
- app/graphql/queries/**/*.{graphql,gql}
|
|
18
|
+
- app/graphql/fragments/**/*.{graphql,gql}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That's the whole setup. The paths are graph_weaver's conventions
|
|
22
|
+
(`GraphWeaver.schema_path`, `queries_paths`, `fragments_paths`) — if you moved
|
|
23
|
+
them, move these to match. Include the fragments directory: an editor
|
|
24
|
+
validating a query that spreads a shared fragment reports `Unknown fragment`
|
|
25
|
+
unless the fragment files are in `documents` too.
|
|
26
|
+
|
|
27
|
+
An SDL dump works just as well if you took one (`cache: :graphql`):
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
schema: app/graphql/schema.graphql
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Introspection JSON is read directly — graphql-config ships a JSON loader, so
|
|
34
|
+
`schema.json` needs no conversion step. graph_weaver also writes a
|
|
35
|
+
`graph_weaver` provenance key alongside the introspection result; if some tool
|
|
36
|
+
objects to it, point `schema:` at an SDL dump instead.
|
|
37
|
+
|
|
38
|
+
## What it buys you
|
|
39
|
+
|
|
40
|
+
The two editor plugins that read this file:
|
|
41
|
+
|
|
42
|
+
- **[vscode-graphql](https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql)**
|
|
43
|
+
— its README states it **requires** a graphql-config file, which is why
|
|
44
|
+
nothing works without the YAML above.
|
|
45
|
+
- **The JetBrains GraphQL plugin**, bundled with recent RubyMine, reads the
|
|
46
|
+
same file.
|
|
47
|
+
|
|
48
|
+
Either one gives you, inside a `.graphql` file:
|
|
49
|
+
|
|
50
|
+
- validation as you type — a typo'd field is red before you run anything
|
|
51
|
+
- field and argument autocomplete off the real schema
|
|
52
|
+
- go-to-definition and hover docs into schema types, including the
|
|
53
|
+
descriptions the API author wrote
|
|
54
|
+
|
|
55
|
+
That is the same feedback the generator gives you, one round trip earlier — you
|
|
56
|
+
find the typo while typing the query, not at `rake graph_weaver:generate`.
|
|
57
|
+
|
|
58
|
+
The same globs also feed the JS CI tools, if you want them (these *do* need
|
|
59
|
+
npm, unlike the editor path):
|
|
60
|
+
[graphql-inspector](https://the-guild.dev/graphql/inspector) `validate` and
|
|
61
|
+
[@graphql-eslint](https://the-guild.dev/graphql/eslint/docs) for lint rules
|
|
62
|
+
over your documents.
|
|
63
|
+
|
|
64
|
+
## What it doesn't buy you
|
|
65
|
+
|
|
66
|
+
**Nothing links a `.graphql` file to the Ruby it generates.** There is no
|
|
67
|
+
go-to-definition from a query field to its `T::Struct`, no rename that moves
|
|
68
|
+
both, no warning that a struct went unused. The editor plugin understands
|
|
69
|
+
GraphQL and Sorbet understands Ruby, and no tool in any ecosystem bridges the
|
|
70
|
+
two except where documents and types share a single language service.
|
|
71
|
+
|
|
72
|
+
So the division of labour is:
|
|
73
|
+
|
|
74
|
+
| Question | Answered by |
|
|
75
|
+
|---|---|
|
|
76
|
+
| Is this query valid, right now, as I type it? | the editor plugin |
|
|
77
|
+
| Do the result types match the query? | `rake graph_weaver:generate` + `srb tc` |
|
|
78
|
+
| Is my checked-in Ruby stale? | `rake graph_weaver:verify` |
|
|
79
|
+
| Did the server break my queries? | `rake graph_weaver:queries:check` |
|
|
80
|
+
|
|
81
|
+
The last two are the Ruby-side answers, and they need no JS at all — see
|
|
82
|
+
[getting started](getting_started.md#5-verify-in-ci).
|
data/docs/errors.md
CHANGED
|
@@ -11,6 +11,7 @@ response = PersonQuery.execute(id: "1") # => GraphWeaver::Response[Result]
|
|
|
11
11
|
response.data # T.nilable(Result) — typed, present even on partial success
|
|
12
12
|
response.errors # Array[GraphWeaver::GraphQLError]
|
|
13
13
|
response.errors? # any top-level errors?
|
|
14
|
+
response.success? # the same question the other way round
|
|
14
15
|
response.extensions # { "cost" => … } — rides on success too
|
|
15
16
|
response.data! # the Result, or raise GraphWeaver::QueryError
|
|
16
17
|
```
|
|
@@ -22,17 +23,27 @@ Every `GraphQLError` exposes `#message`, `#locations`, `#path`, `#extensions`,
|
|
|
22
23
|
and `#code` (`extensions["code"]`) — match on the **code**, not the message
|
|
23
24
|
string (`response.errors.first.code == "THROTTLED"`).
|
|
24
25
|
|
|
25
|
-
Everything GraphWeaver
|
|
26
|
-
|
|
26
|
+
Everything GraphWeaver *concludes* descends from `GraphWeaver::Error` — a
|
|
27
|
+
transport failure, a rejected query, a response that wouldn't cast, a plan the
|
|
28
|
+
[local router](federation.md) refused, a subgraph map that doesn't add up. The
|
|
29
|
+
subclass says where it failed:
|
|
27
30
|
|
|
28
31
|
| Class | When |
|
|
29
32
|
|-------|------|
|
|
30
33
|
| `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
|
|
34
|
+
| `ServerError` | reached it, non-2xx HTTP — `#status`, `#body`, `#headers`, `#retry_after`, `#throttled?` |
|
|
35
|
+
| `QueryError` | 200 body with top-level GraphQL errors — `#errors`, `#data`, `#extensions`, `#codes`, `#throttled?` |
|
|
33
36
|
| `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` |
|
|
37
|
+
| `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, wrong number of @oneOf fields — `#field`, `#struct` |
|
|
35
38
|
| `ValidationError` | build time: the query didn't validate against the schema |
|
|
39
|
+
| `ConfigurationError` | setup judged against your schema — which Ruby schema serves which subgraph (`Testing::Router`, `federation:diff`) |
|
|
40
|
+
| `Testing::Unplannable` | the local test router won't plan this operation — `#category`, `#detail` |
|
|
41
|
+
|
|
42
|
+
An argument that is wrong *on its face* raises a plain `ArgumentError` instead
|
|
43
|
+
(`pool_size: must be >= 1`, `cast: must be a Symbol, Proc, :itself, or nil`),
|
|
44
|
+
like any Ruby method — a bug at the call site, not a condition to rescue. The
|
|
45
|
+
line is whether the library had to read your schema to reach the verdict: it
|
|
46
|
+
did for `ConfigurationError`, which is why a spec helper can rescue that one.
|
|
36
47
|
|
|
37
48
|
```ruby
|
|
38
49
|
begin
|
|
@@ -40,30 +51,22 @@ begin
|
|
|
40
51
|
rescue GraphWeaver::TransportError
|
|
41
52
|
retry # network blip
|
|
42
53
|
rescue GraphWeaver::ServerError => e
|
|
43
|
-
e.status >= 500 ? backoff : raise
|
|
54
|
+
e.throttled? || e.status >= 500 ? backoff : raise # a plain 4xx is our bug
|
|
44
55
|
rescue GraphWeaver::QueryError => e
|
|
45
|
-
e.
|
|
56
|
+
e.throttled? ? backoff : raise # the same question, asked of the errors array
|
|
46
57
|
end
|
|
47
58
|
```
|
|
48
59
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
tries: 5, # total attempts
|
|
56
|
-
backoff: :exponential, # or :linear, or ->(attempt) { seconds }
|
|
57
|
-
base: 0.5, max: 30, # seconds, clamped at max:
|
|
58
|
-
jitter: true, # randomize each delay by 50-100%
|
|
59
|
-
retry_codes: ["THROTTLED"], # also retry GraphQL errors by code
|
|
60
|
-
)
|
|
61
|
-
```
|
|
60
|
+
`#throttled?` deliberately spells the same on both: an API may say "slow
|
|
61
|
+
down" with a 429 or with a `THROTTLED` error in a 200 body, and a caller
|
|
62
|
+
shouldn't have to know which. It recognizes the codes the big graphs
|
|
63
|
+
actually send (`GraphWeaver::GraphQLError::THROTTLE_CODES` — Shopify's
|
|
64
|
+
`THROTTLED`, GitHub's `RATE_LIMITED`, and friends); pass that constant to
|
|
65
|
+
`Retry`'s `retry_codes:` instead of hand-writing the strings.
|
|
62
66
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
`retry_codes:`. Exhausting `tries:` re-raises the last error.
|
|
67
|
+
Or skip the hand-rolling: [`Retry`](transports.md#retries) wraps any client and
|
|
68
|
+
already defaults to exactly the policy above — transport failures always,
|
|
69
|
+
`ServerError` on 5xx plus 408/429, and GraphQL error codes you name.
|
|
67
70
|
|
|
68
71
|
**Top-level scalar variables** fail like any Ruby method call, *outside* the
|
|
69
72
|
hierarchy on purpose — passing the wrong Ruby type for a scalar kwarg is a
|
|
@@ -73,10 +76,9 @@ String"), a missing required one a plain `ArgumentError` ("missing keyword: :id"
|
|
|
73
76
|
|
|
74
77
|
**Input-object variables** are the caller-input case, so they're *inside* the
|
|
75
78
|
hierarchy. When you pass an input object as a hash (or struct) it's built
|
|
76
|
-
through the generated `coerce`, and
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
into a 422:
|
|
79
|
+
through the generated `coerce`, and anything wrong in there raises
|
|
80
|
+
`GraphWeaver::InputError` — one rescue point for turning invalid input into a
|
|
81
|
+
422:
|
|
80
82
|
|
|
81
83
|
```ruby
|
|
82
84
|
rescue GraphWeaver::InputError => e
|
|
@@ -94,8 +96,8 @@ Business/validation failures returned *as data* (Shopify-style `userErrors { fie
|
|
|
94
96
|
message code }`) aren't errors here — they're just fields you selected, so they
|
|
95
97
|
deserialize onto `response.data` like anything else and you inspect them there.
|
|
96
98
|
|
|
97
|
-
The one-shot `GraphWeaver.
|
|
98
|
-
the envelope, `
|
|
99
|
+
The one-shot `GraphWeaver.run` / `run!` mirror this: `run` returns
|
|
100
|
+
the envelope, `run!` the result-or-raise.
|
|
99
101
|
|
|
100
102
|
## Extending TransportError
|
|
101
103
|
|