graph_weaver 0.0.1 → 0.2.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +279 -0
  4. data/Gemfile.lock +75 -17
  5. data/Makefile +11 -1
  6. data/NOTES.md +1 -1
  7. data/PLAN.md +85 -22
  8. data/README.md +86 -26
  9. data/docs/cassettes.md +76 -0
  10. data/docs/errors.md +134 -0
  11. data/docs/generated_modules.md +229 -0
  12. data/docs/getting_started.md +134 -0
  13. data/docs/logging.md +33 -0
  14. data/docs/real_world.md +53 -0
  15. data/docs/scalars.md +183 -0
  16. data/docs/testing.md +103 -0
  17. data/docs/transports.md +124 -0
  18. data/graph_weaver.gemspec +10 -1
  19. data/lib/graph_weaver/client.rb +200 -0
  20. data/lib/graph_weaver/codegen/emit.rb +286 -0
  21. data/lib/graph_weaver/codegen/enum_type.rb +149 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +356 -0
  23. data/lib/graph_weaver/codegen/scalar_type.rb +256 -0
  24. data/lib/graph_weaver/codegen.rb +396 -401
  25. data/lib/graph_weaver/errors.rb +322 -0
  26. data/lib/graph_weaver/hints.rb +63 -0
  27. data/lib/graph_weaver/inflect.rb +19 -0
  28. data/lib/graph_weaver/logging.rb +41 -0
  29. data/lib/graph_weaver/railtie.rb +29 -0
  30. data/lib/graph_weaver/response.rb +55 -0
  31. data/lib/graph_weaver/retry.rb +97 -0
  32. data/lib/graph_weaver/rspec.rb +59 -0
  33. data/lib/graph_weaver/schema_loader.rb +208 -8
  34. data/lib/graph_weaver/selection.rb +68 -0
  35. data/lib/graph_weaver/tasks.rb +71 -0
  36. data/lib/graph_weaver/testing/cassette.rb +224 -0
  37. data/lib/graph_weaver/testing/failure.rb +109 -0
  38. data/lib/graph_weaver/testing/fake_client.rb +228 -0
  39. data/lib/graph_weaver/testing/values.rb +98 -0
  40. data/lib/graph_weaver/testing.rb +106 -0
  41. data/lib/graph_weaver/transport/faraday.rb +56 -0
  42. data/lib/graph_weaver/transport/http.rb +87 -0
  43. data/lib/graph_weaver/transport.rb +120 -0
  44. data/lib/graph_weaver/version.rb +1 -1
  45. data/lib/graph_weaver.rb +268 -4
  46. metadata +132 -2
  47. data/lib/graph_weaver/http_executor.rb +0 -31
