graph_weaver 0.4.6 → 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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1442 -0
  3. data/Gemfile.lock +23 -23
  4. data/README.md +115 -96
  5. data/docs/cassettes.md +93 -46
  6. data/docs/editors.md +82 -0
  7. data/docs/errors.md +34 -30
  8. data/docs/federation.md +521 -48
  9. data/docs/generated_modules.md +352 -137
  10. data/docs/getting_started.md +237 -67
  11. data/docs/logging.md +35 -6
  12. data/docs/real_world.md +21 -15
  13. data/docs/scalars.md +49 -154
  14. data/docs/testing.md +300 -52
  15. data/docs/transports.md +129 -30
  16. data/docs/upgrading.md +134 -0
  17. data/graph_weaver.gemspec +19 -3
  18. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  19. data/lib/graph_weaver/client.rb +118 -111
  20. data/lib/graph_weaver/codegen/aliases.rb +223 -0
  21. data/lib/graph_weaver/codegen/emit.rb +283 -261
  22. data/lib/graph_weaver/codegen/enum_type.rb +25 -124
  23. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  24. data/lib/graph_weaver/codegen/scalar_type.rb +69 -66
  25. data/lib/graph_weaver/codegen/type_helpers.rb +140 -0
  26. data/lib/graph_weaver/codegen.rb +672 -336
  27. data/lib/graph_weaver/errors.rb +154 -16
  28. data/lib/graph_weaver/federation.rb +259 -0
  29. data/lib/graph_weaver/hints.rb +9 -1
  30. data/lib/graph_weaver/in_process.rb +90 -0
  31. data/lib/graph_weaver/input_struct.rb +14 -2
  32. data/lib/graph_weaver/logging.rb +29 -0
  33. data/lib/graph_weaver/parsing.rb +59 -0
  34. data/lib/graph_weaver/query_module.rb +55 -0
  35. data/lib/graph_weaver/railtie.rb +23 -1
  36. data/lib/graph_weaver/representation.rb +74 -0
  37. data/lib/graph_weaver/response.rb +7 -0
  38. data/lib/graph_weaver/retry.rb +29 -8
  39. data/lib/graph_weaver/rspec.rb +220 -16
  40. data/lib/graph_weaver/schema_loader.rb +819 -60
  41. data/lib/graph_weaver/schemas.rb +48 -0
  42. data/lib/graph_weaver/selection.rb +43 -8
  43. data/lib/graph_weaver/tasks.rb +220 -22
  44. data/lib/graph_weaver/testing/cassette.rb +249 -81
  45. data/lib/graph_weaver/testing/coverage.rb +160 -0
  46. data/lib/graph_weaver/testing/failure.rb +14 -25
  47. data/lib/graph_weaver/testing/fake_client.rb +182 -22
  48. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  49. data/lib/graph_weaver/testing/router.rb +1452 -0
  50. data/lib/graph_weaver/testing/subgraphs.rb +134 -0
  51. data/lib/graph_weaver/testing.rb +209 -13
  52. data/lib/graph_weaver/transport/faraday.rb +28 -10
  53. data/lib/graph_weaver/transport/http.rb +99 -36
  54. data/lib/graph_weaver/transport.rb +67 -14
  55. data/lib/graph_weaver/version.rb +1 -1
  56. data/lib/graph_weaver.rb +416 -172
  57. metadata +25 -9
  58. data/CLAUDE.md +0 -69
  59. data/Makefile +0 -23
  60. data/NOTES.md +0 -182
  61. data/PLAN.md +0 -144
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- graph_weaver (0.4.6)
4
+ graph_weaver (0.5.1)
5
5
  graphql (>= 2.6.7)
6
6
  sorbet-runtime
7
7
 
@@ -42,7 +42,7 @@ GEM
42
42
  google-protobuf (4.35.1-x86_64-linux-gnu)
43
43
  bigdecimal
44
44
  rake (~> 13.3)
45
- graphql (2.6.7)
45
+ graphql (2.6.10)
46
46
  base64
