graph_weaver 0.7.0 → 0.7.2

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +4 -4
  3. data/README.md +40 -88
  4. data/docs/alternatives.md +1 -7
  5. data/docs/cassettes.md +54 -59
  6. data/docs/editors.md +32 -47
  7. data/docs/errors.md +261 -369
  8. data/docs/federation.md +650 -837
  9. data/docs/generated_modules.md +380 -463
  10. data/docs/getting_started.md +211 -428
  11. data/docs/i18n.md +114 -177
  12. data/docs/logging.md +127 -116
  13. data/docs/real_world.md +26 -39
  14. data/docs/scalars.md +277 -310
  15. data/docs/testing.md +343 -486
  16. data/docs/transports.md +203 -268
  17. data/docs/upgrading.md +211 -560
  18. data/examples/README.md +38 -0
  19. data/examples/countries.rb +39 -0
  20. data/examples/federation.rb +62 -0
  21. data/examples/github/generate.rb +20 -0
  22. data/examples/github/generated/star_mutation.rb +126 -0
  23. data/examples/github/generated/stargazers_query.rb +232 -0
  24. data/examples/github/generated/starred_query.rb +151 -0
  25. data/examples/github/queries/star.graphql +8 -0
  26. data/examples/github/queries/stargazers.graphql +22 -0
  27. data/examples/github/queries/starred.graphql +11 -0
  28. data/examples/github/run.rb +43 -0
  29. data/examples/github/setup.rb +18 -0
  30. data/examples/rick_and_morty.rb +57 -0
  31. data/graph_weaver.gemspec +12 -3
  32. data/lib/graph_weaver/client.rb +30 -1
  33. data/lib/graph_weaver/codegen/emit.rb +5 -11
  34. data/lib/graph_weaver/codegen.rb +23 -55
  35. data/lib/graph_weaver/context_seam.rb +54 -0
  36. data/lib/graph_weaver/errors.rb +23 -15
  37. data/lib/graph_weaver/federation.rb +11 -2
  38. data/lib/graph_weaver/graph.rb +39 -29
  39. data/lib/graph_weaver/in_process.rb +15 -9
  40. data/lib/graph_weaver/internal/endpoint.rb +7 -5
  41. data/lib/graph_weaver/internal/headers.rb +19 -0
  42. data/lib/graph_weaver/internal/test_clients.rb +7 -11
  43. data/lib/graph_weaver/internal.rb +81 -13
  44. data/lib/graph_weaver/log_subscriber.rb +10 -2
  45. data/lib/graph_weaver/logging.rb +33 -13
  46. data/lib/graph_weaver/query_module.rb +44 -23
  47. data/lib/graph_weaver/retry.rb +12 -8
  48. data/lib/graph_weaver/rspec.rb +13 -24
  49. data/lib/graph_weaver/schema_loader.rb +52 -14
  50. data/lib/graph_weaver/tasks.rb +10 -2
  51. data/lib/graph_weaver/testing/cassette.rb +28 -5
  52. data/lib/graph_weaver/testing/endpoint.rb +14 -13
  53. data/lib/graph_weaver/testing/fake_client.rb +33 -3
  54. data/lib/graph_weaver/testing/router.rb +7 -3
  55. data/lib/graph_weaver/testing.rb +12 -4
  56. data/lib/graph_weaver/transport/http.rb +2 -2
  57. data/lib/graph_weaver/transport.rb +47 -23
  58. data/lib/graph_weaver/version.rb +1 -1
  59. data/lib/graph_weaver.rb +32 -10
  60. metadata +16 -3
  61. data/CHANGELOG.md +0 -3801
data/docs/logging.md CHANGED
@@ -1,44 +1,43 @@
1
1
  # Logging
2
2
 
3
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):
4
+ stdlib-Logger-compatible and the whole flow narrates itself — in Rails the
5
+ railtie wires `Rails.logger` automatically (set `GraphWeaver.logger = nil` in an
6
+ initializer to opt out):
7
7
 
