graph_weaver 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1357 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -136
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -251
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -98
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +617 -264
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +12 -6
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +21 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +15 -1
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +820 -57
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +59 -7
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +186 -62
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +194 -28
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +31 -6
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +74 -18
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +398 -170
  62. metadata +20 -3
data/docs/scalars.md CHANGED
@@ -9,10 +9,8 @@ Ruby object (and serializes back when used as a variable). A field typed
9
9
  GraphWeaver.register_scalar("Money", Money, requires: "bigdecimal")
10
10
  ```
11
11
 
12
- Registrations are global by default. A [client](transports.md) scopes
13
- them: `client.register_scalar(...)` overlays the global registry for that
14
- client's generation only — so two servers can disagree about what a
15
- `DateTime` is, and neither leaks into the other.
12
+ Registration is global and codegen-time: `rake graph_weaver:generate` reads the
13
+ same registry an initializer writes, so register before you generate.
16
14
 
17
15
  Pass a `Type.field` **coordinate** instead of a scalar name to override just
18
16
  that one field — so the same scalar can deserialize as different Ruby types
@@ -23,9 +21,9 @@ GraphWeaver.register_scalar("ISO8601DateTime", Time) # the default, everywhere
23
21
  GraphWeaver.register_scalar("User.birthday", Date) # this field only
24
22
  ```
25
23
 
26
- A field override wins over the scalar-name registration; both stack the same
27
- global-then-client way. (GraphQL names can't contain `.`, so the coordinate is
28
- unambiguous — and it's validated against the schema, so a typo'd field raises.)
24
+ A field override wins over the scalar-name registration which is also how two
25
+ servers that disagree about a `DateTime` coexist in one process. Coordinates are
26
+ validated against the schema, so a typo'd field raises.
29
27
 
30
28
  Pass a real class as `type:` and the cast/serialize are **inferred** from it by
31
29
  probing the deserialize side and pairing its serializer:
@@ -35,12 +33,9 @@ probing the deserialize side and pairing its serializer:
35
33
  | `.parse` | `Type.parse(v)` | `v.to_s` |
36
34
  | `.load` | `Type.load(v)` | `Type.dump(v)` |
37
35
 
38
- so the common case needs nothing more. Probing the *deserialize* side is
39
- deliberate every object has `#to_s`, so inferring off it would wrongly wrap
40
- plain types like `String`/`Integer`; requiring a `.parse`/`.load` the type
41
- actually defines avoids that (and is why the built-in scalars — `Date`, `ID`,
42
- `Int`, and friends, pre-registered and detailed below — can be registered with
43
- their real class constants). Override explicitly when you need to:
36
+ so the common case needs nothing more. A type defining neither `.parse` nor
37
+ `.load` stays pass-through rather than getting wrapped. Override explicitly when
38
+ you need to:
44
39
 
45
40
  - a `Symbol` method name, nothing to misspell: `cast: :load` → `Money.load(expr)`,
46
41
  `serialize: :to_json` → `expr.to_json`
@@ -54,7 +49,7 @@ a real class (so the runtime is loaded), each path is also `require`d at
54
49
  registration — a typo fails now, not in the generated file.
55
50
 
56
51
  Pass `coerce: true` to let a variable of this scalar accept **either** the value
57
- object **or** its raw input, normalizing the latter through the cast:
52
+ object **or** its raw input, normalizing the latter before it goes on the wire:
58
53
 
59
54
  ```ruby
60
55
  GraphWeaver.register_scalar("Money", Money, coerce: true)
@@ -63,38 +58,42 @@ StoreQuery.execute(budget: "12.00") # Money.parse("12.00") under the ho
63
58
  StoreQuery.execute(budget: Money.new(1200)) # passed straight through
64
59
  ```
65
60
 
66
- Bad input still explodes (the cast raises), so some safety survives; coercion
67
- needs both a cast and a serialize. Off by default the strict typed kwarg is the norm.
61
+ `GraphWeaver.auto_coerce = true` is the same switch for every scalar at once —
62
+ set it any time before you generate; an explicit `coerce:` on a registration
63
+ always wins. Off by default either way: the strict typed kwarg is the norm.
68
64
 