47
47
  fiber-storage
48
48
  logger
@@ -54,7 +54,7 @@ GEM
54
54
  prism (>= 1.3.0)
55
55
  rdoc (>= 4.0.0)
56
56
  reline (>= 0.4.2)
57
- json (2.20.0)
57
+ json (2.21.2)
58
58
  logger (1.7.0)
59
59
  net-http (0.9.1)
60
60
  uri (>= 0.11.1)
@@ -99,16 +99,16 @@ GEM
99
99
  rubydex (0.2.7-arm64-darwin)
100
100
  rubydex (0.2.7-x86_64-darwin)
101
101
  rubydex (0.2.7-x86_64-linux)
102
- simplecov (1.0.3)
103
- sorbet (0.6.13365)
104
- sorbet-static (= 0.6.13365)
105
- sorbet-runtime (0.6.13365)
106
- sorbet-static (0.6.13365-aarch64-linux)
107
- sorbet-static (0.6.13365-universal-darwin)
108
- sorbet-static (0.6.13365-x86_64-linux)
109
- sorbet-static-and-runtime (0.6.13365)
110
- sorbet (= 0.6.13365)
111
- sorbet-runtime (= 0.6.13365)
102
+ simplecov (1.1.1)
103
+ sorbet (0.6.13454)
104
+ sorbet-static (= 0.6.13454)
105
+ sorbet-runtime (0.6.13454)
106
+ sorbet-static (0.6.13454-aarch64-linux)
107
+ sorbet-static (0.6.13454-universal-darwin)
108
+ sorbet-static (0.6.13454-x86_64-linux)
109
+ sorbet-static-and-runtime (0.6.13454)
110
+ sorbet (= 0.6.13454)
111
+ sorbet-runtime (= 0.6.13454)
112
112
  spoom (1.8.3)
113
113
  erubi (>= 1.10.0)
114
114
  prism (>= 0.28.0)
@@ -176,12 +176,12 @@ 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.4.6)
180
- graphql (2.6.7) sha256=759755ce5819c965b6459c5039605cf2e4d876bb8ba5e03451676870ea787c45
179
+ graph_weaver (0.5.1)
180
+ graphql (2.6.10) sha256=9b7c8633767f516ff9d48a8d6305b2a00a2101c82aa871b92e69086944f9f83e
181
181
  i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
182
182
  io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
183
183
  irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
184
- json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b
184
+ json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
185
185
  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
186
186
  net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
187
187
  netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f
@@ -206,13 +206,13 @@ CHECKSUMS
206
206
  rubydex (0.2.7-arm64-darwin) sha256=f0d28bbf4153568be79b671642424750053e0bea971b60ddf5cec19bf4563990
207
207
  rubydex (0.2.7-x86_64-darwin) sha256=b002b259d118ac69de44470eff1597143318402c45630c47371f9542631447dc
208
208
  rubydex (0.2.7-x86_64-linux) sha256=dacfade9fa42ce4469618da6dac07e69d5f3ac6a313b4caced5234c8f052419a