8
8
  ```ruby
9
9
  GraphWeaver.logger = Logger.new($stdout, level: Logger::INFO)
10
10
  ```
11
11
 
12
- What logs at which level — pick the level, get the story:
12
+ Pick the level, get the story:
13
13
 
14
14
  | Level | What you see |
15
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` | one line per operation in Rails (see Instrumentation), schema introspection (with timing) and cache hits/misses, the transport a client built, generated files written and any unregistered scalars, query modules loaded, a retry's wait and attempt number — and in development, what's being watched and what a save regenerated |
16
+ | `debug` | the wire: query + variables per call (long queries truncated), response status/bytes/content type, request timing, connection open/drop, dynamically parsed modules |
17
+ | `info` | one line per operation in Rails (see [Instrumentation](#instrumentation)), schema introspection (with timing) and cache hits/misses, the transport a client built, generated files written and any unregistered scalars, query modules loaded, a retry's wait and attempt number — and in development, what's being watched and what a save regenerated |
18
18
  | `warn` | every GraphWeaver error raised — `TransportError`, `ServerError`, `QueryError`, `QueryValidationError`, `CastError` — registrations the schema being generated against can't match, a retry skipped because the operation was a mutation, and every fetch the test router answered with fabricated data |
19
19
  | `error` | development only: a `.graphql` edit that won't compile, with its file and position — the modules already loaded keep serving |
20
20
 
21
- Every line carries `graph_weaver` as the progname, so formatter-based
22
- filtering works out of the box. Wire lines are tagged
23
- `[req 3 FilteredPokemon]` — a per-process request id plus the operation
24
- name so a request's lines stay paired when threads interleave.
21
+ Every line carries `graph_weaver` as the progname, so formatter-based filtering
22
+ works out of the box. Wire lines are tagged `[req 4123-3 FilteredPokemon]` — pid,
23
+ that process's own request count, operation name so a request's lines stay
24
+ paired when threads interleave and distinct when a Puma cluster's workers share a
25
+ log.
25
26
 
26
- **PII note**: queries, variables, and response sizes appear at debug
27
- only variables can carry user data, so keep production loggers at
28
- info or above. Auth headers never log at any level.
27
+ **PII note**: queries, variables and response sizes appear at debug only, so keep
28
+ production loggers at info or above. Auth headers never log at any level.
29
29
 
30
30
  ## Filtered variables
31
31
 
32
32
  Debug gets switched on during an incident, which is exactly when a
33
- `login(password:)` mutation's variables must not land in the log. So the
34
- values of sensitive keys are replaced with `[FILTERED]` before the line is
35
- written — at any depth, including inside input objects.
33
+ `login(password:)` mutation's variables must not land in the log. So the values of
34
+ sensitive keys are replaced with `[FILTERED]` before the line is written, at any
35
+ depth, including inside input objects.
36
36
 
37
37
  In Rails you configure nothing: the railtie adopts the app's own
38
38
  `config.filter_parameters`, so GraphWeaver scrubs whatever the request logs
39
- already scrub — including Rails' own default list, which has `:email` on it, so
40
- an ordinary field named `email` reads `[FILTERED]` in `#value` and in the
41
- message the day you add the gem.
39
+ already scrub — Rails' own default list has `:email` on it, so an ordinary field
40
+ named `email` reads `[FILTERED]` the day you add the gem.
42
41
 
43
42
  Everywhere else, one list:
44
43
 
