graph_weaver 0.1.0 → 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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +213 -0
  4. data/Gemfile.lock +32 -2
  5. data/Makefile +7 -2
  6. data/NOTES.md +1 -1
  7. data/PLAN.md +34 -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 +139 -29
  12. data/docs/getting_started.md +134 -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 +44 -34
  17. data/docs/transports.md +124 -0
  18. data/graph_weaver.gemspec +7 -1
  19. data/lib/graph_weaver/client.rb +200 -0
  20. data/lib/graph_weaver/codegen/emit.rb +88 -39
  21. data/lib/graph_weaver/codegen/enum_type.rb +149 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +109 -9
  23. data/lib/graph_weaver/codegen/scalar_type.rb +53 -35
  24. data/lib/graph_weaver/codegen.rb +301 -67
  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/logging.rb +41 -0
  29. data/lib/graph_weaver/railtie.rb +29 -0
  30. data/lib/graph_weaver/retry.rb +97 -0
  31. data/lib/graph_weaver/rspec.rb +19 -14
  32. data/lib/graph_weaver/schema_loader.rb +156 -20
  33. data/lib/graph_weaver/selection.rb +3 -3
  34. data/lib/graph_weaver/tasks.rb +71 -0
  35. data/lib/graph_weaver/testing/cassette.rb +42 -21
  36. data/lib/graph_weaver/testing/failure.rb +22 -22
  37. data/lib/graph_weaver/testing/{fake_executor.rb → fake_client.rb} +13 -13
  38. data/lib/graph_weaver/testing/values.rb +2 -2
  39. data/lib/graph_weaver/testing.rb +32 -16
  40. data/lib/graph_weaver/transport/faraday.rb +56 -0
  41. data/lib/graph_weaver/transport/http.rb +87 -0
  42. data/lib/graph_weaver/transport.rb +120 -0
  43. data/lib/graph_weaver/version.rb +1 -1
  44. data/lib/graph_weaver.rb +208 -38
  45. metadata +73 -5
  46. data/lib/graph_weaver/faraday_executor.rb +0 -61
  47. data/lib/graph_weaver/http_executor.rb +0 -44
  48. data/lib/graph_weaver/testing/rspec.rb +0 -5
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,84 @@
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/ # *_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
+
7
85
  ## Anatomy
8
86
 
9
87
  ```ruby
@@ -21,9 +99,9 @@ module PersonQuery
21
99
  const :person, T.nilable(Person)
22
100
  end
23
101
 
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
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
27
105
  end
28
106
  ```
29
107
 
@@ -46,40 +124,75 @@ AddPetQuery.execute!(name: "Rex", species: AddPetQuery::Species::Dog)
46
124
  - required vs optional falls out of nullability and defaults: nullable or
47
125
  defaulted variables become optional kwargs (nil is omitted from the wire,
48
126
  so server-side defaults apply)
49
- - enum variables generate module-level `T::Enum`s and serialize themselves
127
+ - enum variables generate module-level `T::Enum`s and accept the enum or
128
+ its wire value (`species: Species::Dog` or `species: "DOG"`)
50
129
  - custom scalars serialize through the [scalar registry](scalars.md)
51
130
 
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:
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
+ ```
55
138
 
56
139
  ```ruby
57
- input = AdoptQuery::AdoptionInput.new(name: "Rex", species: AdoptQuery::Species::Dog)
58
- AdoptQuery.execute!(input:)
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):
59
150
 
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" })
151
+ ```ruby
152
+ AdoptQuery.execute!(input: { name: "Rex", species: "DOG" }, detail: true)
64
153
  ```
65
154
 
66
- Nested inputs work (dependencies emit first); recursive input types are not
67
- yet supported.
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
+ ```
68
168
 
69
169
  ## Selections
70
170
 
71
171
  - **Fragments** — inline fragments and named spreads flatten into the
72
172
  selection; type conditions match exact names or interfaces/unions the type
73
173
  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.
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.
78
184
  - **`@skip` / `@include`** — a directive-conditional field may be absent from
79
185
  the response regardless of schema nullability, so its generated type is
80
186
  always nilable.
81
187
  - **Aliases** — result keys follow aliases; props are the underscored alias.
82
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
+
83
196
  ## Naming
84
197
 
85
198
  Module names derive from the operation name (`query GetPerson` → `GetPerson`);
@@ -92,19 +205,16 @@ are impossible).
92
205
  Nested struct names come from GraphQL type names, disambiguated one level by
93
206
  field name on collision.
94
207
 
95
- ## Executors
96
-
97
- An executor is anything with `execute(query, variables:)` whose result `to_h`s
98
- into `{"data" => ..., "errors" => ...}`. Resolution order:
208
+ ## Clients
99
209
 
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)
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).
104
214
 
105
215
  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.
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.
108
218
 
109
219
  ## Dynamic mode
110
220
 
@@ -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.
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
-