209
- simplecov (1.0.3) sha256=38ef0514f16ae7562f0d0f4df02610071115103d301b6de7dacbcc000082e39b
210
- sorbet (0.6.13365) sha256=3a642fe7afb031ad670c2e6161b3d5492dd67705cbcafb132c9e87ded7ccf676
211
- sorbet-runtime (0.6.13365) sha256=0657cddfd2319c9695a0b6e13aa7a7c7b093149e5589448c03b911adf1e243cb
212
- sorbet-static (0.6.13365-aarch64-linux) sha256=14b7bf3a227ee102c119763158618bc6b677994cebfda2705b94bca56e4c25e1
213
- sorbet-static (0.6.13365-universal-darwin) sha256=0f6033dde8a0dc7b7e72ada3ce73e1e3097843be16e473fb8bb2b8fb6199212b
214
- sorbet-static (0.6.13365-x86_64-linux) sha256=f94daadcae55f2e0f7797f8fdc8a250c89ec62e11f2d41905ed6fb1e60ff708e
215
- sorbet-static-and-runtime (0.6.13365) sha256=9768c114686f7cac0a6f2e222db4213887d294df6059802242ba6e4e08adbad9
209
+ simplecov (1.1.1) sha256=25825ef13f0b2e74694d769817dad6ab8e90131dabdaa666e522fea105521e78
210
+ sorbet (0.6.13454) sha256=3273dda082b8aa5ecf79b7675e2b79127f202a45370d881124af3af19a3c2b35
211
+ sorbet-runtime (0.6.13454) sha256=b9441ae4bd265f51861c54240e8c5888f6355e3284885b21c5154b184e791d80
212
+ sorbet-static (0.6.13454-aarch64-linux) sha256=e09dd1e6cd7e63b3fb6c8c9c6785eeb55a6c4afecb0f932538ee4401b4552632
213
+ sorbet-static (0.6.13454-universal-darwin) sha256=6ac0cf10ae2b4e0e9ecabb886f9be739046424dc35d100e7888c8538a223f93a
214
+ sorbet-static (0.6.13454-x86_64-linux) sha256=569c52ea926e514ebfdc64825f65cd188b7aba5c1b480cc26e1870fd53d5e2c0
215
+ sorbet-static-and-runtime (0.6.13454) sha256=defc00684a6f8bd498bb70a4541f293b9232938a776137946247cba4baed3d20
216
216
  spoom (1.8.3) sha256=32871fa189bbfa49cf557a50f819f23cc9a6ceefd0346caa7a6adc193becd5dd
217
217
  tapioca (0.19.2) sha256=938731b07811aee8d23871b1aee8861d464fbaf2cfffbf79a62b0c869a5120ec
218
218
  thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