@@ -47,36 +46,24 @@ GraphWeaver.filter_parameters = [:password, /token/]
47
46
  ```
48
47
 
49
48
  Strings and Symbols match as case-insensitive substrings — `:token` covers
50
- `apiToken` — and Regexps match themselves. The default is `[:password,
51
- :token, :secret, :authorization]`, which covers the usual names before
52
- anyone configures anything; assigning replaces it rather than adding to it,
53
- and `[]` turns filtering off. Anything answering `#filter(hash)` is used
54
- as-is, which is how the railtie hands over an
55
- `ActiveSupport::ParameterFilter`.
56
-
57
- The same list scrubs error messages, which reach the log at `warn` rather
58
- than `debug`: a variable, input field, or entity key whose name is filtered
59
- is rejected with `[FILTERED]` in place of the value, and a value a message
60
- *quotes* is scrubbed at every depth, so a filtered key one level in reads
61
- `got {"token" => "[FILTERED]"}`. Everything else keeps quoting the value,
62
- since `expected an Int, got "lots"` is the whole diagnosis.
63
-
64
- **It reaches what GraphWeaver composes, and nothing else.** That is
65
- `InputError#message` and `#value` on both halves — a server's sentence
66
- included, once it has been read back into an `InputError` — plus the variables
67
- line at debug. A `GraphQLError#message` is the server's or a resolver's own
68
- words and is **passed through untouched**, in `response.errors`, in
69
- `QueryError`'s summary, and in the `warn` line that summary writes. A server
70
- that quotes a rejected password in its message has to be fixed at the server.
49
+ `apiToken` — and Regexps match themselves. The default is `[:password, :token,
50
+ :secret, :authorization]`; assigning replaces it rather than adding to it, and
51
+ `[]` turns filtering off for variables and messages. A credential in the *url* is
52
+ scrubbed either way. Anything answering `#filter(hash)` is used as-is, which is
53
+ how the railtie hands over an `ActiveSupport::ParameterFilter`.
54
+
55
+ The same list scrubs error messages, which reach the log at `warn` rather than
56
+ `debug`: a variable, input field or entity key whose name is filtered is rejected
57
+ with `[FILTERED]` in place of the value, at every depth a filtered key one level
58
+ in reads `got {"token" => "[FILTERED]"}`. Everything else keeps quoting the
59
+ value, since `expected an Int, got "lots"` is the whole diagnosis.
71
60
 
72
61
  ## Instrumentation
73
62
 
74
- A logger tells a human what happened; an APM needs to time it and count
75
- it. `GraphWeaver.instrumenter` is one callable wrapping every request —
76
- over the wire *and* in-process, one seam for both paths. It's a no-op until you
77
- set one — and **in Rails you set nothing**: the railtie installs the adapter
78
- below and attaches `GraphWeaver::LogSubscriber` on top of it, so the snippet is
79
- what the framework already did. Everywhere else, it is the line to write:
63
+ A logger tells a human what happened; an APM needs to time it and count it.
64
+ `GraphWeaver.instrumenter` is one callable wrapping every request — over the wire
65
+ *and* in-process. **In Rails you set nothing**: the railtie installs the adapter
66
+ below and attaches `GraphWeaver::LogSubscriber` on top of it. Everywhere else:
80
67
 
81
68
  ```ruby
82
69
  GraphWeaver.instrumenter = lambda do |event, payload, &block|
@@ -85,27 +72,24 @@ end
85
72
  ```
86
73
 
87
74
  An instrumenter you set yourself is never replaced — including
88
- `GraphWeaver.instrumenter = nil` in an initializer, which opts out the
89
- way `GraphWeaver.logger = nil` does. Yours **must** call the block and
90
- return its value; a failure propagates through it, so the hook sees the
75
+ `GraphWeaver.instrumenter = nil`, which opts out. Yours **must** call the block
76
+ and return its value; a failure propagates through it, so the hook sees the
91
77
  exception and can record it.
92
78
 
93
- The one event is `GraphWeaver::EXECUTE_EVENT` (`"execute.graph_weaver"`)
94
- — one request, start to parsed response, whichever client slot served
95
- it, so a single subscriber covers both sides of the seam:
79
+ The one event is `GraphWeaver::EXECUTE_EVENT` (`"execute.graph_weaver"`) — one
80
+ request, start to parsed response, whichever client slot served it:
96
81
 