@@ -0,0 +1,229 @@
1
+ # Generated modules
2
+
3
+ `GraphWeaver::Codegen` turns one GraphQL operation into one `# typed: strict`
4
+ Ruby module. Everything `srb tc` knows about your query results comes from this
5
+ file — there is no runtime schema, no lazy wrapper, no reflection.
6
+
7
+ This is the production path — checked in, reviewed, statically checked
8
+ (assembled step by step in the [getting started](getting_started.md), including
9
+ [what Sorbet does and doesn't require](getting_started.md#sorbet-with-or-without)).
10
+ For consoles and dev there's [dynamic mode](#dynamic-mode); for one-off
11
+ scripts, `client.execute!` skips modules entirely.
12
+
13
+ ## Generating
14
+
15
+ The workflow that keeps generated code honest: queries live as `.graphql`
16
+ files (the source of truth), generation writes the Ruby, and verification
17
+ fails when the two drift. The conventional layout (configurable via
18
+ `GraphWeaver.queries_path` / `generated_path` / `schema_path`):
19
+
20
+ ```text
21
+ app/graphql/
22
+ schema.json # introspection dump (or schema.graphql SDL)
23
+ queries/ # *.graphql — hand-written, reviewed
24
+ generated/ # *_query.rb — generated, checked in, never edited
25
+ ```
26
+
27
+ The schema dump is step 0 — codegen reads it, never a live endpoint.
28
+ `cache: true` on a url client writes it on first introspection
29
+ (`GraphWeaver.new(url, cache: true).schema` in a console bootstraps it);
30
+ generating without one fails pointing at exactly that.
31
+
32
+ Rake tasks (self-registering in Rails; elsewhere add
33
+ `require "graph_weaver/tasks"` to your Rakefile):
34
+
35
+ ```sh
36
+ rake graph_weaver:generate # queries_path -> generated_path
37
+ rake graph_weaver:verify # fail if anything is stale — run in CI
38
+ ```
39
+
40
+ Scalar/enum/type registrations are baked into generated source, so they
41
+ must run before the tasks do. In Rails they will — the tasks depend on
42
+ `:environment`, which runs your initializers. Outside Rails, require the
43
+ file that does your registrations from the Rakefile yourself.
44
+
45
+ Or call the same APIs directly:
46
+
47
+ ```ruby
48
+ schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
49
+ GraphWeaver.generate!(schema:) # write the modules
50
+ GraphWeaver.verify_generated!(schema:) # the freshness guard, one line in a spec
51
+ ```
52
+
53
+ In Rails, loading is automatic — the Railtie requires every file under
54
+ `generated_path` at boot, after your initializers (so registrations run
55
+ first). Elsewhere it's explicit, factory_bot-style:
56
+
57
+ ```ruby
58
+ GraphWeaver.load_generated! # require every file under generated_path
59
+ ```
60
+
61
+ (Plain requires, not Zeitwerk: Zeitwerk would expect
62
+ `Generated::PersonQuery` from `generated/person_query.rb`, and generated
63
+ code only changes on regeneration — restart, like a schema migration.)
64
+
65
+ Regenerate when: a query changes, the schema changes (a
66
+ [`schema_stale?`](errors.md) error in production is the late signal — refresh
67
+ the schema dump and regenerate), a scalar registration changes, or GraphWeaver
68
+ itself upgrades (emission may differ across versions; `verify_generated!`
69
+ catches it).
70
+
71
+ Introspected dumps record their source url, so drift is checkable ahead
72
+ of the late signal: `rake graph_weaver:schema:verify` re-introspects the
73
+ recorded url and fails when the server has moved;
74
+ `rake graph_weaver:schema:refresh` rewrites the dump
75
+ (`GRAPHWEAVER_AUTH` supplies a token for private APIs). Don't confuse
76
+ the two verifies: `graph_weaver:verify` asks "is the generated code
77
+ fresh?" — local, every CI run; `graph_weaver:schema:verify` asks "has the
78
+ *server* drifted from the dump?" — network, needs the recorded url, run
79
+ on a schedule.
80
+
81
+ In development, skip the build entirely — `client.load_queries!` parses
82
+ every query file into modules with the same names generation would use
83
+ (see [dynamic mode](#dynamic-mode)).
84
+
85
+ ## Anatomy
86
+
87
+ ```ruby
88
+ module PersonQuery
89
+ QUERY = "..." # the operation, verbatim
90
+
91
+ class Result < T::Struct # the response shape, exactly as selected
92
+ class Person < T::Struct
93
+ const :name, String # non-null in the schema
94
+ const :birthday, T.nilable(Date)
95
+
96
+ def self.from_h(data) ... # generated casting — no reflection
97
+ end
98
+
99
+ const :person, T.nilable(Person)
100
+ end
101
+
102
+ def self.client ... # default client (see below)
103
+ def self.execute(client = nil, id:) # -> GraphWeaver::Response[Result]
104
+ def self.execute!(client = nil, id:) # -> Result, or raises QueryError
105
+ end
106
+ ```
107
+
108
+ - `execute` returns the **envelope** — `GraphWeaver::Response[Result]` with
109
+ `#data`, `#data!`, `#errors`, `#extensions` — so partial data and
110
+ cost/throttle metadata survive.
111
+ - `execute!` is the shortcut: the typed result or a raised
112
+ `GraphWeaver::QueryError`.
113
+
114
+ ## Variables become typed kwargs
115
+
116
+ ```graphql
117
+ mutation($name: String!, $species: Species!, $note: String) { ... }
118
+ ```
119
+
120
+ ```ruby
121
+ AddPetQuery.execute!(name: "Rex", species: AddPetQuery::Species::Dog)
122
+ ```
123
+
124
+ - required vs optional falls out of nullability and defaults: nullable or
125
+ defaulted variables become optional kwargs (nil is omitted from the wire,
126
+ so server-side defaults apply)
127
+ - enum variables generate module-level `T::Enum`s and accept the enum or
128
+ its wire value (`species: Species::Dog` or `species: "DOG"`)
129
+ - custom scalars serialize through the [scalar registry](scalars.md)
130
+
131
+ **Input objects**: when an operation's only variable is a required input
132
+ object (the Relay convention), the input's fields flatten straight into
133
+ `execute`'s kwargs — no wrapper at the call site:
134
+
135
+ ```graphql
136
+ mutation($input: AdoptionInput!) { adopt(input: $input) { ... } }
137
+ ```
138
+
139
+ ```ruby
140
+ AdoptQuery.execute!(name: "Rex", species: "DOG", nickname: "Rexy")
141
+ ```
142
+
143
+ The wrapping level is rebuilt on the wire, and each field type-checks
144
+ exactly like a variable would. Operations with more than one variable (or
145
+ a nullable input) keep the variable-per-kwarg surface — there the input
146
+ kwarg accepts the generated `T::Struct` or a plain hash (`.coerce`
147
+ normalizes underscored Symbol/String keys; enums accept wire values;
148
+ nested inputs accept hashes; unknown keys raise with a spellchecked
149
+ hint rather than silently dropping):
150
+
151
+ ```ruby
152
+ AdoptQuery.execute!(input: { name: "Rex", species: "DOG" }, detail: true)
153
+ ```
154
+
155
+ The structs themselves are module-level (`AdoptQuery::AdoptionInput`) with
156
+ `serialize` (aliased as `to_h`) producing the wire hash — optional fields
157
+ default nil and stay off the wire. Nested inputs work (dependencies emit
158
+ first), including recursive ones — Hasura's self-referential `bool_exp`
159
+ filters generate cleanly (`_and:`/`_not:` fields typed as the struct
160
+ itself), so variable-driven filtering works:
161
+
162
+ ```ruby
163
+ where = mod::PokemonBoolExp.coerce(
164
+ _and: [{ name: { _like: "%chu" } }, { _not: { name: { _eq: "raichu" } } }],
165
+ )
166
+ mod.execute!(where:)
167
+ ```
168
+
169
+ ## Selections
170
+
171
+ - **Fragments** — inline fragments and named spreads flatten into the
172
+ selection; type conditions match exact names or interfaces/unions the type
173
+ belongs to.
174
+ - **Unions and interfaces** — when the selection *varies by concrete
175
+ type*, each abstract site emits a module: one member struct per
176
+ possible type, `Type = T.type_alias { T.any(...) }`, and a `from_h`
177
+ dispatching on `__typename` — which generation therefore *requires* in
178
+ the selection (the wire response carries no type tag unless you ask).
179
+ Two narrower shapes skip the dispatch (and the `__typename`) entirely:
180
+ interface-level fields only → one shared struct; a single `... on X`
181
+ condition and nothing else → `X`'s struct, always nilable — a
182
+ non-matching runtime type comes back as `nil`, so narrowing doubles as
183
+ filtering.
184
+ - **`@skip` / `@include`** — a directive-conditional field may be absent from
185
+ the response regardless of schema nullability, so its generated type is
186
+ always nilable.
187
+ - **Aliases** — result keys follow aliases; props are the underscored alias.
188
+
189
+ Props are always snake_case (`nameWithOwner` → `name_with_owner`). Reaching
190
+ for the wire name is a classic stumble, so it fails helpfully at both
191
+ layers: `srb tc` flags it statically, and at runtime (consoles, dynamic
192
+ mode) the struct raises a NoMethodError naming the prop that does exist —
193
+ `use 'name_with_owner'` for the exact wire name, `did you mean ...?` for
194
+ a near-miss typo in either casing.
195
+
196
+ ## Naming
197
+
198
+ Module names derive from the operation name (`query GetPerson` → `GetPerson`);
199
+ `GraphWeaver.parse` on a `.graphql` file derives from the file name
200
+ (`person.graphql` → `PersonQuery`). Pass `module_name:`/`name:` to override.
201
+ `Codegen.generate` requires a deliberate name for anonymous operations; dynamic
202
+ `parse` defaults to `Query` (its constants are container-scoped, so collisions
203
+ are impossible).
204
+
205
+ Nested struct names come from GraphQL type names, disambiguated one level by
206
+ field name on collision.
207
+
208
+ ## Clients
209
+
210
+ A client is anything with `execute(query, variables:)` whose result `to_h`s
211
+ into `{"data" => ..., "errors" => ...}`. Resolution: per call → per module →
212
+ baked constant → `GraphWeaver.client` — the
213
+ canonical list lives in [transports](transports.md#client-resolution).
214
+
215
+ Generate *without* a baked constant when you want modules to follow the
216
+ app default (`GraphWeaver.client =` in an initializer) — that's also what
217
+ lets [testing's auto_fake](testing.md) swap in a fake per example.
218
+
219
+ ## Dynamic mode
220
+
221
+ `GraphWeaver.parse` generates + evals in one step (no build artifact, evaled
222
+ into an anonymous container — no global constants leak). Same runtime
223
+ semantics; invisible to `srb tc`, so prefer the build step where static
224
+ checking matters. `GraphWeaver.execute(schema:, query:, variables: {})` is
225
+ the one-shot form.
226
+
227
+ Generated source is eval'd, so inputs are validated: module names must be
228
+ constant names, and query heredocs can't be terminated early. Still: queries
229
+ are code — don't feed untrusted strings to parse.
@@ -0,0 +1,134 @@
1
+ # Getting started: the production path (Rails)
2
+
3
+ The setup that ships: queries live as `.graphql` files, generation writes
4
+ `# typed: strict` Ruby you check in, and CI fails when anything drifts.
5
+ Mostly copy/paste. (Exploring an API from a console instead? Start with
6
+ [dynamic mode](real_world.md) — no build step.)
7
+
8
+ Rails is assumed below; the [non-Rails note](#not-rails) at the bottom
9
+ covers the one difference.
10
+
11
+ ## 1. Install
12
+
13
+ ```ruby
14
+ # Gemfile
15
+ gem "graph_weaver"
16
+ ```
17
+
18
+ ## 2. Bootstrap the schema dump
19
+
20
+ Codegen reads a schema dump at `app/graphql/schema.json`
21
+ (`GraphWeaver.schema_path`). You never write this file by hand —
22
+ `cache: true` writes it on first introspection. Bootstrap once from a
23
+ console:
24
+
25
+ ```ruby
26
+ GraphWeaver.new("https://api.example.com/graphql", auth: ENV["API_TOKEN"], cache: true).schema
27
+ ```
28
+
29
+ Skip this step and the generate task tells you exactly that — the error
30
+ message is the documentation. Prefer PR-reviewable diffs? `cache: :graphql`
31
+ writes SDL instead of introspection JSON; both generate identical code.
32
+
33
+ Note `cache:`/`ttl:` apply only to url clients — a schema source (a live
34
+ class or a dump) never introspects, so passing them raises.
35
+
36
+ ## 3. Wire the client
37
+
38
+ ```ruby
39
+ # config/initializers/graph_weaver.rb
40
+ GraphWeaver.client = GraphWeaver.new(
41
+ "https://api.example.com/graphql",
42
+ auth: ENV["API_TOKEN"],
43
+ cache: true, # reuses the committed dump; delete the file to re-introspect
44
+ )
45
+
46
+ # custom scalars/enums/type helpers — register globally, so the rake
47
+ # tasks bake them into generated source
48
+ GraphWeaver.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
49
+ ```
50
+
51
+ `GraphWeaver.client =` is the load-bearing line: generated modules
52
+ without a baked transport resolve to it at execute time (the full
53
+ [resolution order](transports.md#client-resolution)). The generated
54
+ modules themselves load at boot automatically (the Railtie requires
55
+ everything under `generated_path`, after your initializers run) —
56
+ outside Rails, call `GraphWeaver.load_generated!` wherever your app
57
+ boots.
58
+
59
+ ## 4. Rake tasks — nothing to do
60
+
61
+ In Rails the `graph_weaver:*` tasks register themselves (a Railtie), and
62
+ they depend on `:environment`, so your initializer — and its
63
+ registrations, which are baked into generated source — runs first.
64
+ Outside Rails, add `require "graph_weaver/tasks"` to your Rakefile.
65
+
66
+ ## 5. Write a query, generate, commit
67
+
68
+ ```graphql
69
+ # app/graphql/queries/person.graphql
70
+ query($id: ID!) {
71
+ person(id: $id) {
72
+ name
73
+ birthday
74
+ }
75
+ }
76
+ ```
77
+
78
+ ```sh
79
+ rake graph_weaver:generate # writes app/graphql/generated/person_query.rb
80
+ ```
81
+
82
+ Commit the schema dump and the generated files. Generated code is
83
+ reviewed like any other code — and never edited by hand.
84
+
85
+ ```ruby
86
+ PersonQuery.execute!(id: "1").person&.name # typed, via GraphWeaver.client
87
+ ```
88
+
89
+ ## 6. Test against fakes
90
+
91
+ ```ruby
92
+ # spec/support/graph_weaver.rb
93
+ require "graph_weaver/rspec"
94
+ ```
95
+
96
+ That's the whole setup: the schema auto-locates from the committed dump
97
+ and `auto_fake` defaults on, so every query in every example executes
98
+ against a seeded, schema-correct `FakeClient` — no server, no stubs,
99
+ and `rspec --seed 1234` reproduces the fake data along with test order.
100
+ Pin values with `overrides:`, simulate failures with `Failure.*`, opt
101
+ out with `config.auto_fake = false` — see [testing](testing.md).
102
+
103
+ ## 7. Verify in CI
104
+
105
+ ```sh
106
+ rake graph_weaver:verify # generated code fresh? fails on any drift
107
+ rake graph_weaver:schema:verify # server drifted? re-introspects and compares
108
+ ```
109
+
110
+ Two different questions. `graph_weaver:verify` checks that the committed
111
+ generated files match what the current schema + queries + registrations
112
+ would produce — run it in every CI build. `graph_weaver:schema:verify`
113
+ asks whether the *server* has moved since the dump was taken — it needs
114
+ network, a dump with a recorded source url (introspected dumps have one),
115
+ and `GRAPHWEAVER_AUTH` for private APIs; run it on a schedule and refresh
116
+ with `rake graph_weaver:schema:refresh`.
117
+
118
+ ## Sorbet, with or without
119
+
120
+ `sorbet-runtime` is a hard dependency, so generated `T::Struct`s and sigs
121
+ enforce at runtime in every app — no Sorbet setup required on your end.
122
+ The *static* layer (`srb tc` flagging a typo'd field before anything
123
+ runs) applies only when your app runs Sorbet, and only to checked-in
124
+ generated files — dynamic `parse` is invisible to `srb tc`. Everything
125
+ works without Sorbet; codegen plus Sorbet is what moves type errors from
126
+ runtime to CI.
127
+
128
+ ## Not Rails?
129
+
130
+ Everything above works the same, minus the Railtie conveniences: add
131
+ `require "graph_weaver/tasks"` to your Rakefile yourself, and — since
132
+ there's no `:environment` hook to run your registrations — require the
133
+ file that does them from the Rakefile too. `GraphWeaver.load_generated!`
134
+ goes wherever your app boots instead of an initializer.
data/docs/logging.md ADDED
@@ -0,0 +1,33 @@
1
+ # Logging
2
+
3
+ Silent by default. Point `GraphWeaver.logger` at anything
4
+ stdlib-Logger-compatible and the whole flow narrates itself — in Rails
5
+ the railtie wires `Rails.logger` automatically (set
6
+ `GraphWeaver.logger = nil` in an initializer to opt out):
7
+
8
+ ```ruby
9
+ GraphWeaver.logger = Logger.new($stdout, level: Logger::INFO)
10
+ ```
11
+
12
+ What logs at which level — pick the level, get the story:
13
+
14
+ | Level | What you see |
15
+ |-------|--------------|
16
+ | `debug` | the wire: query + variables per call (long queries truncated), response status/bytes, request timing, connection open/drop, dynamically parsed modules |
17
+ | `info` | schema introspection (with timing) and cache hits/misses, generated files written, query modules loaded |
18
+ | `warn` | every GraphWeaver error raised — `TransportError`, `ServerError`, `QueryError`, `ValidationError`, `TypeError` |
19
+
20
+ Every line carries `graph_weaver` as the progname, so formatter-based
21
+ filtering works out of the box. Wire lines are tagged
22
+ `[req 3 FilteredPokemon]` — a per-process request id plus the operation
23
+ name — so a request's lines stay paired when threads interleave.
24
+
25
+ Debugging a misbehaving integration is the intended use: crank to
26
+ `Logger::DEBUG` and you'll see exactly what went on the wire, what came
27
+ back, whether the schema came from cache or a live introspection, and
28
+ which connection served it.
29
+
30
+ **PII note**: queries, variables, and response sizes appear at debug
31
+ only — variables can carry user data, so keep production loggers at
32
+ info or above (or scrub in your formatter). Auth headers never log at
33
+ any level.
@@ -0,0 +1,53 @@
1
+ # Against a real API
2
+
3
+ The exploratory tour: point a client at a live endpoint and go, no build
4
+ step — ideal for consoles, spikes, and getting a feel for an API. What
5
+ ships is the checked-in codegen path in the [getting started](getting_started.md);
6
+ this page is how you get there (the `parse` below becomes a `.graphql`
7
+ file plus `rake graph_weaver:generate`, everything else stays).
8
+
9
+ Everything hangs off a client — transport, schema, and scalars for one
10
+ server. GitHub's API, end to end:
11
+
12
+ ```ruby
13
+ require "graph_weaver"
14
+
15
+ # transport + auth in one object (see docs/transports.md for retries and
16
+ # advanced setup). cache: true dumps the schema at GraphWeaver.schema_path
17
+ # on first introspection — the same file rake graph_weaver:generate reads —
18
+ # and any fresh dump already present is reused regardless of format. The
19
+ # extension picks the format: .json verbatim, .graphql SDL (reviewable
20
+ # diffs) — or say cache: :graphql. Introspected dumps record their source
21
+ # url in a header, so a stale dump says where it came from.
22
+ github = GraphWeaver.new("https://api.github.com/graphql", auth: `gh auth token`.strip, cache: true)
23
+
24
+ # map GitHub's DateTime scalar onto Time (cast inferred from Time.parse) —
25
+ # scoped to this client; GraphWeaver.register_scalar sets the global default
26
+ github.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
27
+
28
+ RepoQuery = github.parse(<<~GRAPHQL)
29
+ query($owner: String!, $name: String!) {
30
+ repository(owner: $owner, name: $name) {
31
+ nameWithOwner
32
+ createdAt
33
+ stargazerCount
34
+ }
35
+ }
36
+ GRAPHQL
37
+
38
+ repo = RepoQuery.execute!(owner: "dpep", name: "graph_weaver").repository
39
+ repo&.name_with_owner # => "dpep/graph_weaver"
40
+ repo&.created_at # => 2026-07-07 ... (a real Time)
41
+ repo&.stargazer_count # => Integer
42
+ ```
43
+
44
+ Clients are independent — build one per server, each with its own
45
+ transport, schema, and scalar mappings. The introspection step (seconds
46
+ on a big API) happens lazily on first `schema`/`parse` and caches per
47
+ `cache:`/`ttl:`; for finer control the pieces are all public
48
+ (`GraphWeaver::SchemaLoader.introspect(transport, cache:, ttl:)`, or cache
49
+ `introspect(transport).to_json` in Rails.cache and `SchemaLoader.load` it).
50
+
51
+ The same flow runs as one-off integration specs against the live GitHub
52
+ and Countries APIs — `make integration` (network; GitHub auth via
53
+ `gh auth token` or `GITHUB_TOKEN`).
data/docs/scalars.md ADDED
@@ -0,0 +1,183 @@
1
+ # Custom scalars
2
+
3
+ Teach the generator how a GraphQL custom scalar deserializes into a rich
4
+ Ruby object (and serializes back when used as a variable). A field typed
5
+ `Money` then generates `const :price, T.nilable(Money)` and casts with
6
+ `Money.parse(...)` inline — no runtime reflection:
7
+
8
+ ```ruby
9
+ GraphWeaver.register_scalar("Money", Money, requires: "bigdecimal")
10
+ ```
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.
16
+
17
+ Pass a real class as `type:` and the cast/serialize are **inferred** from it by
18
+ probing the deserialize side and pairing its serializer:
19
+
20
+ | the class defines | cast | serialize |
21
+ |-------------------|---------------|----------------|
22
+ | `.parse` | `Type.parse(v)` | `v.to_s` |
23
+ | `.load` | `Type.load(v)` | `Type.dump(v)` |
24
+
25
+ so the common case needs nothing more. Probing the *deserialize* side is
26
+ deliberate — every object has `#to_s`, so inferring off it would wrongly wrap
27
+ plain types like `String`/`Integer`; requiring a `.parse`/`.load` the type
28
+ actually defines avoids that (and is why the built-in scalars — `Date`, `ID`,
29
+ `Int`, and friends, pre-registered and detailed below — can be registered with
30
+ their real class constants). Override explicitly when you need to:
31
+
32
+ - a `Symbol` method name, nothing to misspell: `cast: :load` → `Money.load(expr)`,
33
+ `serialize: :to_json` → `expr.to_json`
34
+ - a `Proc` for anything a method name can't express: `cast: ->(expr) { "Money.new(#{expr})" }`
35
+ - `:itself` to force pass-through, opting out of inference (rare)
36
+
37
+ `type:` also accepts a plain string (`"BigDecimal"`) when you'd rather not
38
+ reference the class. `requires:` (a string or array) names files emitted as
39
+ `require`s atop the generated source so the cast/type resolve. When `type:` is
40
+ a real class (so the runtime is loaded), each path is also `require`d at
41
+ registration — a typo fails now, not in the generated file.
42
+
43
+ Pass `coerce: true` to let a variable of this scalar accept **either** the value
44
+ object **or** its raw input, normalizing the latter through the cast:
45
+
46
+ ```ruby
47
+ GraphWeaver.register_scalar("Money", Money, coerce: true)
48
+ # generated execute now takes T.any(Money, String); "12.00" is parsed
49
+ StoreQuery.execute(budget: "12.00") # Money.parse("12.00") under the hood
50
+ StoreQuery.execute(budget: Money.new(1200)) # passed straight through
51
+ ```
52
+
53
+ Bad input still explodes (the cast raises), so some safety survives; coercion
54
+ needs both a cast and a serialize. Off by default — the strict typed kwarg is the norm.
55
+
56
+ `coerce:` also takes a **Symbol** naming a conversion method, for built-ins where
57
+ a plain method is the whole story — `coerce: :to_f` makes a variable accept
58
+ `5`/`"5"` and `.to_f` it, sending a native number (not `"5.0"`) on the wire. The
59
+ convertible built-ins already know theirs (`Float`→`:to_f`, `Int`→`:to_i`,
60
+ `ID`/`String`→`:to_s`), so rather than opting in each, flip the default:
61
+
62
+ ```ruby
63
+ GraphWeaver.auto_coerce = true
64
+ ```
65
+
66
+ Resolved lazily at generation time (set it any time before you generate),
67
+ it gives convertible built-ins their conversion and any scalar with a full
68
+ cast/serialize pair (`Date`, your `Money`) parse-style coercion; an explicit
69
+ `coerce:` on a registration always wins. `Boolean` has no lossless
70
+ one-method conversion, so it stays strict.
71
+
72
+ The built-in scalars (`Date`, `ID`, `Int`, …) are pre-registered through the
73
+ same path (`Date` even carries its own `require "date"`), so a later
74
+ `register_scalar` overrides them; `GraphWeaver.reset_scalars!` restores the
75
+ defaults (`reset_scalars!(coerce: true)` restores them coercible) and
76
+ `clear_scalars!` empties the registry. Register before generating — it's a
77
+ codegen-time concern, baked into the emitted source.
78
+
79
+ ## Enums: map onto your own T::Enum
80
+
81
+ By default each generated module grows its own `T::Enum` per GraphQL
82
+ enum — `AddPetQuery::Species`, `SearchQuery::Result::...::Species`, one
83
+ per module that touches it. That's fine until your app has its own
84
+ domain enum, and then the boundary shuffle starts:
85
+
86
+ ```ruby
87
+ # your domain already speaks PetKind — it's in your models, your
88
+ # ActiveRecord enum column, your case statements
89
+ class PetKind < T::Enum
90
+ enums { Cat = new("cat"); Dog = new("dog") }
91
+ end
92
+
93
+ # without a mapping, every call site converts by hand, in both directions
94
+ kind = PetKind.deserialize(pet.species.serialize.downcase) # response -> domain
95
+ AddPetQuery.execute!(species: kind.serialize.upcase) # domain -> wire
96
+ ```
97
+
98
+ Two enums for one concept, glue at every crossing, and each generated
99
+ module has its *own* incompatible `Species`, so a pet from `SearchQuery`
100
+ and a pet from `AddPetQuery` don't even compare. Register the mapping
101
+ once and the seam disappears — generated code speaks your enum
102
+ everywhere, casting wire values in and serializing members out:
103
+
104
+ ```ruby
105
+ GraphWeaver.register_enum("Species", PetKind) # global
106
+ api.register_enums("Species" => PetKind, "Role" => Role) # or per client, in bulk
107
+
108
+ pet.species # => PetKind::Dog — compare, case, persist directly
109
+ pet.species == other_pet.species # same type across every query
110
+ AddPetQuery.execute!(species: PetKind::Cat) # or "CAT" — members and wire values both work
111
+ ```
112
+
113
+ **When to reach for it**: the enum has a life outside the API — it's
114
+ persisted, matched in business logic, or shared across queries. **When
115
+ not to bother**: display-only values you read and forget; the per-module
116
+ generated enums are self-contained and need zero setup.
117
+
118
+ The mapping is inferred by name (`"CAT"` ↔ `PetKind::Cat`,
119
+ case/underscore-insensitive against each member's serialized value), so
120
+ aligned enums need only the one line. When names diverge, `map:` pins the
121
+ exceptions and merges over inference:
122
+
123
+ ```ruby
124
+ GraphWeaver.register_enum("Species", PetKind, map: { "FELINE" => PetKind::Cat })
125
+ ```
126
+
127
+ Two safety properties do the real work:
128
+
129
+ - **Exhaustiveness at generation**: every value the schema declares must
130
+ resolve to a member, or generation fails naming the gaps
131
+ (`PetKind has no member for Species value(s) DOG — add them, pin with
132
+ map:, or absorb with fallback:`). Your enum drifting from the server's
133
+ is caught at `rake graph_weaver:generate`, not in production.
134
+ - **`fallback:` for forward-compat**: `fallback: PetKind::Unknown` makes
135
+ *casting* absorb wire values the server added after you generated —
136
+ responses keep flowing instead of raising. Inputs stay strict either
137
+ way: a typo'd input is your bug, not drift.
138
+
139
+ The translation tables are emitted into the generated source
140
+ (`SPECIES_FROM_WIRE` / `SPECIES_TO_WIRE`) — reviewable in the diff, no
141
+ runtime registry. And because registration can be client-scoped, two
142
+ servers with different ideas of `"Species"` can map onto different (or
143
+ the same) domain enums without touching each other.
144
+
145
+ ## Type helpers: your logic on generated structs
146
+
147
+ Derived values (display names, emoji, predicates) belong next to the
148
+ data but not *in* it — rewriting wire values on the way in destroys the
149
+ raw truth. Register a plain module and every struct generated from that
150
+ GraphQL type includes it, whatever query it appears in:
151
+
152
+ ```ruby
153
+ module PetHelpers
154
+ def adult? = birthday && birthday < Date.today << 24
155
+ def display_name = adult? ? "#{name} 🦴" : "#{name} 🐶"
156
+ end
157
+
158
+ GraphWeaver.register_type("Pet", PetHelpers) # or api.register_type(...)
159
+
160
+ pet.display_name # => "Shelby 🦴"
161
+ pet.name # => "Shelby" — the wire value stays honest
162
+ ```
163
+
164
+ Because the include is emitted into the generated source, `srb tc` checks
165
+ the helpers against each query's actual selection — a helper that calls
166
+ `birthday` on a query that never selected it is a **static error**, which
167
+ doubles as selection-completeness checking. Registrations are additive
168
+ (global plus client-scoped stack), and fakes/cassettes get the behavior
169
+ automatically since it lives on the struct.
170
+
171
+ For quick decoration, build the mixin inline — the block is
172
+ `module_eval`'d into a fresh module auto-named under
173
+ `GraphWeaver::TypeHelpers` so generated files can reference it:
174
+
175
+ ```ruby
176
+ api.register_type("Pet") do
177
+ def display_name = "#{name} 🐶"
178
+ end
179
+ ```
180
+
181
+ Same runtime behavior, one caveat: `srb tc` can't see into block-defined
182
+ methods, so prefer a named module where static checking matters —
183
+ complexity on demand.