data/README.md CHANGED
@@ -3,14 +3,19 @@ GraphWeaver
3
3
  ![Gem](https://img.shields.io/gem/dt/graph_weaver?style=plastic)
4
4
  [![codecov](https://codecov.io/gh/dpep/graph_weaver/branch/main/graph/badge.svg)](https://codecov.io/gh/dpep/graph_weaver)
5
5
 
6
- A typed GraphQL client for Ruby, built for federation, extensibility, Sorbet, and testing.
6
+ **Your `.graphql` files, compiled into Sorbet types and the fakes to test them.**
7
7
 
8
- GraphWeaver generates `# typed: strict` Ruby from your queries: nested `T::Struct`s, casting code, and a typed `execute` so `srb tc` sees the exact shape of every query result, and a typo'd field is a static error, not a runtime surprise.
8
+ GraphWeaver is graphql-codegen for Ruby. Write a query as a `.graphql` file and it
9
+ generates checked-in `# typed: strict` Ruby — nested `T::Struct`s, casting, a typed
10
+ `execute` — so `srb tc` knows the exact shape of every result. The schema can be a
11
+ live graphql-ruby class, an introspection dump, SDL, or an Apollo supergraph;
12
+ at runtime the only dependencies are `graphql` and `sorbet-runtime`.
9
13
 
10
14
  ```graphql
11
- # queries/person.graphql
15
+ # app/graphql/queries/person.graphql
12
16
  query($id: ID!) {
13
17
  person(id: $id) {
18
+ id
14
19
  name
15
20
  birthday
16
21
  pets { name }
@@ -18,132 +23,146 @@ query($id: ID!) {
18
23
  }
19
24
  ```
20
25
 
26
+ `rake graph_weaver:generate` turns that file into a `PersonQuery` module, and what
27
+ comes back is a struct rather than a Hash you have to trust:
28
+
21
29
  ```ruby
22
- result = PersonQuery.execute!(id: "1") # typed result, or raises on errors (execute returns an envelope)
30
+ result = PersonQuery.execute!(id: "1") # or #execute, for the Response envelope
23
31
 
24
- result.person&.name # => "Daniel" (typed String)
25
- result.person&.birthday # => Date (custom scalars deserialize)
26
- result.person&.nmae # => srb tc: Method `nmae` does not exist
27
- ```
32
+ result.person&.name # => "Daniel"
33
+ result.person&.birthday # => #<Date: 1990-06-15> custom scalars deserialize
34
+ result.person&.pets&.map(&:name) # => ["Shelby", "Brownie"]
28
35
 
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).
36
+ result.person&.nmae
37
+ # srb tc: Method `nmae` does not exist on `PersonQuery::Result::Person`
38
+ # Did you mean `name`?
39
+ ```
36
40
 
37
- #### Features
41
+ `person` is `T.nilable` because the schema says the field is nullable — the `&.`
42
+ isn't defensive, it's the schema talking. A field you misspelled, or never
43
+ selected, is a typecheck error rather than a `NoMethodError` in production.
38
44
 
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
45
+ Typed structs are the part every generator gets right. What decides whether you're
46
+ still happy six months in is everything around them.
46
47
 
47
- #### Usage
48
+ ## Precise types are expensive to fake, so it fakes them for you
48
49
 
49
- Three ways to run a query pick by context:
50
+ Generation makes result types exact, which makes them tedious to build by hand —
51
+ and most generators stop there and leave you the fixtures. GraphWeaver ships the
52
+ fabricator. One line in the spec helper:
50
53
 
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 |
54
+ ```ruby
55
+ require "graph_weaver/rspec"
56
+ ```
56
57
 
57
- The production path assembled is the [getting started](docs/getting_started.md);
58
- the pieces:
58
+ then one tag says what an example runs against:
59
59
 
60
60
  ```ruby
61
- require "graph_weaver"
62
-
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)
67
-
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,
74
- query: File.read("queries/person.graphql"),
75
- module_name: "PersonQuery",
76
- )
77
- File.write("app/queries/person_query.rb", source)
78
-
79
- # at runtime
80
- PersonQuery.execute(id: "1") # via GraphWeaver.client
81
- PersonQuery.execute(other_client, id: "1") # or per call
61
+ it "shows the profile", graphql: :fake do
62
+ person = PersonQuery.execute!(id: "1").person
63
+
64
+ person.name # => "Shakita Stark" fabricated from your schema
65
+ person.birthday # => #<Date: 2024-12-16> custom scalars included
66
+ person.pets.size # => 2
67
+ end
82
68
  ```
83
69
 
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).
70
+ No fixture, no stub, no HTTP — and the values are seeded from rspec's own seed, so
71
+ `--seed 4242` hands back that same person and a failure reproduces. The tag also
72
+ picks a *real* client when you want one: `:in_process` runs your resolvers,
73
+ `:router` runs them across a federated graph. Field-level failure simulation and
74
+ record/replay cassettes with anonymization are in [testing](docs/testing.md).
90
75
 
91
- In development, skip the build step entirely — modules from `client.parse`
92
- carry the client's transport, no global wiring needed:
76
+ ## Federation without a gateway
93
77
 
94
- ```ruby
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")
78
+ When your app is both a GraphQL client and a subgraph, the local router plans a
79
+ query across the composed supergraph and runs your **real resolvers** over the
80
+ boundary no gateway process, no node, no sockets. That's
81
+ [`examples/federation.rb`](examples/federation.rb), the example that needs no network:
82
+
83
+ ```
84
+ $ bundle exec examples/federation.rb
85
+ #<GraphWeaver::Testing::Router subgraphs=["accounts", "products", "reviews"]>
98
86
 
99
- # or every query file at once (queries_path convention), named like generation would
100
- api.load_queries!
87
+ dpep reviewed 2 products:
88
+ Table ($899) — Love it
89
+ Couch ($1299) — Too expensive
101
90
 
102
- # or one-shot, no module at all — variables are plain kwargs
103
- api.execute!("query($id: ID!) { person(id: $id) { name } }", id: "1")
91
+ fetches:
92
+ accounts root fields
93
+ → reviews _entities × 1 User
94
+ → products _entities × 2 Product
104
95
  ```
105
96
 
