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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1357 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -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 -136
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -251
- data/lib/graph_weaver/codegen/enum_type.rb +27 -98
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +617 -264
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +12 -6
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +21 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -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 +15 -1
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +820 -57
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +59 -7
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +186 -62
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +194 -28
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +31 -6
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +74 -18
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +398 -170
- 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
|
-
|
|
13
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
`
|
|
90
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
107
|
+
AddPetMutation.execute!(species: kind.serialize.upcase) # domain -> wire
|
|
109
108
|
```
|
|
110
109
|
|
|
111
|
-
|
|
112
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
61
|
-
|
|
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
|
-
|
|
66
|
-
GraphWeaver.
|
|
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
|
-
|
|
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"
|
|
76
|
-
PersonQuery.execute(
|
|
77
|
-
PersonQuery.execute(id: "1"
|
|
78
|
-
PersonQuery.execute(id: "1"
|
|
79
|
-
PersonQuery.execute(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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)**.
|