graph_weaver 0.5.0 → 0.5.1

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -1
  5. data/docs/cassettes.md +23 -3
  6. data/docs/errors.md +2 -0
  7. data/docs/federation.md +8 -7
  8. data/docs/generated_modules.md +2 -2
  9. data/docs/getting_started.md +1 -1
  10. data/docs/logging.md +1 -1
  11. data/docs/testing.md +8 -7
  12. data/docs/upgrading.md +34 -12
  13. data/graph_weaver.gemspec +16 -2
  14. data/lib/generators/graph_weaver/install_generator.rb +15 -15
  15. data/lib/graph_weaver/client.rb +6 -2
  16. data/lib/graph_weaver/codegen/aliases.rb +10 -4
  17. data/lib/graph_weaver/codegen/emit.rb +11 -3
  18. data/lib/graph_weaver/codegen/enum_type.rb +1 -3
  19. data/lib/graph_weaver/codegen/scalar_type.rb +5 -4
  20. data/lib/graph_weaver/codegen/type_helpers.rb +1 -3
  21. data/lib/graph_weaver/codegen.rb +99 -22
  22. data/lib/graph_weaver/errors.rb +27 -6
  23. data/lib/graph_weaver/federation.rb +4 -17
  24. data/lib/graph_weaver/parsing.rb +1 -9
  25. data/lib/graph_weaver/rspec.rb +13 -7
  26. data/lib/graph_weaver/schema_loader.rb +30 -6
  27. data/lib/graph_weaver/schemas.rb +4 -2
  28. data/lib/graph_weaver/tasks.rb +24 -21
  29. data/lib/graph_weaver/testing/cassette.rb +89 -20
  30. data/lib/graph_weaver/testing/coverage.rb +7 -12
  31. data/lib/graph_weaver/testing/failure.rb +4 -2
  32. data/lib/graph_weaver/testing/fake_client.rb +1 -1
  33. data/lib/graph_weaver/testing/router.rb +68 -47
  34. data/lib/graph_weaver/testing/subgraphs.rb +11 -7
  35. data/lib/graph_weaver/testing.rb +8 -2
  36. data/lib/graph_weaver/version.rb +1 -1
  37. data/lib/graph_weaver.rb +39 -14
  38. metadata +8 -9
  39. data/CLAUDE.md +0 -161
  40. data/DECISIONS.md +0 -309
  41. data/Makefile +0 -23
  42. data/NOTES.md +0 -182
  43. data/PLAN.md +0 -115
  44. data/REVIEW.md +0 -946
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f9913a2f1dba64e814682cd54d4f6f3da0696071e1f20f0045aaf585eb8fe4b
4
- data.tar.gz: 57f2f2b701f09c52235dd0acedee77891b8ed0a570f28a086e140ef3c7a7c57b
3
+ metadata.gz: af7804f6f324bf878d1b11ba4da8e2adc8e2ea4c055fb80a703e91d8ca4eb3cd
4
+ data.tar.gz: fe6fd6340203a8937c836281342c0efcdb701203295e8a4fcf37260892a5afec
5
5
  SHA512:
