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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1442 -0
- data/Gemfile.lock +23 -23
- data/README.md +115 -96
- data/docs/cassettes.md +93 -46
- data/docs/editors.md +82 -0
- data/docs/errors.md +34 -30
- data/docs/federation.md +521 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -154
- data/docs/testing.md +300 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +134 -0
- data/graph_weaver.gemspec +19 -3
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +118 -111
- data/lib/graph_weaver/codegen/aliases.rb +223 -0
- data/lib/graph_weaver/codegen/emit.rb +283 -261
- data/lib/graph_weaver/codegen/enum_type.rb +25 -124
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +69 -66
- data/lib/graph_weaver/codegen/type_helpers.rb +140 -0
- data/lib/graph_weaver/codegen.rb +672 -336
- data/lib/graph_weaver/errors.rb +154 -16
- data/lib/graph_weaver/federation.rb +259 -0
- data/lib/graph_weaver/hints.rb +9 -1
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +14 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +59 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +7 -0
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +220 -16
- data/lib/graph_weaver/schema_loader.rb +819 -60
- data/lib/graph_weaver/schemas.rb +48 -0
- data/lib/graph_weaver/selection.rb +43 -8
- data/lib/graph_weaver/tasks.rb +220 -22
- data/lib/graph_weaver/testing/cassette.rb +249 -81
- data/lib/graph_weaver/testing/coverage.rb +160 -0
- data/lib/graph_weaver/testing/failure.rb +14 -25
- data/lib/graph_weaver/testing/fake_client.rb +182 -22
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1452 -0
- data/lib/graph_weaver/testing/subgraphs.rb +134 -0
- data/lib/graph_weaver/testing.rb +209 -13
- data/lib/graph_weaver/transport/faraday.rb +28 -10
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +67 -14
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +416 -172
- metadata +25 -9
- data/CLAUDE.md +0 -69
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- 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
|
+
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.
|
|
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.
|
|
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.
|
|
103
|
-
sorbet (0.6.
|
|
104
|
-
sorbet-static (= 0.6.
|
|
105
|
-
sorbet-runtime (0.6.
|
|
106
|
-
sorbet-static (0.6.
|
|
107
|
-
sorbet-static (0.6.
|
|
108
|
-
sorbet-static (0.6.
|
|
109
|
-
sorbet-static-and-runtime (0.6.
|
|
110
|
-
sorbet (= 0.6.
|
|
111
|
-
sorbet-runtime (= 0.6.
|
|
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.
|
|
180
|
-
graphql (2.6.
|
|
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.
|
|
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.
|
|
210
|
-
sorbet (0.6.
|
|
211
|
-
sorbet-runtime (0.6.
|
|
212
|
-
sorbet-static (0.6.
|
|
213
|
-
sorbet-static (0.6.
|
|
214
|
-
sorbet-static (0.6.
|
|
215
|
-
sorbet-static-and-runtime (0.6.
|
|
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
|

|
|
4
4
|
[](https://codecov.io/gh/dpep/graph_weaver)
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
**Your `.graphql` files, compiled into Sorbet types — and the fakes to test them.**
|
|
7
7
|
|
|
8
|
-
GraphWeaver
|
|
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") #
|
|
30
|
+
result = PersonQuery.execute!(id: "1") # or #execute, for the Response envelope
|
|
23
31
|
|
|
24
|
-
result.person&.name
|
|
25
|
-
result.person&.birthday
|
|
26
|
-
result.person&.
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
48
|
+
## Precise types are expensive to fake, so it fakes them for you
|
|
48
49
|
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
58
|
-
the pieces:
|
|
58
|
+
then one tag says what an example runs against:
|
|
59
59
|
|
|
60
60
|
```ruby
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
|
|
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
|
-
|
|
85
|
-
`
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
carry the client's transport, no global wiring needed:
|
|
76
|
+
## Federation without a gateway
|
|
93
77
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
87
|
+
dpep reviewed 2 products:
|
|
88
|
+
Table ($899) — Love it
|
|
89
|
+
Couch ($1299) — Too expensive
|
|
101
90
|
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
104
|
+
## The schema keeps itself honest
|
|
108
105
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
120
|
+
In Rails, setup is then one command:
|
|
140
121
|
|
|
141
122
|
```sh
|
|
142
|
-
|
|
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
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
transport (a client wrapping a client), so there's no HTTP interception and
|
|
5
|
+
they work identically over HTTP, Faraday, or in-process execution. A cassette
|
|
6
|
+
is a YAML list of `{query, variables, operationName, response}` entries,
|
|
7
|
+
matched on everything but the response — the request's identity as the server
|
|
8
|
+
sees it.
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
`Testing.cassette(name, client:)` returns a client that replays
|
|
11
|
+
`spec/cassettes/<name>.yml`, recording it through `client:` first if the file
|
|
12
|
+
doesn't exist yet.
|
|
10
13
|
|
|
11
14
|
```ruby
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
result = RepoQuery.execute!(cassette, owner: "dpep", name: "graph_weaver")
|
|
15
|
+
client = GraphWeaver::Testing.cassette("github", client: live)
|
|
16
|
+
result = RepoQuery.execute!(client:, owner: "dpep", name: "graph_weaver")
|
|
15
17
|
```
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
3. **Commit** — tests now run offline, fast, deterministic.
|
|
22
|
-
4. **Re-record** when the API's real behavior changes:
|
|
19
|
+
That first run writes `spec/cassettes/github.yml` (`Testing.config.cassette_dir`
|
|
20
|
+
resolves bare names). Commit it — with anonymization on (below), since
|
|
21
|
+
recordings hold real data — and the suite runs offline from then on. Re-record
|
|
22
|
+
when the API's real behavior changes:
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
```sh
|
|
25
|
+
GRAPHWEAVER_RECORD=1 bundle exec rspec # every Testing.cassette records afresh
|
|
26
|
+
```
|
|
27
|
+
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
and
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
the recording
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
#
|
|
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
|
|
65
|
-
|
|
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
|
-
##
|
|
118
|
+
## Cassette or FakeClient?
|
|
70
119
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
point — pagination 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).
|