69
- `coerce:` also takes a **Symbol** naming a conversion method, for built-ins where
70
- a plain method is the whole story `coerce: :to_f` makes a variable accept
71
- `5`/`"5"` and `.to_f` it, sending a native number (not `"5.0"`) on the wire. The
72
- convertible built-ins already know theirs (`Float`→`:to_f`, `Int`→`:to_i`,
73
- `ID`/`String`→`:to_s`), so rather than opting in each, flip the default:
74
-
75
- ```ruby
76
- GraphWeaver.auto_coerce = true
77
- ```
78
-
79
- Resolved lazily at generation time (set it any time before you generate),
80
- it gives convertible built-ins their conversion and any scalar with a full
81
- cast/serialize pair (`Date`, your `Money`) parse-style coercion; an explicit
82
- `coerce:` on a registration always wins. `Boolean` has no lossless
83
- one-method conversion, so it stays strict.
65
+ *How* a scalar coerces isn't yours to pick the scalar already knows. `Int` and
66
+ `Float` convert (`"5"` `5`, sent as a native number); anything with a full
67
+ cast/serialize pair (`Date`, your `Money`) parses, and bad input still explodes
68
+ because the cast raises. A pass-through scalar `String`, `ID`, `Boolean` — has
69
+ neither a conversion nor a codec pair, so it can't coerce at all: `coerce: true`
70
+ on one raises rather than emitting a no-op.
84
71
 
85
72
  The built-in scalars (`Date`, `ID`, `Int`, …) are pre-registered through the
86
73
  same path (`Date` even carries its own `require "date"`), so a later
87
- `register_scalar` overrides them; `GraphWeaver.reset_scalars!` restores the
88
- defaults (`reset_scalars!(coerce: true)` restores them coercible) and
89
- `clear_scalars!` empties the registry. Register before generating — it's a
90
- codegen-time concern, baked into the emitted source.
74
+ `register_scalar` overrides them.
75
+
76
+ `GraphWeaver.reset_registrations!` is the clean slate between tests: built-in
77
+ scalars restored, enum mappings and type helpers dropped. To reset one registry
78
+ rather than all of them, `GraphWeaver::Codegen` has the pieces —
79
+ `reset_scalars!` (restore the built-ins), `clear_scalars!` (empty the registry
80
+ entirely), `reset_enums!`, `reset_type_helpers!`.
81
+
82
+ A scalar you never register is not an error — it generates as `T.untyped` and
83
+ the wire value passes through untouched. It is, though, the one hole in an
84
+ otherwise exact result type, so generation names the holes at `info` (see
85
+ [logging](logging.md)):
86
+
87
+ ```
88
+ 3 unregistered custom scalars → T.untyped: CountryCode, FuzzyDateInt, Json (register with GraphWeaver.register_scalar)
89
+ ```
91
90
 
92
91
  ## Enums: map onto your own T::Enum
93
92
 
94
- By default each generated module grows its own `T::Enum` per GraphQL
95
- enum `AddPetQuery::Species`, `SearchQuery::Result::...::Species`, one
96
- per module that touches it. That's fine until your app has its own
97
- domain enum, and then the boundary shuffle starts:
93
+ By default a schema enum generates one `T::Enum` per schema, shared by every
94
+ query module that touches it (`GraphQLTypes::Species`, aliased as
95
+ `AddPetMutation::Species`). That's fine until your app has its own domain
96
+ enum, and then the boundary shuffle starts:
98
97
 
99
98
  ```ruby
100
99
  # your domain already speaks PetKind — it's in your models, your
@@ -105,28 +104,24 @@ end
105
104
 
106
105
  # without a mapping, every call site converts by hand, in both directions
107
106
  kind = PetKind.deserialize(pet.species.serialize.downcase) # response -> domain
108
- AddPetQuery.execute!(species: kind.serialize.upcase) # domain -> wire
107
+ AddPetMutation.execute!(species: kind.serialize.upcase) # domain -> wire
109
108
  ```
110
109
 
111
- Two enums for one concept, glue at every crossing, and each generated
112
- module has its *own* incompatible `Species`, so a pet from `SearchQuery`
113
- and a pet from `AddPetQuery` don't even compare. Register the mapping
114
- once and the seam disappears — generated code speaks your enum
115
- everywhere, casting wire values in and serializing members out:
110
+ Register the mapping once and the seam disappears generated code speaks your
111
+ enum everywhere, casting wire values in and serializing members out:
116
112
 