6
- metadata.gz: 469bff9b186698995c51ed7997957ecccb613c70da29ea0a9788e9597cb9c4cb4a679ceb95cd23aade6475159dbbd47fa7f13c5073ee5a28b5f3f2b0e4dc3483
7
- data.tar.gz: 0144261b27419fd180a96193c0cfc1a6bc2d023fbb9d2cbb55e45f9608118385b91d4e2ee5a453a21c0022a0804aa88e598981c925322b3519144bfd246d0c45
6
+ metadata.gz: 1e5bd51d719bec6ddb81a75bab2bc68bb7b7ab9fd0c656fbbb0fd2497433ebe733893bda80e7d5dc1b4735487c1d787c5d10156dd7b0f8634e6740907af4fe01
7
+ data.tar.gz: 4fd87cae3d8c320c9dfb61b08f4464d3d2978c831a35c0b26c4262b27929e90da52e1a528d411b6b1689d45e24edca5d2727c3e9dc42bb5bf6202589d0973d71
data/CHANGELOG.md CHANGED
@@ -1,3 +1,131 @@
1
+ ### v0.5.1 (2026-09-07)
2
+ - **A union's catch-all struct keeps the fields an interface fragment asked
3
+ for.** `... on Named { name }` under a union types `name` on every member the
4
+ query named, but the catch-all — the member it didn't name, including one the
5
+ schema grows later — carried only `__typename`, so a `name` the server
6
+ actually sent was dropped on the floor. Those fields are `T.nilable`: the
7
+ member that arrives needn't implement the interface. **Regenerate** to pick
8
+ it up.
9
+ - **`schema_stale?` now recognises the drift a graphql-ruby server reports.**
10
+ It matched Apollo's one flat code plus a message pattern, but graphql-ruby —
11
+ which this library ships as its in-process client — names the rule that fired
12
+ in `extensions.code`. So a renamed type, a removed argument, an argument that
13
+ became required and four other drift shapes all raised without the one
14
+ sentence telling you to refresh the dump and regenerate, and `to_h` reported
15
+ `"schema_stale": false`.
16
+ - **A cast failure keeps the server's own explanation.** A field that came back
17
+ null *with a reason* — a permission rule, a partial outage — raised only
18
+ Sorbet's nil complaint, because the generated envelope built `data` before it
19
+ read `errors`. The reason is now in the message. **Regenerate** to pick it up.
20
+ - **Cassette anonymization missed `errors` and `extensions`.** It walked
21
+ `data` and nothing else, so a rejected request's error message — which
22
+ routinely quotes the input that caused it — and whatever the server hangs
23
+ off `extensions` went to disk verbatim, under a flag that says real data
24
+ never reaches it. **Re-run `rake graph_weaver:cassettes:anonymize` on any
25
+ cassette you've already committed**, or re-record it. Neither key has a
26
+ schema behind it, so both are now walked by shape: keys, nesting, nulls and
27
+ booleans survive and every string and number is replaced, with `path`,
28
+ `locations` and an error's `extensions.code` kept because they describe the
29
+ request rather than the data.
30
+ - **Recording flags a credential that lands in the file.** A cassette gets
31
+ committed as written, and its query and variables can't be anonymized —
32
+ they're the key replay matches on. So the bytes headed for disk are checked
33
+ for the shapes that are unmistakable (a JWT, `AKIA…`, `ghp_…`, `xox…`,
34
+ `sk_live_…`, a PEM block, a `Bearer` header) and named on stderr once per
35
+ cassette. Nothing is rewritten; a password like `hunter2` has no shape, so a
36
+ quiet run still isn't a clean bill of health.
37
+ - **An anonymized cassette no longer drops a key selected twice.** The
38
+ anonymizer kept the last occurrence's sub-selection where codegen merges
39
+ them, so `a { x } a { y }` came back holding only `y` — a recording that
40
+ then failed its own `cassettes:check`.
41
+ - **Three generated types disagreed with the schema.** `srb tc` proves the
42
+ generated code is self-consistent, not that it is true, so each of these was a
43
+ lie the typechecker endorsed. **Regenerate** to pick them up:
44
+ - A field reached only through a `@skip`/`@include`-guarded occurrence, where
45
+ the same key is also selected unguarded, was typed as guaranteed. The server
46
+ legitimately omits it, so `from_h` raised `key not found` on a valid
47
+ response — and a union whose `__typename` arrived that way crashed its own
48
+ dispatch. Those children are nilable now, and the `__typename` case refuses
49
+ at generation as it already did for the plainer spelling.
50
+ - A narrowed abstract inside a non-null list made the *list* nilable
51
+ (`[Thing!]!` with `... on Widget` → `T.nilable(T::Array[…])`). Only the
52
+ elements can be nil; the array is always there.
53
+ - An input field the schema gives a default is optional, but a non-null one
54
+ emitted `const :x, String, default: nil` — so `x.upcase` typechecked and
55
+ was a `NoMethodError`.
56
+ - **`rake graph_weaver:federation:coverage` no longer counts a subgraph as
57
+ served here when two loaded schemas fit it.** `Router.new` refuses that case
58
+ — picking either would be a coin flip — so the report was promising a run
59
+ the suite can't have. It now lands in the "nothing here serves what they
60
+ reach" list, whose advice (name a schema for those subgraphs) is the fix.
61
+ - **A supergraph that renamed the join spec is now refused instead of read as
62
+ empty.** `@link(url: ".../join/v0.3", as: "j")` spells every marker `j__`,
63
+ which the routing table doesn't follow — so it reported a composed graph with
64
+ *no subgraphs* and nothing unsupported, and `Testing::Router` built happily on
65
+ top of that. It now lands in `unsupported`, so the router and
66
+ `federation:coverage` refuse at construction and say why.
67
+ - **`rake graph_weaver:cassettes:anonymize` now finds an SDL schema dump.** It
68
+ opened `GraphWeaver.schema_path` directly instead of locating the dump the
69
+ way every sibling task does, so an app whose committed dump is
70
+ `schema.graphql` (what the generator writes for a supergraph) got a raw
71
+ `Errno::ENOENT`. A missing dump now says so.
72
+ - **`rake graph_weaver:queries:check` now authenticates with the token your
73
+ app uses.** It re-introspects the url the dump records, but read
74
+ `GRAPHWEAVER_AUTH` rather than the ENV var the dump named — so an app
75
+ installed with `--auth MY_TOKEN` reached its private API while the check
76
+ 401'd. Every re-introspection now goes through `SchemaLoader.source_transport`,
77
+ which is also now public.
78
+ - **A `graphql: :router` example now starts from the same fabricated data
79
+ whether it runs alone or after two hundred others.** The router is built once
80
+ for the suite, and a faked subgraph kept fabricating from wherever the last
81
+ example left its sequence — so `rspec spec/foo_spec.rb:12` disagreed with the
82
+ same example in a full run, which is the opposite of what `--seed` promises.
83
+ `Router#reset!` is the example boundary (`reset_trace` still clears only the
84
+ trace); the rspec tag calls it for you.
85
+ - **An override key given as a Symbol now pins.** `graphql_fake(overrides: { name: "Ada" })`
86
+ validated clean and silently fabricated a random value, because lookup is by
87
+ String — the exact silent-green failure the override validation exists to
88
+ prevent.
89
+ - **A helper no longer contradicts `config.default_mode`.** With
90
+ `default_mode = :fake`, an untagged example calling `graphql_in_process`
91
+ raised *"this example is tagged graphql: :fake"* — naming a tag that wasn't
92
+ there, and blocking the untagged form the docs recommend. Only an explicit
93
+ tag can disagree with a helper now.
94
+ - **A variable default that isn't a Boolean no longer reaches a subgraph.** An
95
+ enum or input-object default is a parser AST node; sending one put a
96
+ back-pointer to the lexer on the wire, and an input object raised
97
+ `NoMethodError` outside any rescue. Only `@skip`/`@include` read these, and
98
+ they read Booleans; a subgraph applies its own defaults from the variable
99
+ declaration it already receives.
100
+ - The local router asks the schema for one type by name rather than rebuilding
101
+ the whole type map, at four sites — one of them per response row. On a
102
+ 1200-type supergraph a stitched query over 200 rows was **5x** slower than it
103
+ needed to be; the cost is now flat in schema size.
104
+ - **`graphql: :in_process` ran the committed schema dump instead of your live
105
+ schema class.** A dump loads as an anonymous `GraphQL::Schema` subclass, which
106
+ looks like a runnable class and has no resolvers — so every app that followed
107
+ `getting_started.md` (the generator always commits a dump) got a graphql-ruby
108
+ 500 blaming its own resolver, and the error that was written to explain this
109
+ was unreachable. Nothing to do; if you worked around it with
110
+ `Testing.config.schema = MySchema`, that still works and still wins.
111
+ - **`GraphWeaver.parse(query:)` accepts a `Pathname`.** `schema:` already did,
112
+ so `GraphWeaver.parse(schema: Rails.root.join("schema.graphql"), query:
113
+ Rails.root.join("app/graphql/queries/person.graphql"))` died on the query
114
+ argument with `undefined method 'end_with?'`. Same for `client.parse(path)`.
115
+ - **`rake graph_weaver:cassettes:check` and `:anonymize` now find cassettes
116
+ from any working directory.** Both read `config.cassette_dir` raw, while
117
+ `Cassette.new` resolves it against `Rails.root` — so run from anywhere but
118
+ the app root, `check` aborted with "this checked nothing, so it proved
119
+ nothing" and `anonymize` silently did nothing, both while a spec run found
120
+ the same files fine. They now use the same resolution the recordings do.
121
+ - **An `extend_type(alias:)` name a struct instance already answers to now
122
+ refuses** instead of silently overriding it. `alias: { hash: "…" }` emitted
123
+ `def hash`, which breaks every `Hash` and `Set` holding that struct;
124
+ `inspect`, `to_s`, `method` and `class` were the same story. A *wire field*
125
+ by any of those names was already refused, so this is the same rule reaching
126
+ the same case from the other side. **If generation now refuses an alias you
127
+ had**, rename it — the accessor it generated was overriding a Ruby method.
128
+
1
129
  ### v0.5.0 (2026-09-07)