97
82
  ```ruby
98
83
  ActiveSupport::Notifications.subscribe(GraphWeaver::EXECUTE_EVENT) do |*, payload|
99
84
  StatsD.timing("graphql.#{payload[:operation] || "anonymous"}", payload[:duration_ms],
100
- tags: ["status:#{payload[:status]}", "code:#{payload[:code]}"])
85
+ tags: ["status:#{payload[:status]}", "kind:#{payload[:kind]}", "code:#{payload[:code]}"])
101
86
  end
102
87
  ```
103
88
 
104
89
  **A subscriber that raises takes the request down with it** — the response was
105
90
  computed and is then thrown away. That is `ActiveSupport::Notifications`' own
106
91
  semantics, the same on `sql.active_record`, so rescue inside the block. A
107
- `GraphWeaver::LogSubscriber` subclass is the exception: it already rescues, and
108
- reports the failure instead of raising it.
92
+ `GraphWeaver::LogSubscriber` subclass already does.
109
93
 
110
94
  ### The payload
111
95
 
@@ -113,46 +97,37 @@ reports the failure instead of raising it.
113
97
  |-----|------|--|
114
98
  | `:operation` | always | the operation name sent with the request, nil for an anonymous document — what a trace keys on (a generated module always has one) |
115
99
  | `:client` | always | the class that ran it: `GraphWeaver::Transport::HTTP`, `GraphWeaver::InProcess`, your own |
100
+ | `:kind` | always | `:query`, `:mutation` or `:subscription` — what the document runs, so a write failure rate is a payload question rather than a guess at the operation's name. The shorthand `{ ... }` document is a `:query`. The same reading decides whether [`Retry`](transports.md#retries) may repeat the request |
116
101
  | `:status` | always | `:ok`, `:errors` (a response carrying GraphQL errors), or `:failed` (it raised) |
