graph_weaver 0.1.0 → 0.2.1

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +256 -0
  4. data/Gemfile.lock +32 -2
  5. data/Makefile +7 -2
  6. data/NOTES.md +1 -1
  7. data/PLAN.md +41 -1
  8. data/README.md +56 -41
  9. data/docs/cassettes.md +76 -0
  10. data/docs/errors.md +37 -9
  11. data/docs/generated_modules.md +178 -29
  12. data/docs/getting_started.md +136 -0
  13. data/docs/logging.md +33 -0
  14. data/docs/real_world.md +27 -20
  15. data/docs/scalars.md +122 -8
  16. data/docs/testing.md +55 -38
  17. data/docs/transports.md +124 -0
  18. data/graph_weaver.gemspec +7 -1
  19. data/lib/graph_weaver/client.rb +201 -0
  20. data/lib/graph_weaver/codegen/emit.rb +315 -76
  21. data/lib/graph_weaver/codegen/enum_type.rb +149 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +123 -68
  23. data/lib/graph_weaver/codegen/scalar_type.rb +53 -35
  24. data/lib/graph_weaver/codegen.rb +272 -101
  25. data/lib/graph_weaver/errors.rb +37 -25
  26. data/lib/graph_weaver/hints.rb +63 -0
  27. data/lib/graph_weaver/inflect.rb +2 -1
  28. data/lib/graph_weaver/input_struct.rb +59 -0
  29. data/lib/graph_weaver/logging.rb +41 -0
  30. data/lib/graph_weaver/railtie.rb +30 -0
  31. data/lib/graph_weaver/retry.rb +97 -0
  32. data/lib/graph_weaver/rspec.rb +17 -14
  33. data/lib/graph_weaver/schema_loader.rb +156 -20
  34. data/lib/graph_weaver/selection.rb +3 -3
  35. data/lib/graph_weaver/tasks.rb +71 -0
  36. data/lib/graph_weaver/testing/cassette.rb +42 -21
  37. data/lib/graph_weaver/testing/failure.rb +22 -22
  38. data/lib/graph_weaver/testing/{fake_executor.rb → fake_client.rb} +13 -13
  39. data/lib/graph_weaver/testing/values.rb +2 -2
  40. data/lib/graph_weaver/testing.rb +31 -15
  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 +289 -38
  46. metadata +74 -5
  47. data/lib/graph_weaver/faraday_executor.rb +0 -61
  48. data/lib/graph_weaver/http_executor.rb +0 -44
  49. data/lib/graph_weaver/testing/rspec.rb +0 -5