2
130
  - **`graphql_in_process(SomeSchema)`** runs one example against that schema
3
131
  class's real resolvers — the sibling of `graphql_fake`, and how a federated
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- graph_weaver (0.5.0)
4
+ graph_weaver (0.5.1)
5
5
  graphql (>= 2.6.7)
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.5.0)
179
+ graph_weaver (0.5.1)
180
180
  graphql (2.6.10) sha256=9b7c8633767f516ff9d48a8d6305b2a00a2101c82aa871b92e69086944f9f83e
181
181
  i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
182
182
  io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
data/README.md CHANGED
@@ -97,7 +97,7 @@ fetches:
97
97
  The trace is the query plan: every node at a level in one `_entities` call, so two
98
98
  products cost one fetch. Anything it can't answer *faithfully* it refuses at plan
99
99
  time rather than guessing — and it's diffed against a real `@apollo/gateway` over
100
- the same supergraph, currently 42 queries identical, 1 refused, 0 wrong
100
+ the same supergraph, currently 72 queries identical, 2 refused, 0 wrong
101
101
  ([`spec/integration/router_parity_spec.rb`](spec/integration/router_parity_spec.rb)).
102
102
  See [federation](docs/federation.md).
103
103
 
data/docs/cassettes.md CHANGED
@@ -82,9 +82,29 @@ preserving everything that makes the recording faithful:
82
82
  | enums, booleans, `__typename` | numbers, dates |
83
83
  | id *relationships* (same original id → same fake id) | the id values themselves |
84
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.
85
+ `data` is walked against the schema — which is why it needs one, to know which
86
+ values are enums, dates, ids. `errors` and `extensions` have none behind them,
87
+ so they're walked by shape instead: keys, nesting and structure survive, every
88
+ string and number is replaced. `path`, `locations` and an error's
89
+ `extensions.code` are kept, because they describe the request rather than the
90
+ data — and call sites branch on `code` the way they branch on an enum.
91
+
92
+ **The query and its variables are not anonymized.** They're the key replay
93
+ matches on, so scrubbing them would make the recording unfindable. A mutation's
94
+ input is often the sensitive part, so record with placeholder variables, or
95
+ don't record that request.
96
+
97
+ Recording says so when the bytes it wrote look like a credential:
98
+
99
+ ```
100
+ graph_weaver: spec/cassettes/github.yml contains a JWT, a GitHub token — a
101
+ cassette is committed as written, so review this one first. …
102
+ ```
103
+
104
+ It recognizes tokens by shape — a JWT, `AKIA…`, `ghp_…`, `xox…`, `sk_live_…`, a
105
+ PEM block, a `Bearer` header — which is every credential that is unmistakable
106
+ and nothing else. A password like `hunter2` has no shape, so a quiet run is not
107
+ a clean bill of health: **read a cassette before committing it.**
88
108
 
