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.
- checksums.yaml +4 -4
- data/Gemfile.lock +4 -4
- data/README.md +40 -88
- data/docs/alternatives.md +1 -7
- data/docs/cassettes.md +54 -59
- data/docs/editors.md +32 -47
- data/docs/errors.md +261 -369
- data/docs/federation.md +650 -837
- data/docs/generated_modules.md +380 -463
- data/docs/getting_started.md +211 -428
- data/docs/i18n.md +114 -177
- data/docs/logging.md +127 -116
- data/docs/real_world.md +26 -39
- data/docs/scalars.md +277 -310
- data/docs/testing.md +343 -486
- data/docs/transports.md +203 -268
- data/docs/upgrading.md +211 -560
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +12 -3
- data/lib/graph_weaver/client.rb +30 -1
- data/lib/graph_weaver/codegen/emit.rb +5 -11
- data/lib/graph_weaver/codegen.rb +23 -55
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +23 -15
- data/lib/graph_weaver/federation.rb +11 -2
- data/lib/graph_weaver/graph.rb +39 -29
- data/lib/graph_weaver/in_process.rb +15 -9
- data/lib/graph_weaver/internal/endpoint.rb +7 -5
- data/lib/graph_weaver/internal/headers.rb +19 -0
- data/lib/graph_weaver/internal/test_clients.rb +7 -11
- data/lib/graph_weaver/internal.rb +81 -13
- data/lib/graph_weaver/log_subscriber.rb +10 -2
- data/lib/graph_weaver/logging.rb +33 -13
- data/lib/graph_weaver/query_module.rb +44 -23
- data/lib/graph_weaver/retry.rb +12 -8
- data/lib/graph_weaver/rspec.rb +13 -24
- data/lib/graph_weaver/schema_loader.rb +52 -14
- data/lib/graph_weaver/tasks.rb +10 -2
- data/lib/graph_weaver/testing/cassette.rb +28 -5
- data/lib/graph_weaver/testing/endpoint.rb +14 -13
- data/lib/graph_weaver/testing/fake_client.rb +33 -3
- data/lib/graph_weaver/testing/router.rb +7 -3
- data/lib/graph_weaver/testing.rb +12 -4
- data/lib/graph_weaver/transport/http.rb +2 -2
- data/lib/graph_weaver/transport.rb +47 -23
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +32 -10
- metadata +16 -3
- 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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
27
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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 —
|
|
40
|
-
|
|
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
|
-
:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
128
|
-
`:
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
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
|
|
23
|
-
#
|
|
24
|
-
#
|
|
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 # =>
|
|
37
|
+
repo&.created_at # => a real Time
|
|
40
38
|
repo&.stargazer_count # => Integer
|
|
41
39
|
```
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
is
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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`).
|