97
+ The trace is the query plan: every node at a level in one `_entities` call, so two
98
+ products cost one fetch. Anything it can't answer *faithfully* it refuses at plan
99
+ time rather than guessing — and it's diffed against a real `@apollo/gateway` over
100
+ the same supergraph, currently 72 queries identical, 2 refused, 0 wrong
101
+ ([`spec/integration/router_parity_spec.rb`](spec/integration/router_parity_spec.rb)).
102
+ See [federation](docs/federation.md).
106
103
 
107
- #### Dig deeper
104
+ ## The schema keeps itself honest
108
105
 
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
- - **[Federation](docs/federation.md)** — Apollo Federation: supergraph vs API
117
- schema, feeding weaver a composed graph, the `@inaccessible` caveat
118
- - **[Transports](docs/transports.md)** — clients, the execute contract,
119
- Faraday, retries and backoff
120
- - **[Custom scalars](docs/scalars.md)** — the registry: codec inference,
121
- requires, input coercion
122
- - **[Errors](docs/errors.md)** — the Response envelope, the error hierarchy,
123
- field-level reports with entity ids, stale-schema detection
124
- - **[Logging](docs/logging.md)** — point `GraphWeaver.logger` at any Logger:
125
- wire traffic at debug, introspection/cache/codegen at info, errors at warn
126
- - **[Testing](docs/testing.md)** — schema-correct fakes, failure simulation,
127
- rspec integration
128
- - **[Cassettes](docs/cassettes.md)** — capture and replay real API
129
- responses; anonymized recording (`GRAPHWEAVER_RECORD=1`, rake tasks)
106
+ The lifecycle is rake tasks, not a CI pipeline you assemble yourself:
107
+ `schema:refresh` re-introspects the committed dump, `schema:diff` fails when the
108
+ server has drifted, `queries:check` names the queries that drift broke and where,
109
+ and `verify` fails when the checked-in Ruby is stale. Generation is deterministic
110
+ same schema and queries, byte-identical files — so regenerating never shows a
111
+ diff you didn't earn. See [getting started](docs/getting_started.md#5-verify-in-ci).
130
112
 
131
- ----
132
- ## Installation
113
+ ## Start here
133
114
 
134
115
  ```ruby
135
116
  # Gemfile
136
117
  gem "graph_weaver"
137
118
  ```
138
119
 
139
- or
120
+ In Rails, setup is then one command:
140
121
 
141
122
  ```sh
142
- gem install graph_weaver
123
+ rails g graph_weaver:install https://api.example.com/graphql
143
124
  ```
144
125
 
126
+ which writes the initializer, the `app/graphql` layout, the editor config and the
127
+ schema dump. **[Getting started](docs/getting_started.md)** walks the production
128
+ setup end to end. Or skip the build step entirely and poke at an API from a
129
+ console — anything holding a schema parses, and the module runs on what parsed it:
130
+
131
+ ```ruby
132
+ api = GraphWeaver.new("https://countries.trevorblades.com/")
133
+ CountryQuery = api.parse("queries/country.graphql") # a path or a raw string
134
+ CountryQuery.execute!(code: "JP").country&.capital # => "Tokyo"
135
+
136
+ api.run!("query { continents { name } }").continents # or no module at all
137
+ ```
138
+
139
+ The **[examples](examples/)** run that path for real, smallest first: a public API
140
+ in 30 lines, a paginated search, the production path against GitHub, and the
141
+ federated graph above.
142
+
143
+ #### Also in the box
144
+
145
+ - **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
146
+ - **Fragments** (inline, named, type conditions), **unions and interfaces** (member structs, `__typename` dispatch), `@skip`/`@include` nullability
147
+ - **Any transport**: in-process execution, a zero-dependency HTTP client, or Faraday with your own middleware — plus a composable `Retry` with backoff and jitter
148
+ - **Structured errors**: a typed envelope that keeps partial data and extensions, an error hierarchy split by failure site, field-level reports with entity ids, and stale-schema detection
149
+
150
+ #### Dig deeper
151
+
152
+ - **[Getting started](docs/getting_started.md)** — the production path in Rails, step by step
153
+ - **[Generated modules](docs/generated_modules.md)** — module anatomy, typed variables, fragments/unions/interfaces, naming, clients, dynamic mode
154
+ - **[Testing](docs/testing.md)** — fakes, failure simulation, the rspec tags
155
+ - **[Federation](docs/federation.md)** — supergraph vs API schema, the local router, what it refuses
156
+ - **[Transports](docs/transports.md)** — the execute contract, Faraday, retries and backoff
157
+ - **[Errors](docs/errors.md)** — the Response envelope, the error hierarchy, field-level reports
158
+ - **[Custom scalars](docs/scalars.md)** — the registry: codec inference, requires, input coercion
159
+ - **[Cassettes](docs/cassettes.md)** — capture and replay real responses, anonymized
160
+ - **[Editor support](docs/editors.md)** — five lines of YAML for schema autocomplete in `.graphql` files, no JS project
161
+ - **[Against a real API](docs/real_world.md)** — introspecting a live endpoint, GitHub end to end
162
+ - **[Logging](docs/logging.md)** — point `GraphWeaver.logger` at any Logger
163
+
145
164
  ----