89
109
  For cassettes recorded before the flag was on:
90
110
 
data/docs/errors.md CHANGED
@@ -36,8 +36,10 @@ subclass says where it failed:
36
36
  | `TypeError` | the response wouldn't cast into the generated structs — `#struct`, `#cause` |
37
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` |
38
38
  | `ValidationError` | build time: the query didn't validate against the schema |
39
+ | `Codegen::Aliases::UnknownSegment` | build time: an [`alias:`](generated_modules.md#flat-accessors-with-alias) path names a field no type here has — a typo, so `optional: true` won't skip it |
39
40
  | `ConfigurationError` | setup judged against your schema — which Ruby schema serves which subgraph (`Testing::Router`, `federation:diff`) |
40
41
  | `Testing::Unplannable` | the local test router won't plan this operation — `#category`, `#detail` |
42
+ | `Testing::MissingRecording` | a [cassette](cassettes.md) holds no entry for this request — the message prints the variables, and the ones it did record |
41
43
 
42
44
  An argument that is wrong *on its face* raises a plain `ArgumentError` instead
43
45
  (`pool_size: must be >= 1`, `cast: must be a Symbol, Proc, :itself, or nil`),
data/docs/federation.md CHANGED
@@ -67,7 +67,7 @@ metadata.
67
67
 
68
68
  The derivation is diffed against Apollo's own `composeServices` +
69
69
  `toAPISchema()` in
70
- [`spec/integration/api_schema_spec.rb`](../spec/integration/api_schema_spec.rb),
70
+ [`spec/integration/api_schema_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/integration/api_schema_spec.rb),
71
71
  over composed supergraphs carrying `@interfaceObject`, `@join__unionMember`,
72
72
  `@join__enumValue` and an aliased `@inaccessible` — identical in each.
73
73
 
@@ -224,10 +224,10 @@ is which subgraphs a code path touched and a service object rarely runs one
224
224
  query. The rspec tag resets it before each example; outside rspec call
225
225
  `router.reset_trace` around the code path you're measuring.
226
226
 
227
- **[`examples/federation.rb`](../examples/federation.rb)** is the whole shape
227
+ **[`examples/federation.rb`](https://github.com/dpep/graph_weaver/blob/main/examples/federation.rb)** is the whole shape
228
228
  in one runnable file, and the only example that needs no network: three real
229
229
  subgraphs, a boundary-crossing query through a generated module, the trace,
230
- and a refusal. [`spec/router_spec.rb`](../spec/router_spec.rb) is the
230
+ and a refusal. [`spec/router_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/router_spec.rb) is the
231
231
  exhaustive reference — every plan shape, every refusal, the partly-local
232
232
  graph and the `:fake` opt-in, each as a named example.
233
233
 
@@ -435,7 +435,8 @@ $ rake graph_weaver:federation:coverage SUPERGRAPH=supergraph.graphql
435
435
  the graph — could the router split this query faithfully at all. *Servable
436
436
  here* is what your suite actually gets: every subgraph that plan reaches is one
437
437
  this process serves. In a [partly-local supergraph](#a-supergraph-only-partly-local)
438
- they differ, and the plannable number alone reads optimistically:
438
+ they differ, and the plannable number alone reads optimistically — sketched
439
+ here on a graph whose `billing` and `shipping` run elsewhere:
439
440
 
440
441
  ```
441
442
  5/5 queries plannable locally (100%), 2 servable here
@@ -454,14 +455,14 @@ the second number and says it counted planning only. The subgraph line says
454
455
  which subgraphs each query touches, so a graph whose queries all sit in one is
455
456
  visibly a different situation from one that stitches everywhere. Anything
456
457
  refused is listed after it, grouped by category, so one glance says whether the
457
- gap is one construct or many. The runs above are against the demo graph in
458
+ gap is one construct or many. The first run above is the demo graph in
458
459
  `spec/support/federation`, not a real app's mix.
459
460
 
460
461
  ### How the refusals are kept honest
461
462
 
462
463
  A double that quietly answered *differently* from the router would be worse
463
464
  than no double at all, so
464
- [`spec/integration/router_parity_spec.rb`](../spec/integration/router_parity_spec.rb)
465
+ [`spec/integration/router_parity_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/integration/router_parity_spec.rb)
465
466
  serves the demo subgraphs over HTTP, boots a real `@apollo/gateway` on the same
466
467
  supergraph, and runs the whole corpus through both. Three outcomes, one of them
467
468
  a defect: match, refuse, or answer differently — and the spec fails on the
@@ -547,4 +548,4 @@ And a `@key(..., resolvable: false)` declares a key this subgraph does *not*
547
548
  answer for, so it builds nothing. Key fields typed as scalars get their
548
549
  registered Ruby type; anything else (a nested selection) is an open `Hash` the
549
550
  runtime narrows. Every shape above is a named example in
