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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1314 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -154
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -258
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -124
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +593 -334
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +9 -1
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +14 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +7 -0
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +794 -59
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +43 -8
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +160 -61
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +181 -21
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +28 -10
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +67 -14
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +389 -170
  62. 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
- 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.
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
- ## The workflow
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
- # 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
+ client = GraphWeaver::Testing.cassette("github", client: live)
16
+ result = RepoQuery.execute!(client:, owner: "dpep", name: "graph_weaver")
15
17
  ```
16
18
 
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:
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
- ```sh
25
- GRAPHWEAVER_RECORD=1 bundle exec rspec # every Cassette.use records afresh
26
- ```
24
+ ```sh
25
+ GRAPHWEAVER_RECORD=1 bundle exec rspec # every Testing.cassette records afresh
26
+ ```
27
27
 
28
- (`Testing.config.record = true` is the programmatic equivalent.)
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
- Replaying an unrecorded request raises `MissingRecording` with the query
31
- and the path — no silent fabrication.
34
+ ## Has a recording gone stale?
32
35
 
33
- ## Anonymization
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
- Anonymizing rewrites recorded values through the same engine
36
- [FakeClient](testing.md) uses, while preserving everything that makes
37
- the recording faithful:
44
+ ```sh
45
+ rake graph_weaver:cassettes:check
46
+ ```
38
47
 
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 |
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
- Three ways to run it:
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
- # 3. the whole cassette_dir at once
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 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.
95
+ Anonymization preserves shape, so an anonymized cassette still passes
96
+ `cassettes:check`.
68
97
 
69
- ## When to use what
98
+ ## Cassette or FakeClient?
70
99
 
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.
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
+ pointpagination 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 raises descends from `GraphWeaver::Error`, split by where
26
- it failed:
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 # retry 5xx; a 4xx is our bug
54
+ e.throttled? || e.status >= 500 ? backoff : raise # a plain 4xx is our bug
44
55
  rescue GraphWeaver::QueryError => e
45
- e.codes.include?("THROTTLED") ? backoff : raise
56
+ e.throttled? ? backoff : raise # the same question, asked of the errors array
46
57
  end
47
58
  ```
48
59
 
49
- Or skip the hand-rolling `Retry` wraps any transport with
50
- configurable retries:
51
-
52
- ```ruby
53
- transport = GraphWeaver::Retry.new(
54
- GraphWeaver::Transport::HTTP.new(url),
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
- Defaults match the rescue block above: transport failures always retry,
64
- `ServerError` only on 5xx (a 4xx is your bug retrying won't fix it;
65
- override with `retry_if:`), and GraphQL-level codes only when listed in
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 abovetransport 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 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:
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.execute` / `execute!` mirror this: `execute` returns
98
- the envelope, `execute!` the result-or-raise.
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