117
113
  ```ruby
118
- GraphWeaver.register_enum("Species", PetKind) # global
119
- api.register_enums("Species" => PetKind, "Role" => Role) # or per client, in bulk
114
+ GraphWeaver.register_enum("Species", PetKind)
120
115
 
121
116
  pet.species # => PetKind::Dog — compare, case, persist directly
122
117
  pet.species == other_pet.species # same type across every query
123
- AddPetQuery.execute!(species: PetKind::Cat) # or "CAT" — members and wire values both work
118
+ AddPetMutation.execute!(species: PetKind::Cat) # or "CAT" — members and wire values both work
124
119
  ```
125
120
 
126
121
  **When to reach for it**: the enum has a life outside the API — it's
127
- persisted, matched in business logic, or shared across queries. **When
128
- not to bother**: display-only values you read and forget; the per-module
129
- generated enums are self-contained and need zero setup.
122
+ persisted or matched in business logic. **When not to bother**: values you
123
+ only read back out of responses; the generated enum is already one type
124
+ across every query and needs zero setup.
130
125
 
131
126
  The mapping is inferred by name (`"CAT"` ↔ `PetKind::Cat`,
132
127
  case/underscore-insensitive against each member's serialized value), so
@@ -151,89 +146,7 @@ Two safety properties do the real work:
151
146
 
152
147
  The translation tables are emitted into the generated source
153
148
  (`SPECIES_FROM_WIRE` / `SPECIES_TO_WIRE`) — reviewable in the diff, no
154
- runtime registry. And because registration can be client-scoped, two
155
- servers with different ideas of `"Species"` can map onto different (or
156
- the same) domain enums without touching each other.
157
-
158
- ## Type helpers: your logic on generated structs
159
-
160
- Derived values (display names, emoji, predicates) belong next to the
161
- data but not *in* it — rewriting wire values on the way in destroys the
162
- raw truth. Register a plain module and every struct generated from that
163
- GraphQL type includes it, whatever query it appears in:
164
-
165
- ```ruby
166
- module PetHelpers
167
- def adult? = birthday && birthday < Date.today << 24
168
- def display_name = adult? ? "#{name} 🦴" : "#{name} 🐶"
169
- end
170
-
171
- GraphWeaver.extend_type("Pet", PetHelpers) # or api.extend_type(...)
172
-
173
- pet.display_name # => "Shelby 🦴"
174
- pet.name # => "Shelby" — the wire value stays honest
175
- ```
176
-
177
- The methods live on the struct, so they see its wire fields at runtime and
178
- fakes/cassettes get the behavior automatically; registrations are additive
179
- (global plus client-scoped stack). One caveat on *static* typing, though:
180
- `srb tc` checks a mixin's method bodies in the module's own scope, not the
181
- including struct's — so a helper that reads a wire field (`name`, `birthday`)
182
- doesn't resolve it and fails with "method does not exist on the module." Write
183
- such a helper at `# typed: false`, or reach the field through `T.unsafe(self)`
184
- — either way its body isn't statically checked against the selection. (Sorbet's
185
- `requires_ancestor` is the escape in principle, but it needs an experimental
186
- flag and a concrete ancestor, which a per-query struct isn't.) The only place a
187
- field-reading derivation type-checks natively is *inside* the struct body, where
188
- the field is in scope — which is codegen's job, not a mixin's.
189
-
190
- For quick decoration, build the mixin inline — the block is
191
- `module_eval`'d into a fresh module auto-named under
192
- `GraphWeaver::TypeHelpers` so generated files can reference it:
193
-
194
- ```ruby
195
- api.extend_type("Pet") do
196
- def display_name = "#{name} 🐶"
197
- end
198
- ```
199
-
200
- Same runtime behavior, less static reach: the block becomes a runtime module
201
- with no source on disk, so `srb tc` can't see its methods at all — fine in
202
- dynamic `parse`, but in a checked-in `# typed: strict` file it's an unresolved
203
- reference. Prefer a named module (and mind the field-access caveat above) where
204
- static checking matters — complexity on demand.
205
-
206
- ### Flat accessors with `alias:`
207
-
208
- The one derivation the generator can type for you is a plain projection — a
209
- selected field, possibly nested, exposed under a flat accessor. `alias:` emits a
210
- sig'd delegator *into the struct body*, where the field is in scope, so it's
211
- fully checked (the thing a mixin can't be):
212
-
213
- ```ruby
214
- GraphWeaver.extend_type("Widget", alias: { tag: "meta.tag" })
215
-
216
- # generated on the Widget struct:
217
- # sig { returns(T.nilable(String)) }
218
- # def tag = meta&.tag
219
- ```
220
-
221
- So a hand-written value object that only existed to expose `tag` flat over
222
- `data.dig("meta", "tag")` drops away — the generated struct answers `.tag`
223
- directly. Forms:
224
-
225
- ```ruby
226
- alias: { tag: "meta.tag" } # explicit accessor name
227
- alias: "meta.tag" # accessor named after the last segment (`tag`)
228
- alias: ["meta.tag", "meta.color"] # several at once
229
- alias: { label: "name", tag: "meta.tag" }
230
- ```
149
+ runtime registry.
231
150
 
232
- The path is the Ruby accessor chain (`meta.tag`), typed from the selection: any
233
- nullable hop makes the accessor nilable and inserts `&.`; the leaf can be a
234
- scalar, enum, or nested struct. It's validated against each query at generation —
235
- an unselected or misspelled segment (`did you mean 'tag'?`), a path through a
236
- list, or a name that collides with a real field all fail with a pointed error.
237
- Registrations stack and are client-scopable, like the mixin forms. For anything
238
- beyond a passthrough projection — real logic, still typed — reopen the generated
239
- struct in your own file and add sig'd methods; Sorbet merges the bodies.
151
+ Decorating a generated *struct* with your own methods is the sibling API —
152
+ `extend_type`, in [generated modules](generated_modules.md#type-helpers).
data/docs/testing.md CHANGED
@@ -1,22 +1,162 @@
1
1
  # Testing
2
2
 
3
- Everything here is a *client* — the one interface queries run
4
- through: anything with `execute(query, variables:)` returning
5
- `{"data" => ..., "errors" => ...}` (see [transports](transports.md)).
6
- Fakes, failures, and cassettes all slot in wherever a real transport
7
- would.
3
+ One line in your spec helper:
8
4
 
9
- `require "graph_weaver/rspec"` from your spec helper (or
10
- `graph_weaver/testing` outside rspec — never in production) for a
11
- zero-setup fake backend. `FakeClient` fabricates
12
- schema-correct responses for whatever query arrives: real enum values,
13
- valid `__typename` members, iso8601 date scalars every fake casts
14
- cleanly through your generated structs.
5
+ ```ruby
6
+ require "graph_weaver/rspec"
7
+ ```
8
+
9
+ (In Rails, put it **above** the `spec/support` glob in `rails_helper.rb` —
10
+ rspec-rails requires those partway through, and a support file mentioning
11
+ `GraphWeaver::Testing` before this line dies on `NameError`.)
12
+
13
+ Then **one tag says what an example runs against** — on the example, or on
14
+ the group it belongs to, since rspec metadata inherits:
15
15
 
16
16
  ```ruby
17
- fake = GraphWeaver::Testing::FakeClient.new(schema:)
17
+ describe "checkout", graphql: :router do
18
+ it "stitches the dashboard" do … end # every example here, too
19
+ end
20
+
21
+ it "renders the empty state", graphql: :fake do … end
22
+ it "authorizes drafts", graphql: :in_process do … end
23
+ ```
24
+
25
+ | mode | reach for it when | what it costs |
26
+ |---|---|---|
27
+ | `graphql: :fake` | most unit tests — you need *a* well-shaped response | no resolver code runs |
28
+ | `graphql: :in_process` | the point of the test is that your resolver logic works | slower; needs a live schema class |
29
+ | `graphql: :router` | the same, across a federated graph | needs a composed supergraph; [refuses](federation.md#what-it-refuses) shapes it can't plan faithfully |
30
+ | [cassettes](cassettes.md) | pinning a real server's exact response | must be re-recorded when the query changes |
31
+
32
+ The tag installs its client as `GraphWeaver.client` for that example, so
33
+ generated modules run against it with zero per-test setup. (Generate them
34
+ *without* a baked `client:` — a module that has one never consults
35
+ `GraphWeaver.client`.) `rspec --tag graphql:router` runs one mode's
36
+ examples; an untagged example is left alone unless you set
37
+ `config.default_mode`, and **`graphql: false` opts one back out** of that
38
+ default.
39
+
40
+ `GraphWeaver.client` is **snapshotted before every example and restored
41
+ after** — tagged, untagged or opted out, and whatever the example did to
42
+ it. So building your own client is a plain assignment, cleaned up like a
43
+ tagged one:
44
+
45
+ ```ruby
46
+ before { GraphWeaver.client = GraphWeaver::Testing::Failure.throttled }
47
+ ```
48
+
49
+ Everything here is a *client* — the one interface queries run through:
50
+ anything with `execute(query, variables:, operation_name:)` returning
51
+ `{"data" => ..., "errors" => ...}` (see [transports](transports.md)). Fakes,
52
+ the router, failures, and cassettes all slot in wherever a real transport
53
+ would, so they work outside rspec too (`require "graph_weaver/testing"` —
54
+ never from production code). Outside the tags there's no `GraphWeaver.client`
55
+ to lean on, so parse from the fake or the router itself — anything holding a
56
+ schema parses against it, and the module runs on what parsed it:
57
+
58
+ ```ruby
59
+ router = GraphWeaver::Testing::Router.new(supergraph: "app/graphql/supergraph.graphql")
60
+ DashboardQuery = router.parse("query Dashboard { me { username } }")
61
+ DashboardQuery.execute!.me.username
62
+ ```
63
+
64
+ All three modes, tagged and running end to end, are
65
+ [`spec/rspec_spec.rb`](../spec/rspec_spec.rb) — the reference for anything
66
+ this page leaves out.
67
+
68
+ ## Nothing to configure
69
+
70
+ Each mode works out what to run against, and **refuses — naming what it
71
+ looked for — rather than guessing**:
72
+
73
+ - **the schema** is `config.schema` if you set one, else the committed dump
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.)
78
+ - **`:in_process`** needs the live schema *class*, since only that has
79
+ resolvers: the one your client already runs in-process, else the loaded
80
+ class that defines everything the schema declares — the same
81
+ derive-verify-refuse rule that
82
+ [maps subgraphs](federation.md#which-schema-serves-which-subgraph).
83
+ - **`:router`** plans against the composed supergraph. If your committed dump
84
+ *is* one (it carries `@join__*` markers), that's it — no config at all. A
85
+ client can't stand in for it: a client's schema is the API schema the router
86
+ serves, with the `@join__*` routing table stripped out, so the supergraph has
87
+ to be named. Subgraphs are derived either way.
88
+
89
+ So configure only to override a derivation, or to tune fabricated values:
90
+
91
+ ```ruby
92
+ GraphWeaver::Testing.configure do |config|
93
+ # config.schema = MySchema # the live class, rather than the dump
94
+ # config.router = { supergraph: Rails.root.join("supergraph.graphql") }
95
+ # config.router = { subgraphs: { "reviews" => :fake } } # either key alone
96
+ # config.context = { tenant: } # baseline context every example starts from
97
+ # config.default_mode = :fake # what an UNtagged example runs against
98
+ # # (graphql: false opts one back out)
99
+ # config.mode = :faker # or :literal (plain typed values); nil = auto
100
+ # config.overrides = { "Person.name" => "Daniel" }
101
+ # config.list_size = 1..3
102
+ # config.null_chance = 0.1 # nullable fields go nil sometimes
103
+ end
104
+ ```
105
+
106
+ ## The context your resolvers see
107
+
108
+ `graphql_context` is available in every example. It **merges** onto
109
+ `config.context` — the baseline survives unless you override a key — and is
110
+ **reset before the next example**, so one example running as somebody else
111
+ can't leak into the one after it.
112
+
113
+ Context is setup, so it usually belongs in a `before` block — a group of
114
+ examples sharing one identity says who they are once:
115
+
116
+ ```ruby
117
+ describe "as the owner", graphql: :in_process do
118
+ before { graphql_context(current_user: alice) }
119
+
120
+ it "shows the drafts" do
121
+ expect(DraftsQuery.execute!.drafts.size).to eq 2
122
+ end
123
+
124
+ it "counts them" do … end
125
+ end
126
+ ```
127
+
128
+ The reset runs ahead of any group hook, so each example re-applies that
129
+ `before` from the same baseline rather than stacking onto the last one's
130
+ context. Set it inline for the one-off:
131
+
132
+ ```ruby
133
+ it "shows the owner's drafts", graphql: :in_process do
134
+ graphql_context(current_user: alice)
135
+ expect(DraftsQuery.execute!.drafts.size).to eq 2
136
+ end
137
+ ```
138
+
139
+ Pass a block to scope it, for the example that needs two identities:
140
+
141
+ ```ruby
142
+ graphql_context(admin: true) { expect(SettingsQuery.execute!.settings).to be_present }
143
+ ```
18
144
 
19
- person = PersonQuery.execute!(fake, id: "1").person
145
+ Called with nothing it reads the context back. Under `graphql: :fake` it
146
+ refuses: there are no resolvers to receive a context, and silently ignoring
147
+ one would leave an example asserting on data nothing scoped. Pin the data
148
+ itself instead — `graphql_fake(overrides: …)`, below.
149
+
150
+ ## Fabricated data — `graphql: :fake`
151
+
152
+ `FakeClient` fabricates schema-correct responses for whatever query
153
+ arrives: real enum values, valid `__typename` members, iso8601 date scalars
154
+ — every fake casts cleanly through your generated structs.
155
+
156
+ ```ruby
157
+ fake = GraphWeaver::Testing::FakeClient.new # schema: falls back to Testing.config
158
+
159
+ person = PersonQuery.execute!(client: fake, id: "1").person
20
160
  person.name # => "Eliza Kertzmann" (faker-matched on field name, when faker is loaded)
21
161
  person.birthday # => a real Date
22
162
  ```
@@ -31,52 +171,120 @@ GraphWeaver::Testing::FakeClient.new(schema:, overrides: {
31
171
  })
32
172
  ```
33
173
 
34
- With rspec, the setup is two lines in `spec/support/graph_weaver.rb`
35
- the require, plus an explicit opt-in to per-example fakes (deliberately
36
- not a default: silently swapping every example onto a fake would be
37
- surprising). The schema auto-locates from the committed dump at
38
- `GraphWeaver.schema_path`:
174
+ Keys are checked against the schema, spellchecked `"Person.nmae"` raises
175
+ rather than quietly pinning nothing and leaving the example green against
176
+ random data.
177
+
178
+ ### The example that's *about* the data
179
+
180
+ Fabricated data answers "does this render", not "does it render Ada's two
181
+ orders". `graphql_fake` is the tag with options — same client, built where
182
+ the example can say what it needs:
39
183
 
40
184
  ```ruby
41
- require "graph_weaver/rspec" # seed follows --seed
185
+ it "shows the two paid orders", graphql: :fake do
186
+ graphql_fake(overrides: {
187
+ "Reader.name" => "Ada",
188
+ "Reader.orders" => [{ "status" => "PAID" }, {}],
189
+ })
42
190
 
43
- GraphWeaver::Testing.configure do |config|
44
- config.auto_fake = true # every example runs against a fresh fake
45
- # config.schema = MySchema # optional: an in-process class instead of the dump
46
- # config.mode = :faker # or :literal (plain typed values); nil = auto
47
- # config.overrides = { "Person.name" => "Daniel" }
48
- # config.list_size = 1..3
49
- # config.null_chance = 0.1 # nullable fields go nil sometimes
191
+ expect(DashboardQuery.execute!.reader.orders.size).to eq 2
192
+ end
193
+ ```
194
+
195
+ An override pins a **subtree** as readily as a leaf, and **merges**: name
196
+ the fields the example is about and everything else in the selection is
197
+ still fabricated. A pinned list is exactly as long as you write it — `{}`
198
+ means "another one, all fabricated". Inside a subtree the keys are
199
+ *response* keys, as they come back on the wire (`priceCents`, or an alias
200
+ you selected); one the query doesn't select is refused and spellchecked,
201
+ same as a typo'd coordinate. At a union or interface, name the member with
202
+ `"__typename"`.
203
+
204
+ `graphql_fake` returns the client, which records what it was asked:
205
+
206
+ ```ruby
207
+ fake = graphql_fake
208
+ 2.times { Dashboard.load }
209
+ expect(fake.requests.size).to eq 1 # memoized
210
+ expect(fake.requests.first[:variables]).to eq({ "id" => "1" })
211
+ ```
212
+
213
+ It works in a `before` block, an example body, or a shared context — and
214
+ with no tag at all, since it installs the client itself. The tag is
215
+ `graphql_fake` with no options.
216
+
217
+ One thing to know: **two identical queries fabricate different data**, so
218
+ assert a memoization with `requests.size`, not by comparing two responses.
219
+
220
+ ### Naming the schema your resolvers run on
221
+
222
+ `graphql_in_process` is the same idea for real resolvers. The tag runs
223
+ `config.schema` when that's a live class, which is the whole story for an app
224
+ that serves the API it calls:
225
+
226
+ ```ruby
227
+ it "hides another reader's drafts", graphql: :in_process do
228
+ expect(DraftsQuery.execute!.drafts.map(&:id)).to eq %w[d3]
229
+ end
230
+ ```
231
+
232
+ A federated app has no one live class, so the example says which subgraph it
233
+ means — testing one subgraph's resolvers directly is a different question from
234
+ `graphql: :router`, which plans across the whole graph and stitches. Both are
235
+ worth asking, and a suite asks them of different subgraphs:
236
+
237
+ ```ruby
238
+ it "rejects a review from a blocked reader" do
239
+ graphql_in_process(Reviews::Schema)
240
+
50
241
  end
51
242
  ```
52
243
 
53
- With the rspec integration, `rspec --seed 1234` reproduces fake data
54
- along with test order, and `auto_fake` installs a seeded fake as the
55
- app client per example (generate modules *without* a baked `client:` so
56
- they consult `GraphWeaver.client`). `mode:` picks value fabrication: `:faker`
57
- (semantic, field-name matched — raises if the gem is missing),
58
- `:literal` (plain type-derived), or nil to auto-detect faker.
244
+ Like `graphql_fake`, it needs no tag, works in a `before` block, and is
245
+ restored after the example. Set the GraphQL context with `graphql_context`
246
+ rather than the helper's `context:` the helper's is a baseline, and
247
+ `graphql_context` is what merges onto it per example.
248
+
249
+ `rspec --seed 1234` reproduces fake data along with test order. `config.mode`
250
+ picks value fabrication: `:faker` (semantic, field-name matched — raises if
251
+ the gem is missing), `:literal` (plain type-derived), or nil to auto-detect
252
+ faker.
253
+
254
+ Need the schema itself inside an example — to sample a field, or build a
255
+ query on the fly? The client in play exposes it as
256
+ `GraphWeaver.client.schema`, and `GraphWeaver::Testing.config.schema` reads
257
+ back what `config.schema =` set, falling back to the committed dump.
59
258
 
60
- Test-only queries don't have to live in `app/` — the conventional paths
61
- are appendable lists, so the same support file can register a
62
- spec-local set that `load_generated!` (and the Railtie) pick up:
259
+ Test-only generated modules don't have to live in `app/` — `generated_paths` is
260
+ an appendable list, so a support file can register a spec-local set:
63
261
 
64
262
  ```ruby
65
- GraphWeaver.generated_paths << "spec/support/graphql/generated"
66
- GraphWeaver.queries_paths << "spec/support/graphql/queries"
263
+ # spec/support/graph_weaver.rb
264
+ GraphWeaver.generated_paths << "spec/graphql/generated"
265
+ GraphWeaver.load_generated! # the appended path needs this call
67
266
  ```
68
267
 
69
- **Simulating failures** every failure mode is just a client, so
268
+ Both lines matter. In Rails the Railtie loads generated modules during boot,
269
+ which is finished before `spec/support/*.rb` runs — so a path appended here is
270
+ never loaded unless you load it. And keep the directory *outside*
271
+ `spec/support/`: rspec-rails requires every `spec/support/**/*.rb` itself, in
272
+ sorted order, so a generated module gets required before the shared `types.rb`
273
+ it needs and dies on `LoadError`.
274
+
275
+ ## Simulating failures
276
+
277
+ Every failure mode is just a client, so
70
278
  error-handling paths are testable without a server that misbehaves on cue:
71
279
 
72
280
  ```ruby
73
281
  Failure = GraphWeaver::Testing::Failure
74
282
 
75
- PersonQuery.execute(id: "1", client: Failure.transport) # TransportError (cause preserved)
76
- PersonQuery.execute(id: "1", client: Failure.server(status: 502)) # ServerError
77
- PersonQuery.execute(id: "1", client: Failure.throttled) # QueryError, code THROTTLED
78
- PersonQuery.execute(id: "1", client: Failure.stale_schema) # schema_stale? => true
79
- PersonQuery.execute(id: "1", client: Failure.graphql("boom", data: {...})) # partial failure
283
+ PersonQuery.execute(client: Failure.transport, id: "1") # TransportError (cause preserved)
284
+ PersonQuery.execute(client: Failure.server(status: 502), id: "1") # ServerError
285
+ PersonQuery.execute(client: Failure.throttled, id: "1") # QueryError, code THROTTLED
286
+ PersonQuery.execute(client: Failure.stale_schema, id: "1") # schema_stale? => true
287
+ PersonQuery.execute(client: Failure.graphql("boom"), id: "1") # partial failure
80
288
 
81
289
  # retries: clients run in sequence (the last repeats) — here, two
82
290
  # transport failures and then a FakeClient serving good responses
@@ -87,24 +295,63 @@ GraphWeaver::Testing::Sequence.new(Failure.transport, Failure.transport, fake)
87
295
  # casting raises GraphWeaver::TypeError (overrides remain the manual escape hatch)
88
296
  GraphWeaver::Testing::FakeClient.new(schema:, corrupt: "Person.birthday")
89
297
 
90
- # stale schema naming a real (sampled) field
91
- Failure.stale_schema(schema: MySchema)
92
-
93
298
  # field-level partial failure with real GraphQL null propagation: the error
94
299
  # lands with its concrete path and nulls bubble to the nearest nullable spot
95
300
  GraphWeaver::Testing::FakeClient.new(schema:, fail_at: { path: "person.email", code: "PRIVATE" })
96
301
  ```
97
302
 
98
- **Capture and replay** — cassettes record real API responses and replay
303
+ ## Capture and replay
304
+
305
+ Cassettes record real API responses and replay
99
306
  them offline, above the transport (no HTTP interception):
100
307
 
101
308
  ```ruby
102
309
  # records against the live client when the file is missing, replays after
103
- cassette = GraphWeaver::Testing::Cassette.use("github", client: live)
310
+ client = GraphWeaver::Testing.cassette("github", client: live)
311
+ ```
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)**.
316
+
317
+ ## Real resolvers, one schema — `graphql: :in_process`
318
+
319
+ Your actual resolvers, your actual `context`, in the same process — no
320
+ socket, no serialization, and a resolver's real backtrace when it raises.
321
+
322
+ ```ruby
323
+ it "hides other people's drafts", graphql: :in_process do
324
+ graphql_context(current_user: alice)
325
+ expect(DraftsQuery.execute!.drafts.map(&:owner)).to all(eq alice.name)
326
+ end
327
+ ```
328
+
329
+ The live schema *class* is found for you (a schema dump has no resolvers,
330
+ so it won't do). If two loaded classes match, or none does, it says so and
331
+ asks for `config.schema = MySchema` — and in Rails, remember that an
332
+ autoloaded schema isn't loaded until something references it.
333
+
334
+ ## A federated graph — `graphql: :router`
335
+
336
+ Same thing across a federated graph: the tag builds a
337
+ [`Testing::Router`](federation.md#the-local-router), which plans the query
338
+ across your subgraphs and runs it against those **real resolvers** — no
339
+ gateway, no node, no sockets.
340
+
341
+ ```ruby
342
+ describe "the dashboard", graphql: :router do
343
+ it "stitches a user's reviews" do
344
+ graphql_context(current_user: user)
345
+ expect(DashboardQuery.execute!.me.reviews.size).to eq 2
346
+ end
347
+ end
104
348
  ```
105
349
 
106
- Re-record with `GRAPHWEAVER_RECORD=1`, anonymize before committing
107
- (`config.anonymize = true` scrubs as recordings happen, or
108
- `rake graph_weaver:cassettes:anonymize` after) the full workflow guide
109
- is **[cassettes](cassettes.md)**.
350
+ The router is built once for the suite (parsing a supergraph per example
351
+ would be real time) and installed as `GraphWeaver.client` for each; its
352
+ context is reset from `config.context` every time, so an example that runs
353
+ as someone else can't leak into the next.
110
354
 
355
+ What it plans, what it **refuses** and why, how subgraphs are matched to your
356
+ schema classes, and what to do about a supergraph only partly local:
357
+ **[federation → the local router](federation.md#the-local-router)**.