550
- [`spec/federation_spec.rb`](../spec/federation_spec.rb).
551
+ [`spec/federation_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/federation_spec.rb).
@@ -409,7 +409,7 @@ The name is a function of that field's own position and nothing else, which is
409
409
  the property that matters when generated code is checked in and referenced from
410
410
  app code: **adding, removing, or reordering an unrelated selection can never
411
411
  rename a struct you already use.**
412
- [`spec/naming_spec.rb`](../spec/naming_spec.rb) asserts each of those three
412
+ [`spec/naming_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/naming_spec.rb) asserts each of those three
413
413
  edits leaves the name alone.
414
414
 
415
415
  The key is used verbatim — no pluralization heuristics, so a list field `pets`
@@ -521,7 +521,7 @@ the generated struct in your own file and add sig'd methods; Sorbet merges the
521
521
  bodies.
522
522
 
523
523
  Every form above, and every error it raises, is a named example in
524
- [`spec/aliases_spec.rb`](../spec/aliases_spec.rb).
524
+ [`spec/aliases_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/aliases_spec.rb).
525
525
 
526
526
  ## Clients
527
527
 
@@ -41,7 +41,7 @@ initializer that fits:
41
41
 
42
42
  | flag | |
43
43
  |---|---|
44
- | `--auth` | name of the ENV var holding the auth token — default `GRAPHWEAVER_AUTH`. Url only. Name a different one and the initializer follows, but `schema:refresh`/`schema:diff` still read `GRAPHWEAVER_AUTH` set both |
44
+ | `--auth` | name of the ENV var holding the auth token — default `GRAPHWEAVER_AUTH`. Url only. The name is recorded into the dump, so `schema:refresh`/`schema:diff`/`queries:check` read the same one the initializer does |
45
45
  | `--no-schema` | skip writing the dump; `rake graph_weaver:schema:refresh URL=...` does it later |
46
46
 
47
47
  Re-running is safe — every file goes through the usual Rails conflict
data/docs/logging.md CHANGED
@@ -52,7 +52,7 @@ response. Its payload carries:
52
52
  | Key | |
53
53
  |-----|--|
54
54
  | `:url` | the endpoint — nil in-process |
55
- | `:schema` | the schema class, in-process only |
55
+ | `:schema` | the schema class's name, in-process only — a String, so a payload logs as it stands |
56
56
  | `:operation` | the operation name sent with the request (a generated module always has one) — what a trace keys on |
57
57
  | `:status` | the HTTP status, added once the response lands |
58
58
 
data/docs/testing.md CHANGED
@@ -62,7 +62,7 @@ DashboardQuery.execute!.me.username
62
62
  ```
63
63
 
64
64
  All three modes, tagged and running end to end, are
65
- [`spec/rspec_spec.rb`](../spec/rspec_spec.rb) — the reference for anything
65
+ [`spec/rspec_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/rspec_spec.rb) — the reference for anything
66
66
  this page leaves out.
67
67
 
68
68
  ## Nothing to configure
@@ -72,9 +72,9 @@ looked for — rather than guessing**:
72
72
 
73
73
  - **the schema** is `config.schema` if you set one, else the committed dump
74
74
  at `GraphWeaver.schema_path`, else the schema `GraphWeaver.client` talks to.
75
- (`config.schema` refuses a federation *subgraph* class: fakes are fabricated
76
- against it too, so one subgraph would be a fraction of the graph. A
77
- federated graph has no one schema class — that's what `:router` is.)
75
+ (A federated app has no one schema class, so `config.schema` is the whole
76
+ graph's shape and an example names the subgraph whose resolvers it wants
77
+ see `graphql_in_process` below.)
78
78
  - **`:in_process`** needs the live schema *class*, since only that has
79
79
  resolvers: the one your client already runs in-process, else the loaded
80
80
  class that defines everything the schema declares — the same
@@ -310,9 +310,10 @@ them offline, above the transport (no HTTP interception):
310
310
  client = GraphWeaver::Testing.cassette("github", client: live)
311
311
  ```
312
312
 
313
- Re-record with `GRAPHWEAVER_RECORD=1`, and set `config.anonymize = true` so
314
- real data never lands in a committed file — the full workflow guide is
315
- **[cassettes](cassettes.md)**.
313
+ Re-record with `GRAPHWEAVER_RECORD=1`, and set `config.anonymize = true` so the
314
+ response is scrubbed on its way to disk — the query and its variables are the
315
+ replay key and are recorded verbatim, so read a cassette before committing it.
316
+ The full workflow guide is **[cassettes](cassettes.md)**.
316
317
 
317
318
  ## Real resolvers, one schema — `graphql: :in_process`
318
319
 
data/docs/upgrading.md CHANGED
@@ -1,16 +1,27 @@
1
1
  # Upgrading to 0.5.0
2
2
 
3
- 0.5.0 is one large breaking release. Almost all of it is caught mechanically
4
- the work is running three commands and following what they tell you.
3
+ 0.5.0 is one large breaking release. Almost all of it is caught mechanically,
4
+ in this order:
5
5
 
6
6
  ```sh
7
- rake graph_weaver:generate # 1. regenerate; the emitted call shape changed
8
- srb tc # 2. every call site that moved is now a type error
9
- rake graph_weaver:verify # 3. fails until the tree is regenerated
7
+ # 1. rename the path settings first — generate won't load without them
8
+ # (queries_path -> queries_paths, generated_path -> generated_paths,
9
+ # fragments_path -> fragments_paths; see "One plurality rule" below)
10
+
11
+ bundle exec tapioca gem graph_weaver # 2. regenerate the RBI
12
+ rake graph_weaver:generate # 3. the emitted call shape changed
13
+ srb tc # 4. every call site that moved is an error
14
+ rake graph_weaver:verify # 5. fails until the tree is regenerated
10
15
  ```
11
16
 
12
- Generated code is `# typed: strict`, so step 2 finds the call sites for you.
13
- The rest of this page is what a typechecker can't see.
17
+ **Step 2 is not optional.** Against the 0.4.6 RBI, `srb tc` reports errors
18
+ pointing into your `generated/` directory `QueryModule`, `client_for`,
19
+ `check_envelope!` — which read as though codegen emitted broken Ruby. It
20
+ didn't; sorbet is checking new generated code against the old gem's types.
21
+ Regenerate the RBI and what remains is only your own call sites.
22
+
23
+ Generated code is `# typed: strict`, so step 4 finds those for you. The rest of
24
+ this page is what a typechecker can't see.
14
25
 
15
26
  ## `execute` means one thing now
16
27
 
@@ -30,7 +41,9 @@ GraphWeaver.run(source, query, **vars) # after
30
41
  ```
31
42
 
32
43
  **This one is worth grepping for.** `Client#execute` still exists, so a stale
33
- call fails at runtime rather than at typecheck: `rg '\.execute!?\(' --type ruby`
44
+ call fails at runtime rather than at typecheck as do `GraphWeaver.execute`
45
+ and `GraphWeaver.reset_scalars!`, which are simply gone and will not be flagged
46
+ until the RBI is regenerated (step 2): `rg '\.execute!?\(' --type ruby`
34
47
  and check each hit is passing `variables:` rather than loose kwargs.
35
48
 
36
49
  A generated module takes its per-call client as a **keyword**:
@@ -69,16 +82,25 @@ GraphWeaver::Codegen.reset_scalars! # after (also reset_enums!, clear_scalar
69
82
 
70
83
  | before | after |
71
84
  |---|---|
72
- | `response.ok?` | `response.success?` |
73
85
  | `Testing.config.auto_fake = true` | `Testing.config.default_mode = :fake` |
74
86
  | `register_scalar(…, coerce: :to_s)` | `coerce: true`, or a `cast:`/`serialize:` pair |
75
87
  | a mutation's `…Query` module | `…Mutation` |
88
+ | `graphql: :none` (rspec tag) | `graphql: false` |
89
+
90
+ **The shared types module was three, and is now one.** `GraphQLInputs`,
91
+ `GraphQLEnums` and `GraphQLUnions` are all `GraphQLTypes`, and the files move
92
+ with them — `generated/inputs/` becomes `generated/types/`. The three settings
93
+ that named them (`inputs_module=`, `enums_module=`, `unions_module=`) are one
94
+ `types_module=`. Regenerating writes the new tree; delete the old directory,
95
+ which pruning leaves behind empty.
76
96
 
77
- `Testing::LiveSchema` is gone. If your client points at a different API than the
78
- schema class your specs run in-process, name it once:
97
+ If your specs run one schema class in-process while your client points at a
98
+ different API, name it per example, since a federated suite runs more than
99
+ one:
79
100
 
80
101
  ```ruby
81
- GraphWeaver::Testing.config.schema = MySchema
102
+ graphql_in_process(MySchema) # in the example
103
+ GraphWeaver::Testing.config.schema = MySchema # or once, for the whole suite
82
104
  ```
83
105
 
84
106
  ## Registering from Rails
data/graph_weaver.gemspec CHANGED
@@ -7,13 +7,27 @@ Gem::Specification.new do |s|
7
7
  s.description = "Your .graphql files, compiled into Sorbet types — and the fakes to test them."
8
8
  # ".yardopts" explicitly: `git ls-files *` skips dotfiles, and
9
9
  # rubydoc.info needs it shipped to render docstrings as markdown
10
- s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' ':!:examples'`.split("\n") + [".yardopts"]
10
+ # CLAUDE.md/PLAN.md/REVIEW.md/NOTES.md/DECISIONS.md are written for whoever
11
+ # works on the gem, not whoever installs it — and REVIEW.md carries examples
12
+ # from before the API it describes was rewritten
13
+ s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' ':!:examples' \
14
+ ':!:CLAUDE.md' ':!:PLAN.md' ':!:REVIEW.md' ':!:NOTES.md' \
15
+ ':!:DECISIONS.md' ':!:Makefile' ':!:design'`.split("\n") + [".yardopts"]
11
16
  s.homepage = "https://github.com/dpep/graph_weaver"
12
17
  s.license = "MIT"
13
18
  s.name = "graph_weaver"
14
- s.summary = "GraphWeaver"
19
+ # rubygems.org shows summary as the headline, description below it
20
+ s.summary = "A typed GraphQL client for Ruby"
15
21
  s.version = GraphWeaver::VERSION
16
22
 
23
+ s.metadata = {
24
+ "bug_tracker_uri" => "#{s.homepage}/issues",
25
+ "changelog_uri" => "#{s.homepage}/blob/main/CHANGELOG.md",
26
+ "documentation_uri" => "#{s.homepage}/tree/main/docs",
27
+ "rubygems_mfa_required" => "true",
28
+ "source_code_uri" => s.homepage,
29
+ }
30
+
17
31
  s.required_ruby_version = ">= 3.3"
18
32
 
19
33
  # 2.6.7 fills defaulted directive arguments when building from SDL
@@ -31,17 +31,14 @@ module GraphWeaver
31
31
  argument :source, type: :string, banner: "SOURCE",
32
32
  desc: "what you'd pass to GraphWeaver.new: an endpoint url, a graphql-ruby schema class, or a schema dump path"
33
33
 
34
+ # the default is SchemaLoader's, not one restated here — an --auth the
35
+ # generator omits from the dump is one the schema tasks then can't find
34
36
  class_option :auth, type: :string,
35
- desc: "name of the ENV var holding the auth token (url only) — default GRAPHWEAVER_AUTH"
37
+ desc: "name of the ENV var holding the auth token (url only) — " \
38
+ "default #{GraphWeaver::SchemaLoader::DEFAULT_AUTH_ENV}"
36
39
  class_option :schema, type: :boolean, default: true,
37
40
  desc: "write the schema dump codegen reads"
38
41
 
39
- # a Ruby constant path names a schema class; anything that is neither
40
- # this nor a url is taken as a path to a dump
41
- CONSTANT = /\A[A-Z]\w*(::[A-Z]\w*)*\z/
42
-
43
- DEFAULT_AUTH = "GRAPHWEAVER_AUTH"
44
-
45
42
  # Before anything is written: a mistyped source or a flag that doesn't
46
43
  # apply to it is a mistake in the command just typed, so say so there
47
44
  # rather than at boot, three files later.
@@ -140,14 +137,16 @@ module GraphWeaver
140
137
  end
141
138
  end
142
139
 
143
- # Which of GraphWeaver.new's source forms this is the url test is
144
- # its own, so the generator and the client can't disagree about what
145
- # counts as one.
140
+ # Which of GraphWeaver.new's source forms this is. Neither test is its
141
+ # own a url is whatever the client calls one, a constant path whatever
142
+ # codegen will spell — so the generator can't disagree with either about
143
+ # what it just wrote an initializer for. Anything that is neither is
144
+ # taken as a path to a dump.
146
145
  def form
147
146
  @form ||=
148
147
  if source.match?(GraphWeaver::Client::URL)
149
148
  :url
150
- elsif source.match?(CONSTANT)
149
+ elsif source.match?(GraphWeaver::Codegen::CONSTANT_NAME)
151
150
  :schema_class
152
151
  else
153
152
  :path
@@ -174,7 +173,7 @@ module GraphWeaver
174
173
  # writes the conventional one.
175
174
  def schema_path = (form == :path) ? source : GraphWeaver.schema_path
176
175
 
177
- def auth_var = options[:auth] || DEFAULT_AUTH
176
+ def auth_var = options[:auth] || GraphWeaver::SchemaLoader::DEFAULT_AUTH_ENV
178
177
 
179
178
  def refresh_command
180
179
  (form == :url) ? "rake graph_weaver:schema:refresh" : "rails g graph_weaver:install #{source}"
@@ -243,15 +242,16 @@ module GraphWeaver
243
242
  end
244
243
 
245
244
  # fragments are in documents: too — without them an editor reports
246
- # `Unknown fragment` on any query that spreads a shared one
245
+ # `Unknown fragment` on any query that spreads a shared one. The glob is
246
+ # codegen's, so the editor validates exactly the files codegen reads.
247
247
  def editor_config
248
248
  <<~YAML
249
249
  # Autocomplete and validation for .graphql files in VS Code / RubyMine.
250
250
  # https://github.com/dpep/graph_weaver/blob/main/docs/editors.md
251
251
  schema: #{schema_path}
252
252
  documents:
253
- - #{GraphWeaver.queries_paths.first}/**/*.{graphql,gql}
254
- - #{GraphWeaver.fragments_paths.first}/**/*.{graphql,gql}
253
+ - #{File.join(GraphWeaver.queries_paths.first, GraphWeaver::Codegen::DOCUMENT_GLOB)}
254
+ - #{File.join(GraphWeaver.fragments_paths.first, GraphWeaver::Codegen::DOCUMENT_GLOB)}
255
255
  YAML
256
256
  end
257
257
  end
@@ -36,12 +36,16 @@ class GraphWeaver::Client
36
36
 
37
37
  URL = %r{\Ahttps?://}i
38
38
 
39
+ # refused from two branches — a url source, and a schema source with
40
+ # nothing to hand a context to — so the two can't word it differently
41
+ CONTEXT_IN_PROCESS = "context: applies to a schema class executing in-process"
42
+
39
43
  def initialize(source, auth: nil, headers: {}, retries: false, transport: nil, cache: nil, ttl: nil,
40
44
  open_timeout: nil, read_timeout: nil, context: nil, &middleware)
41
45
  check_source!(source)
42
46
 
43
47
  if source.is_a?(String) && source.match?(URL)
44
- raise ArgumentError, "context: applies to a schema class executing in-process" if context
48
+ raise ArgumentError, CONTEXT_IN_PROCESS if context
45
49
 
46
50
  built = build_transport(source, auth:, headers:, kind: transport, open_timeout:, read_timeout:, &middleware)
47
51
  @transport = wrap_retries(built, retries)
@@ -65,7 +69,7 @@ class GraphWeaver::Client
65
69
  if context && !(source.is_a?(Module) && transport.nil?)
66
70
  # nothing would ever read it — a dump has no resolvers, and an
67
71
  # explicit transport carries its own
68
- raise ArgumentError, "context: applies to a schema class executing in-process"
72
+ raise ArgumentError, CONTEXT_IN_PROCESS
69
73
  end
70
74
 
71
75
  # InProcess adds context:, logging and branded errors to the bare
@@ -56,9 +56,14 @@ class GraphWeaver::Codegen
56
56
  "#{@module_name}: #{message}"
57
57
  end
58
58
 
59
+ # An alias emits a plain instance method, so it is held to the same bar
60
+ # as a wire field's prop: a name the struct already answers to would be
61
+ # silently overridden, and `hash` or `inspect` breaks the object rather
62
+ # than the file.
59
63
  def check_alias_name!(node, name)
60
64
  taken = node.fields.any? { |f| f.prop == name } ||
61
- ALIAS_RESERVED.include?(name) || RUBY_KEYWORDS.include?(name)
65
+ STRUCT_METHODS.include?(name) || ALIAS_RESERVED.include?(name) ||
66
+ RUBY_KEYWORDS.include?(name)
62
67
  return unless taken
63
68
 
64
69
  raise GraphWeaver::Error,
@@ -70,9 +75,10 @@ class GraphWeaver::Codegen
70
75
  GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
71
76
  end
72
77
 
73
- # methods every generated struct already answers to; Ruby keywords are
74
- # checked alongside (RUBY_KEYWORDS is defined by the class this mixes into)
75
- ALIAS_RESERVED = %w[from_h serialize to_h].to_set.freeze
78
+ # The CLASS methods a generated struct defines; STRUCT_METHODS covers the
79
+ # instance side, and both are checked with RUBY_KEYWORDS alongside (all
80
+ # three are defined by the class this mixes into).
81
+ ALIAS_RESERVED = %w[from_h].to_set.freeze
76
82
  # list selectors — pick one element out of a list-typed hop, always nilable
77
83
  # (the list may be empty). Everything else is a field prop.
78
84
  LIST_SELECTORS = %w[first last].freeze
@@ -500,9 +500,12 @@ class GraphWeaver::Codegen
500
500
  out << " sig { params(response: T.untyped).returns(GraphWeaver::Response[Result]) }"
501
501
  out << " def self.from_response(response)"
502
502
  out << " raw = GraphWeaver.check_envelope!(response.to_h, Result)"
503
+ # errors first: a cast failure is usually a field the server nulled for
504
+ # a reason it stated, and that reason belongs in the raised error
505
+ out << " errors = (raw[\"errors\"] || []).map { |e| GraphWeaver::GraphQLError.from_h(e) }"
503
506
  out << " GraphWeaver::Response[Result].new("
504
- out << " data: (Result.from_h(raw[\"data\"]) if raw[\"data\"]),"
505
- out << " errors: (raw[\"errors\"] || []).map { |e| GraphWeaver::GraphQLError.from_h(e) },"
507
+ out << " data: (GraphWeaver.cast_data(Result, raw[\"data\"], errors) if raw[\"data\"]),"
508
+ out << " errors:,"
506
509
  out << " extensions: raw[\"extensions\"] || {},"
507
510
  out << " )"
508
511
  out << " end"
@@ -548,8 +551,13 @@ class GraphWeaver::Codegen
548
551
  out << ""
549
552
  end
550
553
  node.fields.each do |field|
554
+ # A schema default makes the field optional here, so the prop has to
555
+ # admit the nil that omitting it leaves behind — the same widening
556
+ # execute's kwargs already get.
557
+ type = field.node.prop_type
558
+ type = "T.nilable(#{type})" if !field.required && field.node.non_null? && type != "T.untyped"
551
559
  default = field.required ? "" : ", default: nil"
552
- out << "#{pad} const :#{field.prop}, #{field.node.prop_type}#{default}"
560
+ out << "#{pad} const :#{field.prop}, #{type}#{default}"
553
561
  end
554
562
  out << ""
555
563
  out << "#{pad} # (prop, wire, required, serializer, coercer) per field"
@@ -27,9 +27,7 @@ class GraphWeaver::Codegen
27
27
  # autoloading is set up after those run. Say where it does resolve.
28
28
  if type.is_a?(String)
29
29
  raise ArgumentError, "type: is the T::Enum itself, not its name — " \
30
- "register_enum(#{@graphql_name.inspect}, #{type}). An autoloaded constant isn't " \
31
- "resolvable while config/initializers run; register from a " \
32
- "Rails.application.config.to_prepare block, which generation also runs first."
30
+ "register_enum(#{@graphql_name.inspect}, #{type}). #{GraphWeaver::Codegen::AUTOLOAD_HINT}"
33
31
  end
34
32
  unless type.is_a?(Class) && type < T::Enum
35
33
  raise ArgumentError, "type: must be a T::Enum subclass, got #{type.inspect}"