146
165
  ## Development
147
166
 
148
167
  - `make check` — regenerate spec fixtures, run specs, typecheck
149
- - `make integration` — one-off checks against the live GitHub and Countries APIs
168
+ - `make integration` — one-off checks against live APIs (GitHub needs a token) and a federation gateway (needs node)
data/docs/cassettes.md CHANGED
@@ -1,40 +1,80 @@
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
+
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.
33
+
34
+ ## Has a recording gone stale?
35
+
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.
43
+
44
+ ```sh
45
+ rake graph_weaver:cassettes:check
46
+ ```
47
+
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:
27
50
 
28
- (`Testing.config.record = true` is the programmatic equivalent.)
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
+ ```
29
56
 
30
- Replaying an unrecorded request raises `MissingRecording` with the query
31
- and the path no silent fabrication.
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.
32
62
 
33
63
  ## Anonymization
34
64
 
35
- Anonymizing rewrites recorded values through the same engine
36
- [FakeClient](testing.md) uses, while preserving everything that makes
37
- the recording faithful:
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.
68
+
69
+ ```ruby
70
+ GraphWeaver::Testing.configure do |config|
71
+ config.schema = MySchema
72
+ config.anonymize = true
73
+ end
74
+ ```
75
+
76
+ Values are rewritten through the same engine [FakeClient](testing.md) uses,
77
+ preserving everything that makes the recording faithful:
38
78
 
39
79
  | preserved | replaced |
40
80
  |-----------|----------|
@@ -42,35 +82,42 @@ the recording faithful:
42
82
  | enums, booleans, `__typename` | numbers, dates |
43
83
  | id *relationships* (same original id → same fake id) | the id values themselves |
44
84
 
45
- Three ways to run it:
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.
46
91
 
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
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:
54
98
 
55
- # 2. after the fact, per cassette
56
- GraphWeaver::Testing::Cassette.new("spec/cassettes/github.yml").anonymize!(schema:)
57
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.**
108
+
109
+ For cassettes recorded before the flag was on:
58
110
 
59
111
  ```sh
60
- # 3. the whole cassette_dir at once
61
- rake graph_weaver:cassettes:anonymize
112
+ rake graph_weaver:cassettes:anonymize # every cassette in cassette_dir, in place
62
113
  ```
63
114
 
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.
115
+ Anonymization preserves shape, so an anonymized cassette still passes
116
+ `cassettes:check`.
68
117
 
69
- ## When to use what
118
+ ## Cassette or FakeClient?
70
119
 
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.
120
+ [FakeClient](testing.md) needs no recording and is the better default for unit
121
+ tests. Reach for a cassette when the *shape* of a real API's answers is the
122
+ pointpagination quirks, which union member came back, where that server puts
123
+ 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).