graph_weaver 0.4.6 → 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 +1314 -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 -154
- 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 -258
- data/lib/graph_weaver/codegen/enum_type.rb +27 -124
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +593 -334
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -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 +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 +7 -0
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +794 -59
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +43 -8
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +160 -61
- 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 +181 -21
- 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 +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 +389 -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,107 +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
|
-
```
|
|
231
|
-
|
|
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 selector on a
|
|
236
|
-
non-list, or a name that collides with a real field all fail with a pointed
|
|
237
|
-
error. Registrations stack and are client-scopable, like the mixin forms.
|
|
238
|
-
|
|
239
|
-
A segment can also be `first` or `last` to pick one element out of a list hop —
|
|
240
|
-
always nilable, since the list may be empty. This is what turns an
|
|
241
|
-
`_entities`-style "array that logically holds one thing" into a clean accessor:
|
|
242
|
-
|
|
243
|
-
```ruby
|
|
244
|
-
GraphWeaver.extend_type("Query", alias: { entity: "_entities.first" }, optional: true)
|
|
245
|
-
|
|
246
|
-
# sig { returns(T.nilable(Widget)) } # concrete, when the selection is one `... on Widget`
|
|
247
|
-
# def entity = _entities&.first # (a multi-fragment selection types it as the union)
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
`optional: true` makes the aliases *lenient*: a query whose selection doesn't fit
|
|
251
|
-
the path just omits the accessor instead of failing generation. Reach for it when
|
|
252
|
-
the alias lives on a universal type like `Query` — where a strict alias would
|
|
253
|
-
force *every* query to select the path — or when it only fits some selections.
|
|
149
|
+
runtime registry.
|
|
254
150
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
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).
|