117
- | `:duration_ms` | always | start to parsed response, for **this attempt** — under a [`Retry`](transports.md#retries) the backoff sleep between attempts is in none of them, so no event reports the wall clock the caller waited |
102
+ | `:duration_ms` | always | start to parsed response, for **this attempt** — under a [`Retry`](transports.md#retries) no event covers the backoff sleep, so a tracer recovers the caller's wall clock from the sibling spans instead: the first attempt's start to the last one's end |
118
103
  | `:url` | over the wire | the endpoint; nil in-process |
119
104
  | `:http_status` | over the wire | what the server answered with, success or not; nil in-process |
120
105
  | `:schema` | in-process | the schema class's name, as a String, so a payload logs as it stands |
121
- | `:code` | on `:errors`, and on a `ServerError` | the machine-readable reason — the first `code` *any* of the errors carries, not the first error's, or a `ServerError`'s status. A code that exists beats the absence of one at position 0, which is what an alert groups by. Present and `nil` when the errors carry none |
106
+ | `:code` | on `:errors` | the machine-readable reason, always a String or `nil` — the first `extensions.code` *any* of the errors carries, not the first error's, since a code that exists beats the absence of one at position 0. Present and `nil` when the errors carry none. Never an HTTP status: that is `:http_status` |
122
107
  | `:error` | on `:failed` | the exception's class name |
123
108
  | `:retries` | under a `Retry` | how many retries this attempt follows. Each attempt is its own event, so one retried call is three events reading 0, 1, 2 — present at 0 rather than absent, so its absence means nothing was retrying |
124
109
  | `:graph` | always | the [graph](getting_started.md#more-than-one-schema) the generated module was declared under, as a Symbol — `nil` for a module that names none, and for a client called directly. Never inferred from the client: a wrong graph on a request is worse than no graph |
125
110
 
126
- Every key is filled in before your callable's block returns, so a
127
- subscriber reads a complete payload. `ActiveSupport::Notifications` adds
128
- `:exception` and `:exception_object` of its own when the block raises.
111
+ Every key is filled in before your callable's block returns, so a subscriber reads
112
+ a complete payload; `ActiveSupport::Notifications` adds `:exception` and
113
+ `:exception_object` of its own when the block raises.
129
114
 
130
- **`:graph` labels one request.** A generated module's `execute` labels the
131
- request *it* makes each of them, when a federated operation fans out to
132
- several subgraphs. It never labels what a *server* does while answering
133
- one: an in-process resolver that calls out produces an event of its own,
134
- carrying its own graph or `nil`. So `:graph` always reads "this request
135
- went to that graph", which is the only claim a dashboard can group by.
115
+ **`:graph` labels one request**, never what a *server* does while answering one:
116
+ an in-process resolver that calls out produces an event of its own. The label is
117
+ fiber-local, so a dispatch that crosses a `Fiber` graphql-ruby's `Dataloader`
118
+ does arrives with `:graph` unset. No label rather than a wrong one.
136
119
 
137
- One edge: the label is fiber-local, so a dispatch that crosses a `Fiber`
138
- graphql-ruby's `Dataloader` does arrives with `:graph` unset. No label
139
- rather than a wrong one, which is the same trade as the paragraph above.
140
-
141
- **Never the query text or the variables.** `filter_parameters` scrubs
142
- what reaches the log, which GraphWeaver writes itself; the payload fans
143
- out to subscribers that know none of those rules, so here the rule isn't
144
- "scrub it", it's that it was never there. Queries and variables stay at
145
- debug on the logger, where the level gates them.
146
-
147
- `:url` is the one thing on the payload that *is* scrubbed, because a url can
148
- itself be a credential: its userinfo, and any query parameter
149
- `filter_parameters` filters, are folded to `[FILTERED]` — the same list, the
150
- same spelling — before the payload, a log line or a `TransportError` says it.
120
+ **Never the query text or the variables**: the payload fans out to subscribers
121
+ that know none of the filtering rules, so the rule here isn't "scrub it", it's
122
+ that it was never there. `:url` is the one thing on the payload that *is*
123
+ scrubbed, because a url can itself be a credential — and the default names apply
124
+ to its query parameters even when you have emptied or narrowed
125
+ `filter_parameters`. Your list widens that; it can't narrow it.
151
126
 
152
127
  ### One line per operation
153
128
 
154
- In Rails the railtie also attaches `GraphWeaver::LogSubscriber`, which
155
- turns each event into one line — the shape ActiveRecord uses for a query:
129
+ In Rails the railtie also attaches `GraphWeaver::LogSubscriber`, which turns each
130
+ event into one line — the shape ActiveRecord uses for a query:
156
131
 
157
132
  ```
158
133
  GraphWeaver PersonQuery (12.3ms) ok
@@ -162,35 +137,17 @@ GraphWeaver PersonQuery (5.0ms) ok (retry 2)
162
137
  GraphWeaver billing/InvoicesQuery (12.3ms) ok
163
138
  ```
164
139
 
165
- The operation is prefixed by its graph when the request carried one, so an
166
- app with several graphs sorts its own log and an app with one never sees
167
- the prefix.
140
+ The operation is prefixed by its graph when the request carried one, so an app
141
+ with several graphs sorts its own log and an app with one never sees the prefix.
168
142
 
169
- **One rule: the summary is info, the wire is debug.** This is the only
170
- GraphWeaver line at info, so a production log gets one per operation and
171
- nothing that can carry PII; turning the logger up to debug adds the
172
- query, the variables and the response *beneath* it rather than repeating
173
- it. It writes through `GraphWeaver.logger`, so `GraphWeaver.logger = nil`
174
- silences this along with everything else.
143
+ **The summary is info, the wire is debug.** This is the only GraphWeaver line at
144
+ info, so a production log gets one per operation and nothing that can carry PII;
145
+ debug adds the query, the variables and the response *beneath* it. It writes
146
+ through `GraphWeaver.logger`.
175
147
 
176
- Outside Rails there's no railtie to attach it, so the same line is an
177
- instrumenter of your own read the payload in an `ensure`, since it is only
178
- complete once the block has returned:
179
-
180
- ```ruby
181
- log = Logger.new("log/graphql.log")
182
-
183
- GraphWeaver.instrumenter = lambda do |_event, payload, &block|
184
- block.call
185
- ensure
186
- log.info { "#{payload[:operation] || "anonymous"} (#{payload[:duration_ms]}ms) #{payload[:status]}" }
187
- end
188
- ```
189
-
190
- ```
191
- I, [2026-09-12T18:40:33.032236 #97490] INFO -- : PersonQuery (19.57ms) ok
192
- I, [2026-09-12T18:40:33.037186 #97490] INFO -- : DraftsQuery (4.67ms) errors
193
- ```
148
+ Outside Rails the same line is an instrumenter of your own, writing
149
+ `payload[:operation]`, `[:duration_ms]` and `[:status]` from an `ensure` the
150
+ shape the two tracing examples below use.
194
151
 
195
152
  ### OpenTelemetry
196
153
 
@@ -202,13 +159,24 @@ GraphWeaver.instrumenter = lambda do |_event, payload, &block|
202
159
  block.call
203
160
  ensure
204
161
  span.add_attributes(payload.compact.transform_keys { "graphql.#{_1}" }.transform_values(&:to_s))
162
+ span.status = OpenTelemetry::Trace::Status.error(payload[:code] || "graphql errors") if payload[:status] == :errors
205
163
  end
206
164
  end
207
165
  ```
208
166
 
209
- `ensure` rather than after the call: the payload is only complete once
210
- the block has returned, and a failed span needs the attributes most.
211
- `in_span` records the exception and sets the span status itself.
167
+ `ensure` rather than after the call: the payload is only complete once the block
168
+ has returned, and a failed span needs the attributes most. `in_span` sets the
169
+ span status itself for a *raise* only, which is the last line's reason to exist.
170
+
171
+ **Span status is not the alerting signal; `payload[:status]` is.** A response
172
+ carrying GraphQL errors is a 200 that returned normally, so nothing raises and a
173
+ span left to itself is `UNSET` — an SLO built on span status alone misses every
174
+ GraphQL-level failure there is. Alert on `:status` and group by `:code`.
175
+
176
+ **Propagating the trace outward** is a header, and a
177
+ [header value may be a callable](transports.md#headers) resolved per request —
178
+ inside the span, which is what makes it work:
179
+ `headers: { "traceparent" => -> { {}.tap { OpenTelemetry.propagation.inject(_1) }["traceparent"] } }`.
212
180
 
213
181
  ### Datadog
214
182
 
@@ -218,11 +186,54 @@ GraphWeaver.instrumenter = lambda do |event, payload, &block|
218
186
  block.call
219
187
  ensure
220
188
  payload.compact.each { |key, value| span.set_tag("graphql.#{key}", value.to_s) }
189
+ span.set_error([payload[:code], "graphql errors"]) if payload[:status] == :errors
221
190
  end
222
191
  end
223
192
  ```
224
193
 
225
- Datadog's Net::HTTP and Faraday contribs already trace the transport
226
- layer, so with them on you have a span for the POST. This adds the span
227
- *above* it, named for the operation — the one that means anything, since
228
- every GraphQL call is a POST to the same url.
194
+ Datadog's Net::HTTP and Faraday contribs already trace the transport layer, so
195
+ with them on you have a span for the POST. This adds the span *above* it, named
196
+ for the operation — the one that means anything, since every GraphQL call is a
197
+ POST to the same url.
198
+
199
+ ## Details
200
+
201
+ ### What carries text we didn't author
202
+
203
+ A log line, an exception and an APM tag all outlive the request, and each can
204
+ carry text somebody else wrote. Every such channel has a policy, and there are no
205
+ others:
206
+
207
+ | Channel | Policy |
208
+ |---------|--------|
209
+ | the variables line | scrubbed through `filter_parameters` at every depth, and written at **debug** only |
210
+ | the query text | debug only, truncated |
211
+ | `InputError#message`, `#value`, `#to_h` | `[FILTERED]` under a filtered key, at every depth; capped at 1 KB |
212
+ | `ServerError#message` | the status, what *we* judged wrong, the hint, the safe url — **never the body** |
213
+ | `ServerError#body` | the bytes verbatim. This is the channel that carries them, which is why no other has to |
214
+ | `ServerError#to_h` | status, `retry_after`, url — the body and the headers stay off it (read `#headers`) |
215
+ | a redirect's `Location` | a url the server chose, folded the way we fold our own |
216
+ | `TransportError#message` | the adapter's own sentence, capped, plus the safe url |
217
+ | `GraphQLError#message` | the server's own words, **passed through untouched** — a server that quotes a rejected password has to be fixed at the server |
218
+ | `extensions.code` → the info line, the APM `:code` | control characters stripped, capped — a tag can't forge a line |
219
+ | the endpoint, everywhere it is said | userinfo and credential query parameters folded to `[FILTERED]` |
220
+ | `#inspect` on any public object | its class and its safe url; never a header, a context or a body |
221
+
222
+ The rule behind the `ServerError` rows: every raised error writes its message to
223
+ the log at `warn`, and the commonest non-2xx body in the world is a framework
224
+ error page echoing the request, `Authorization` header included.
225
+
226
+ ### What is process-global, and who owns it
227
+
228
+ Three things outlive a single request and meet more than one writer in a shipped
229
+ configuration. Each has one owner, so none needs a convention on your side:
230
+
231
+ | Resource | Several writers arrive from | Who keeps them apart |
232
+ |----------|-----------------------------|----------------------|
233
+ | the `[req …]` counter | a Puma cluster: forked workers inherit it | the tag carries the pid, and the count restarts in a new process |
234
+ | the schema cache file | two clients both saying `cache: true` | a dump records its source url; a client that didn't write it caches under a name of its own ([getting started](getting_started.md)) |
235
+ | a cassette | `parallel_tests`, one cassette, several processes | a recorder re-reads and rewrites under a `flock` ([testing](testing.md)) |
236
+
237
+ Two things are *not* process-global and shouldn't be made so: a client's GraphQL
238
+ context (per client, guarded by the client — see `GraphWeaver::ContextSeam`), and
239
+ a connection pool (per process, rebuilt after a fork).
data/docs/real_world.md CHANGED
@@ -1,10 +1,9 @@
1
1
  # Against a real API
2
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).
3
+ Point a client at a live endpoint and go, no build step — the exploratory tour,
4
+ for consoles and spikes. What ships is the checked-in codegen path in
5
+ [getting started](getting_started.md); the `parse` below becomes a `.graphql`
6
+ file plus `rake graph_weaver:generate`, and everything else stays.
8
7
 
9
8
  Everything hangs off a client — transport and schema for one server. GitHub's
10
9
  API, end to end:
@@ -12,17 +11,16 @@ API, end to end:
12
11
  ```ruby
13
12
  require "graph_weaver"
14
13
 
15
- # transport + auth in one object (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
- # (docs/getting_started.md for the formats), with the source url recorded
19
- # in a header, so a stale dump says where it came from.
14
+ # transport + auth in one object (docs/transports.md for retries and TLS).
15
+ # cache: true writes the schema to GraphWeaver.schema_path on first
16
+ # introspection — the same dump rake graph_weaver:generate reads. It records
17
+ # its source url, so a second client at a second origin caches under a name of
18
+ # its own rather than overwriting this one.
20
19
  github = GraphWeaver.new("https://api.github.com/graphql", auth: `gh auth token`.strip, cache: true)
21
20
 
22
- # GitHub's DateTime needs no registration it is one of the names the
23
- # registry knows (docs/scalars.md). A scalar of your own would go here;
24
- # registrations are global and codegen-time, so one line types your
25
- # console and your checked-in code identically.
21
+ # GitHub's DateTime needs no registration (docs/scalars.md). A scalar of your
22
+ # own goes here: registrations are global and codegen-time, so one line types
23
+ # your console and your checked-in code identically.
26
24
 
27
25
  RepoQuery = github.parse(<<~GRAPHQL)
28
26
  query($owner: String!, $name: String!) {
@@ -36,22 +34,14 @@ GRAPHQL
36
34
 
37
35
  repo = RepoQuery.execute!(owner: "dpep", name: "graph_weaver").repository
38
36
  repo&.name_with_owner # => "dpep/graph_weaver"
39
- repo&.created_at # => 2026-07-07 ... (a real Time)
37
+ repo&.created_at # => a real Time
40
38
  repo&.stargazer_count # => Integer
41
39
  ```
42
40
 
43
- Clients are independent build one per server, each with its own transport
44
- and schema. When you're ready to check the generated code in, the same client
45
- is the schema `generate!` wants:
46
-
47
- ```ruby
48
- GraphWeaver.generate!(schema: github) # no dump on disk needed
49
- ```
50
-
51
- Keeping a second server's modules checked in alongside your own is a
52
- [graph](getting_started.md#more-than-one-schema) — its own queries, output and
53
- namespace, declared once and generated by the same
54
- `rake graph_weaver:generate` as everything else.
41
+ Build one client per server; they're independent. That same client is what
42
+ `GraphWeaver.generate!(schema: github)` takes when you check the generated code
43
+ in, so no dump on disk is needed and keeping a second server's modules beside
44
+ your own is a [graph](getting_started.md#more-than-one-schema).
55
45
 
56
46
  ## Browsing the schema
57
47
 
@@ -71,17 +61,14 @@ repo.graphql_name # => "Repository"
71
61
  repo.fields.keys.sort # => ["createdAt", "description", ...]
72
62
  ```
73
63
 
74
- `graphql_name` is the one to reach for, not `name`: a schema built from
75
- introspection or SDL has no Ruby class behind its types, so `.name` is `nil`
76
- there — and where the schema *is* a class you wrote, `.name` gives you the Ruby
77
- constant, never the GraphQL name.
64
+ Reach for `graphql_name`, not `name`: a schema built from introspection or SDL
65
+ has no Ruby class behind its types, so `.name` is `nil` there, and where the
66
+ schema *is* a class you wrote `.name` gives the Ruby constant.
78
67
 
79
- The introspection step (seconds on a big API) happens lazily on first
80
- `schema`/`parse` and caches per `cache:`/`ttl:`; for finer control the
81
- pieces are all public
82
- (`GraphWeaver::SchemaLoader.introspect(transport, cache:, ttl:)`, or cache
83
- `introspect(transport).to_json` in Rails.cache and `SchemaLoader.load` it).
68
+ Introspection (seconds on a big API) happens lazily on first `schema`/`parse`
69
+ and caches per `cache:`/`ttl:`; for finer control the pieces are public —
70
+ `GraphWeaver::SchemaLoader.introspect(transport, cache:, ttl:)`, or cache
71
+ `introspect(transport).to_json` yourself and `SchemaLoader.load` it.
84
72
 
85
- The same flow runs as one-off integration specs against the live GitHub
86
- and Countries APIs `make integration` (network; GitHub auth via
87
- `gh auth token` or `GITHUB_TOKEN`).
73
+ `make integration` runs this flow against the live GitHub and Countries APIs
74
+ (network; GitHub auth via `gh auth token` or `GITHUB_TOKEN`).