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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1314 -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 -154
  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 -258
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -124
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +593 -334
  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 +9 -1
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +14 -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 +7 -0
  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 +794 -59
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +43 -8
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +160 -61
  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 +181 -21
  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 +28 -10
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +67 -14
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +389 -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,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. 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
- ```
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
- For anything beyond a passthrough projection real logic, still typed reopen
256
- the generated struct in your own file and add sig'd methods; Sorbet merges the
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).