data/docs/cassettes.md ADDED
@@ -0,0 +1,76 @@
1
+ # Cassettes: capture and replay
2
+
3
+ Cassettes record real API responses and replay them in tests — above the
4
+ transport (a client wrapping a client), so there's no HTTP
5
+ interception and they work identically over HTTP, Faraday, or in-process
6
+ execution. A cassette is a YAML file of `{query, variables, response}`
7
+ entries, matched on the normalized query + variables.
8
+
9
+ ## The workflow
10
+
11
+ ```ruby
12
+ # spec: replay when the cassette exists, record against `live` when not
13
+ cassette = GraphWeaver::Testing::Cassette.use("github", client: live)
14
+ result = RepoQuery.execute!(cassette, owner: "dpep", name: "graph_weaver")
15
+ ```
16
+
17
+ 1. **Record** — first run hits the live API and writes
18
+ `spec/cassettes/github.yml` (`Testing.config.cassette_dir` resolves bare
19
+ names).
20
+ 2. **Anonymize** — cassettes hold real data; scrub before committing (below).
21
+ 3. **Commit** — tests now run offline, fast, deterministic.
22
+ 4. **Re-record** when the API's real behavior changes:
23
+
24
+ ```sh
25
+ GRAPHWEAVER_RECORD=1 bundle exec rspec # every Cassette.use records afresh
26
+ ```
27
+
28
+ (`Testing.config.record = true` is the programmatic equivalent.)
29
+
30
+ Replaying an unrecorded request raises `MissingRecording` with the query
31
+ and the path — no silent fabrication.
32
+
33
+ ## Anonymization
34
+
35
+ Anonymizing rewrites recorded values through the same engine
36
+ [FakeClient](testing.md) uses, while preserving everything that makes
37
+ the recording faithful:
38
+
39
+ | preserved | replaced |
40
+ |-----------|----------|
41
+ | shape: keys, list lengths, null positions | strings (semantically: emails look like emails) |
42
+ | enums, booleans, `__typename` | numbers, dates |
43
+ | id *relationships* (same original id → same fake id) | the id values themselves |
44
+
45
+ Three ways to run it:
46
+
47
+ ```ruby
48
+ # 1. as recordings happen — assertions you write against the recording
49
+ # run hold on replay, and real data never touches disk
50
+ GraphWeaver::Testing.configure do |config|
51
+ config.schema = MySchema
52
+ config.anonymize = true
53
+ end
54
+
55
+ # 2. after the fact, per cassette
56
+ GraphWeaver::Testing::Cassette.new("spec/cassettes/github.yml").anonymize!(schema:)
57
+ ```
58
+
59
+ ```sh
60
+ # 3. the whole cassette_dir at once
61
+ rake graph_weaver:cassettes:anonymize
62
+ ```
63
+
64
+ Anonymization needs the schema (it walks each recorded query's selections
65
+ to know which values are enums, dates, ids...). Variables are NOT
66
+ anonymized — they're the replay matching key; don't record with secret
67
+ variables.
68
+
69
+ ## When to use what
70
+
71
+ - **FakeClient** — no recording needed; schema-correct random data.
72
+ Best default for unit tests.
73
+ - **Cassettes** — real response *shapes* from a real API (pagination
74
+ quirks, actual union members, servers' null habits). Best for
75
+ integration-ish tests and regression pinning.
76
+ - **Anonymized cassettes** — cassette fidelity, committable without PII.
data/docs/errors.md CHANGED
@@ -30,7 +30,8 @@ it failed:
30
30
  | `TransportError` | never reached the server — DNS, connection refused, TLS, timeout |
31
31
  | `ServerError` | reached it, non-2xx HTTP — `#status`, `#body` |
32
32
  | `QueryError` | 200 body with top-level GraphQL errors — `#errors`, `#data`, `#extensions`, `#codes` |
33
- | `ValidationError` | build time: the query didn't validate against the schema (also an `ArgumentError`) |
33
+ | `TypeError` | the response wouldn't cast into the generated structs `#struct`, `#cause` |
34
+ | `ValidationError` | build time: the query didn't validate against the schema |
34
35
 
35
36
  ```ruby
36
37
  begin
@@ -38,22 +39,37 @@ begin
38
39
  rescue GraphWeaver::TransportError
39
40
  retry # network blip
40
41
  rescue GraphWeaver::ServerError => e
41
- raise if e.status < 500 # backoff on 5xx only
42
+ e.status >= 500 ? backoff : raise # retry 5xx; a 4xx is our bug
42
43
  rescue GraphWeaver::QueryError => e
43
44
  e.codes.include?("THROTTLED") ? backoff : raise
44
45
  end
45
46
  ```
46
47
 
47
- What counts as a `TransportError` is an **extensible set** — each transport
48
- seeds its own network exceptions (`Errno::*`, `SocketError`, timeouts, TLS; the
49
- Faraday executor adds its own), and you can register more so a custom adapter's
50
- or connection pool's failure gets the same treatment:
48
+ Or skip the hand-rolling `Retry` wraps any transport with
49
+ configurable retries:
51
50
 
52
51
  ```ruby
53
- GraphWeaver.register_transport_error(ConnectionPool::TimeoutError)
54
- GraphWeaver.transport_errors << MyAdapter::ResetError # it's just a Set
52
+ transport = GraphWeaver::Retry.new(
53
+ GraphWeaver::Transport::HTTP.new(url),
54
+ tries: 5, # total attempts
55
+ backoff: :exponential, # or :linear, or ->(attempt) { seconds }
56
+ base: 0.5, max: 30, # seconds, clamped at max:
57
+ jitter: true, # randomize each delay by 50-100%
58
+ retry_codes: ["THROTTLED"], # also retry GraphQL errors by code
59
+ )
55
60
  ```
56
61
 
62
+ Defaults match the rescue block above: transport failures always retry,
63
+ `ServerError` only on 5xx (a 4xx is your bug — retrying won't fix it;
64
+ override with `retry_if:`), and GraphQL-level codes only when listed in
65
+ `retry_codes:`. Exhausting `tries:` re-raises the last error.
66
+
67
+ Two deliberate exceptions live *outside* the hierarchy, because typed
68
+ kwargs should fail like any Ruby method call: a wrong-typed variable
69
+ raises sorbet-runtime's `TypeError` ("Parameter 'page': Expected type
70
+ T.nilable(Integer), got type String"), and a missing required variable
71
+ raises a plain `ArgumentError` ("missing keyword: :id").
72
+
57
73
  Business/validation failures returned *as data* (Shopify-style `userErrors { field
58
74
  message code }`) aren't errors here — they're just fields you selected, so they
59
75
  deserialize onto `response.data` like anything else and you inspect them there.
@@ -61,6 +77,18 @@ deserialize onto `response.data` like anything else and you inspect them there.
61
77
  The one-shot `GraphWeaver.execute` / `execute!` mirror this: `execute` returns
62
78
  the envelope, `execute!` the result-or-raise.
63
79
 
80
+ ## Extending TransportError
81
+
82
+ What counts as a `TransportError` is an **extensible set** — each transport
83
+ seeds its own network exceptions (`Errno::*`, `SocketError`, timeouts, TLS; the
84
+ Faraday transport adds its own), and you can register more so a custom adapter's
85
+ or connection pool's failure gets the same treatment:
86
+
87
+ ```ruby
88
+ GraphWeaver.register_transport_error(ConnectionPool::TimeoutError)
89
+ GraphWeaver.transport_errors << MyAdapter::ResetError # it's just a Set
90
+ ```
91
+
64
92
 
65
93
  ## Programmatic surfacing
66
94
 
@@ -102,5 +130,5 @@ When wire data disagrees with the types the schema promised at generation time
102
130
  (a nil where non-null was declared, a malformed scalar, an unknown enum value),
103
131
  casting raises `GraphWeaver::TypeError` naming the failing generated struct,
104
132
  with the original exception as `#cause`. Simulate one in tests with
105
- `FakeExecutor.new(schema:, corrupt: "Person.birthday")` — see
133
+ `GraphWeaver::Testing::FakeClient.new(schema:, corrupt: "Person.birthday")` — see
106
134
  [testing](testing.md).
@@ -4,6 +4,101 @@
4
4
  Ruby module. Everything `srb tc` knows about your query results comes from this
5
5
  file — there is no runtime schema, no lazy wrapper, no reflection.
6
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/
25
+ inputs.rb # manifest: requires + forward declarations
26
+ inputs/ # one file per shared type (input structs, enums)
27
+ *_query.rb # one module per query — generated, checked in, never edited
28
+ ```
29
+
30
+ The schema dump is step 0 — codegen reads it, never a live endpoint.
31
+ `cache: true` on a url client writes it on first introspection
32
+ (`GraphWeaver.new(url, cache: true).schema` in a console bootstraps it);
33
+ generating without one fails pointing at exactly that.
34
+
35
+ Rake tasks (self-registering in Rails; elsewhere add
36
+ `require "graph_weaver/tasks"` to your Rakefile):
37
+
38
+ ```sh
39
+ rake graph_weaver:generate # queries_path -> generated_path
40
+ rake graph_weaver:verify # fail if anything is stale — run in CI
41
+ ```
42
+
43
+ Scalar/enum/type registrations are baked into generated source, so they
44
+ must run before the tasks do. In Rails they will — the tasks depend on
45
+ `:environment`, which runs your initializers. Outside Rails, require the
46
+ file that does your registrations from the Rakefile yourself.
47
+
48
+ Or call the same APIs directly:
49
+
50
+ ```ruby
51
+ schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
52
+ GraphWeaver.generate!(schema:) # write the modules
53
+ GraphWeaver.verify_generated!(schema:) # the freshness guard, one line in a spec
54
+ ```
55
+
56
+ In Rails, loading is automatic — the Railtie requires every file under
57
+ `generated_path` at boot, after your initializers (so registrations run
58
+ first). Elsewhere it's explicit, factory_bot-style:
59
+
60
+ ```ruby
61
+ GraphWeaver.load_generated! # require every file under generated_path
62
+ ```
63
+
64
+ The conventional paths are lists (entries may be globs — the default
65
+ includes `app/graphql/*/generated`, so per-schema layouts load too).
66
+ Append extra locations (a test-only schema, an engine's queries) and
67
+ every loader walks them all:
68
+
69
+ ```ruby
70
+ # e.g. in spec/support/graph_weaver.rb
71
+ GraphWeaver.generated_paths << "spec/support/graphql/generated"
72
+ GraphWeaver.queries_paths << "spec/support/graphql/queries"
73
+ ```
74
+
75
+ The singular accessors (`generated_path` etc.) read and replace the
76
+ first entry — the default target for `generate!` and the rake tasks.
77
+
78
+ (Plain requires, not Zeitwerk: Zeitwerk would expect
79
+ `Generated::PersonQuery` from `generated/person_query.rb`, and generated
80
+ code only changes on regeneration — restart, like a schema migration.)
81
+
82
+ Regenerate when: a query changes, the schema changes (a
83
+ [`schema_stale?`](errors.md) error in production is the late signal — refresh
84
+ the schema dump and regenerate), a scalar registration changes, or GraphWeaver
85
+ itself upgrades (emission may differ across versions; `verify_generated!`
86
+ catches it).
87
+
88
+ Introspected dumps record their source url, so drift is checkable ahead
89
+ of the late signal: `rake graph_weaver:schema:verify` re-introspects the
90
+ recorded url and fails when the server has moved;
91
+ `rake graph_weaver:schema:refresh` rewrites the dump
92
+ (`GRAPHWEAVER_AUTH` supplies a token for private APIs). Don't confuse
93
+ the two verifies: `graph_weaver:verify` asks "is the generated code
94
+ fresh?" — local, every CI run; `graph_weaver:schema:verify` asks "has the
95
+ *server* drifted from the dump?" — network, needs the recorded url, run
96
+ on a schedule.
97
+
98
+ In development, skip the build entirely — `client.load_queries!` parses
99
+ every query file into modules with the same names generation would use
100
+ (see [dynamic mode](#dynamic-mode)).
101
+
7
102
  ## Anatomy
8
103
 
9
104
  ```ruby
@@ -21,9 +116,9 @@ module PersonQuery
21
116
  const :person, T.nilable(Person)
22
117
  end
23
118
 
24
- def self.executor ... # default transport (see below)
25
- def self.execute(id:, executor: self.executor) # -> GraphWeaver::Response[Result]
26
- def self.execute!(id:, executor: self.executor) # -> Result, or raises QueryError
119
+ def self.client ... # default client (see below)
120
+ def self.execute(client = nil, id:) # -> GraphWeaver::Response[Result]
121
+ def self.execute!(client = nil, id:) # -> Result, or raises QueryError
27
122
  end
28
123
  ```
29
124
 
@@ -46,40 +141,97 @@ AddPetQuery.execute!(name: "Rex", species: AddPetQuery::Species::Dog)
46
141
  - required vs optional falls out of nullability and defaults: nullable or
47
142
  defaulted variables become optional kwargs (nil is omitted from the wire,
48
143
  so server-side defaults apply)
49
- - enum variables generate module-level `T::Enum`s and serialize themselves
144
+ - enum variables generate module-level `T::Enum`s and accept the enum or
145
+ its wire value (`species: Species::Dog` or `species: "DOG"`)
50
146
  - custom scalars serialize through the [scalar registry](scalars.md)
51
147
 
52
- **Input objects** generate module-level `T::Struct`s with `serialize`
53
- (aliased as `to_h`) producing the wire hash — optional fields default nil and
54
- stay off the wire:
148
+ **Input objects**: when an operation's only variable is a required input
149
+ object (the Relay convention), the input's fields flatten straight into
150
+ `execute`'s kwargs — no wrapper at the call site:
151
+
152
+ ```graphql
153
+ mutation($input: AdoptionInput!) { adopt(input: $input) { ... } }
154
+ ```
55
155
 
56
156
  ```ruby
57
- input = AdoptQuery::AdoptionInput.new(name: "Rex", species: AdoptQuery::Species::Dog)
58
- AdoptQuery.execute!(input:)
157
+ AdoptQuery.execute!(name: "Rex", species: "DOG", nickname: "Rexy")
158
+ ```
159
+
160
+ The wrapping level is rebuilt on the wire, and each field type-checks
161
+ exactly like a variable would. Operations with more than one variable (or
162
+ a nullable input) keep the variable-per-kwarg surface — there the input
163
+ kwarg accepts the generated `T::Struct` or a plain hash (`.coerce`
164
+ normalizes underscored Symbol/String keys; enums accept wire values;
165
+ nested inputs accept hashes; unknown keys raise with a spellchecked
166
+ hint rather than silently dropping):
59
167
 
60
- # or pass a plain hash straight to execute — .coerce normalizes and type-checks it at
61
- # the boundary (underscored Symbol/String keys; enums accept wire values;
62
- # nested inputs accept hashes)
63
- AdoptQuery.execute!(input: { name: "Rex", species: "DOG" })
168
+ ```ruby
169
+ AdoptQuery.execute!(input: { name: "Rex", species: "DOG" }, detail: true)
64
170
  ```
65
171
 
66
- Nested inputs work (dependencies emit first); recursive input types are not
67
- yet supported.
172
+ In the generate! workflow, input types (and the enums they use) are
173
+ emitted **once per schema** — one file per type under
174
+ `generated/inputs/`, with `inputs.rb` as the manifest. The module is
175
+ named from the output path: the conventional layout gets
176
+ `GraphQLInputs`, while a multi-schema layout names each schema's module
177
+ after its directory (`app/graphql/github/generated` → `GithubInputs`).
178
+ Override globally with `GraphWeaver.inputs_module=` or per run with
179
+ `generate!(inputs_module:)`; opt out with
180
+ `generate!(shared_inputs: false)`. Per-type files keep schema drift
181
+ reviewable: a migration diffs exactly the types it touched, and types
182
+ the schema drops are pruned on regeneration (`verify` flags strays).
183
+ Query modules alias what they touch,
184
+ so `AdoptQuery::AdoptionInput` still works and shared types keep one
185
+ identity across modules — three filtered Hasura queries cost one ~11k-line
186
+ inputs file plus ~90 lines each, instead of ~35k lines of duplicates.
187
+ Deeply nested types live unaliased in the shared module
188
+ (`GraphQLInputs::PetFilter`). Dynamic `parse` stays self-contained.
189
+
190
+ The structs themselves are module-level (`AdoptQuery::AdoptionInput`):
191
+ typed consts plus a compact per-field `FIELDS` table that the
192
+ `GraphWeaver::InputStruct` runtime drives — `serialize` (aliased `to_h`)
193
+ produces the wire hash with nil optionals omitted, `coerce` builds from
194
+ plain hashes. The conversions ship in the generated file as data
195
+ (lambdas in the table), so a Hasura `bool_exp` pulling hundreds of input
196
+ types stays ~2 lines per field instead of unrolled methods. Nested inputs work (dependencies emit
197
+ first), including recursive ones — Hasura's self-referential `bool_exp`
198
+ filters generate cleanly (`_and:`/`_not:` fields typed as the struct
199
+ itself), so variable-driven filtering works:
200
+
201
+ ```ruby
202
+ where = mod::PokemonBoolExp.coerce(
203
+ _and: [{ name: { _like: "%chu" } }, { _not: { name: { _eq: "raichu" } } }],
204
+ )
205
+ mod.execute!(where:)
206
+ ```
68
207
 
69
208
  ## Selections
70
209
 
71
210
  - **Fragments** — inline fragments and named spreads flatten into the
72
211
  selection; type conditions match exact names or interfaces/unions the type
73
212
  belongs to.
74
- - **Unions and interfaces** — each abstract selection site emits a module:
75
- one member struct per possible type, `Type = T.type_alias { T.any(...) }`,
76
- and a `from_h` dispatching on `__typename`. Generation *requires*
77
- `__typename` in the selection so dispatch is possible.
213
+ - **Unions and interfaces** — when the selection *varies by concrete
214
+ type*, each abstract site emits a module: one member struct per
215
+ possible type, `Type = T.type_alias { T.any(...) }`, and a `from_h`
216
+ dispatching on `__typename` which generation therefore *requires* in
217
+ the selection (the wire response carries no type tag unless you ask).
218
+ Two narrower shapes skip the dispatch (and the `__typename`) entirely:
219
+ interface-level fields only → one shared struct; a single `... on X`
220
+ condition and nothing else → `X`'s struct, always nilable — a
221
+ non-matching runtime type comes back as `nil`, so narrowing doubles as
222
+ filtering.
78
223
  - **`@skip` / `@include`** — a directive-conditional field may be absent from
79
224
  the response regardless of schema nullability, so its generated type is
80
225
  always nilable.
81
226
  - **Aliases** — result keys follow aliases; props are the underscored alias.
82
227
 
228
+ Props are always snake_case (`nameWithOwner` → `name_with_owner`). Reaching
229
+ for the wire name is a classic stumble, so it fails helpfully at both
230
+ layers: `srb tc` flags it statically, and at runtime (consoles, dynamic
231
+ mode) the struct raises a NoMethodError naming the prop that does exist —
232
+ `use 'name_with_owner'` for the exact wire name, `did you mean ...?` for
233
+ a near-miss typo in either casing.
234
+
83
235
  ## Naming
84
236
 
85
237
  Module names derive from the operation name (`query GetPerson` → `GetPerson`);
@@ -92,19 +244,16 @@ are impossible).
92
244
  Nested struct names come from GraphQL type names, disambiguated one level by
93
245
  field name on collision.
94
246
 
95
- ## Executors
96
-
97
- An executor is anything with `execute(query, variables:)` whose result `to_h`s
98
- into `{"data" => ..., "errors" => ...}`. Resolution order:
247
+ ## Clients
99
248
 
100
- 1. per call: `execute(..., executor: something)`
101
- 2. per module: `PersonQuery.executor = something`
102
- 3. baked constant: `Codegen.generate(..., executor: MyApi::Executor)`
103
- 4. global: `GraphWeaver.executor` (raises helpfully when unconfigured)
249
+ A client is anything with `execute(query, variables:)` whose result `to_h`s
250
+ into `{"data" => ..., "errors" => ...}`. Resolution: per call → per module →
251
+ baked constant `GraphWeaver.client` the
252
+ canonical list lives in [transports](transports.md#client-resolution).
104
253
 
105
254
  Generate *without* a baked constant when you want modules to follow the
106
- global — that's also what lets [testing's auto_fake](testing.md) swap in a
107
- fake per example.
255
+ app default (`GraphWeaver.client =` in an initializer) — that's also what
256
+ lets [testing's auto_fake](testing.md) swap in a fake per example.
108
257
 
109
258
  ## Dynamic mode
110
259
 
@@ -0,0 +1,136 @@
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
+ GraphWeaver::Testing.configure { |config| config.auto_fake = true }
96
+ ```
97
+
98
+ The opt-in is deliberate (no surprise fakes); once on, the schema
99
+ auto-locates from the committed dump and every query in every example
100
+ executes against a seeded, schema-correct `FakeClient` — no server, no
101
+ stubs, and `rspec --seed 1234` reproduces the fake data along with test
102
+ order. Pin values with `overrides:`, simulate failures with `Failure.*`
103
+ — see [testing](testing.md).
104
+
105
+ ## 7. Verify in CI
106
+
107
+ ```sh
108
+ rake graph_weaver:verify # generated code fresh? fails on any drift
109
+ rake graph_weaver:schema:verify # server drifted? re-introspects and compares
110
+ ```
111
+
112
+ Two different questions. `graph_weaver:verify` checks that the committed
113
+ generated files match what the current schema + queries + registrations
114
+ would produce — run it in every CI build. `graph_weaver:schema:verify`
115
+ asks whether the *server* has moved since the dump was taken — it needs
116
+ network, a dump with a recorded source url (introspected dumps have one),
117
+ and `GRAPHWEAVER_AUTH` for private APIs; run it on a schedule and refresh
118
+ with `rake graph_weaver:schema:refresh`.
119
+
120
+ ## Sorbet, with or without
121
+
122
+ `sorbet-runtime` is a hard dependency, so generated `T::Struct`s and sigs
123
+ enforce at runtime in every app — no Sorbet setup required on your end.
124
+ The *static* layer (`srb tc` flagging a typo'd field before anything
125
+ runs) applies only when your app runs Sorbet, and only to checked-in
126
+ generated files — dynamic `parse` is invisible to `srb tc`. Everything
127
+ works without Sorbet; codegen plus Sorbet is what moves type errors from
128
+ runtime to CI.
129
+
130
+ ## Not Rails?
131
+
132
+ Everything above works the same, minus the Railtie conveniences: add
133
+ `require "graph_weaver/tasks"` to your Rakefile yourself, and — since
134
+ there's no `:environment` hook to run your registrations — require the
135
+ file that does them from the Rakefile too. `GraphWeaver.load_generated!`
136
+ 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.
data/docs/real_world.md CHANGED
@@ -1,30 +1,31 @@
1
1
  # Against a real API
2
2
 
3
- Everything composes for a remote endpoint introspect the schema
4
- straight off the wire, then query with typed results. GitHub's API,
5
- end to end:
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:
6
11
 
7
12
  ```ruby
8
13
  require "graph_weaver"
9
14
 
10
- executor = GraphWeaver::HttpExecutor.new(
11
- "https://api.github.com/graphql",
12
- headers: { "Authorization" => "Bearer #{`gh auth token`.strip}" },
13
- )
14
-
15
- # introspecting a big API takes seconds cache: stores the introspection
16
- # JSON in a file and reuses it for ttl: seconds. For Rails.cache/redis,
17
- # cache SchemaLoader.introspection_result(executor) (a plain Hash) instead.
18
- schema = GraphWeaver::SchemaLoader.introspect(
19
- executor,
20
- cache: "tmp/github-schema.json",
21
- ttl: 24 * 60 * 60,
22
- )
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
23
 
24
- # map GitHub's DateTime scalar onto Time (cast inferred from Time.parse)
25
- GraphWeaver.register_scalar("DateTime", type: Time, serialize: :iso8601, requires: "time")
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")
26
27
 
27
- RepoQuery = GraphWeaver.parse(schema:, executor:, query: <<~GRAPHQL)
28
+ RepoQuery = github.parse(<<~GRAPHQL)
28
29
  query($owner: String!, $name: String!) {
29
30
  repository(owner: $owner, name: $name) {
30
31
  nameWithOwner
@@ -40,7 +41,13 @@ repo&.created_at # => 2026-07-07 ... (a real Time)
40
41
  repo&.stargazer_count # => Integer
41
42
  ```
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
+
43
51
  The same flow runs as one-off integration specs against the live GitHub
44
52
  and Countries APIs — `make integration` (network; GitHub auth via
45
53
  `gh auth token` or `GITHUB_TOKEN`).
46
-