graph_weaver 0.7.4 → 0.7.6
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 +2 -2
- data/README.md +1 -0
- data/docs/errors.md +12 -5
- data/docs/generated_modules.md +134 -17
- data/docs/getting_started.md +182 -20
- data/docs/logging.md +79 -35
- data/docs/migrating.md +126 -0
- data/docs/scalars.md +50 -6
- data/docs/testing.md +78 -14
- data/docs/upgrading.md +44 -2
- data/examples/README.md +4 -2
- data/examples/github/generate.rb +22 -8
- data/examples/github/generated/star_mutation.rb +2 -2
- data/examples/github/generated/stargazers_query.rb +2 -2
- data/examples/github/generated/starred_query.rb +2 -2
- data/examples/github/run.rb +1 -0
- data/examples/github/setup.rb +16 -8
- data/graph_weaver.gemspec +15 -6
- data/lib/generators/graph_weaver/install_generator.rb +49 -2
- data/lib/graph_weaver/client.rb +0 -23
- data/lib/graph_weaver/codegen/aliases.rb +36 -3
- data/lib/graph_weaver/codegen/emit.rb +20 -15
- data/lib/graph_weaver/codegen/enum_type.rb +52 -11
- data/lib/graph_weaver/codegen/nodes.rb +75 -32
- data/lib/graph_weaver/codegen.rb +260 -100
- data/lib/graph_weaver/coerce.rb +1 -1
- data/lib/graph_weaver/federation.rb +1 -6
- data/lib/graph_weaver/graph.rb +55 -5
- data/lib/graph_weaver/hints.rb +20 -5
- data/lib/graph_weaver/in_process.rb +2 -4
- data/lib/graph_weaver/input_struct.rb +50 -10
- data/lib/graph_weaver/internal/overrides.rb +126 -14
- data/lib/graph_weaver/internal/subgraphs.rb +1 -10
- data/lib/graph_weaver/internal/test_clients.rb +29 -7
- data/lib/graph_weaver/internal/unused.rb +62 -18
- data/lib/graph_weaver/internal/values.rb +24 -7
- data/lib/graph_weaver/internal.rb +23 -6
- data/lib/graph_weaver/log_subscriber.rb +27 -17
- data/lib/graph_weaver/logging.rb +115 -82
- data/lib/graph_weaver/parsing.rb +32 -3
- data/lib/graph_weaver/query_module.rb +67 -12
- data/lib/graph_weaver/railtie.rb +7 -2
- data/lib/graph_weaver/rspec.rb +41 -18
- data/lib/graph_weaver/schema_diff.rb +24 -5
- data/lib/graph_weaver/schema_loader.rb +29 -17
- data/lib/graph_weaver/tasks.rb +98 -16
- data/lib/graph_weaver/testing/fake_client.rb +28 -31
- data/lib/graph_weaver/testing/router.rb +26 -25
- data/lib/graph_weaver/testing.rb +27 -8
- data/lib/graph_weaver/transport.rb +1 -1
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +72 -42
- metadata +3 -2
|
@@ -18,6 +18,11 @@ module GraphWeaver
|
|
|
18
18
|
# lexical scope, so a private constant would be unreachable from exactly
|
|
19
19
|
# the files that need it. The name and the surface lock carry the rule.
|
|
20
20
|
module Internal
|
|
21
|
+
# The wire value the member register_enum fallback: true adds to a
|
|
22
|
+
# generated enum serializes to. The GraphQL spec reserves a leading `__`,
|
|
23
|
+
# so no schema can declare a value that collides with it.
|
|
24
|
+
ENUM_FALLBACK_WIRE = "__other__"
|
|
25
|
+
|
|
21
26
|
# Odds and ends several files share. Each is here because more than one
|
|
22
27
|
# caller needs it, not because it belongs together with the others.
|
|
23
28
|
module Util
|
|
@@ -50,6 +55,18 @@ module GraphWeaver
|
|
|
50
55
|
# "a" or "an" for a word an error message is about to name.
|
|
51
56
|
def article(word) = word.downcase.start_with?(/[aeiou]/) ? "an" : "a"
|
|
52
57
|
|
|
58
|
+
# how many entries a message names before it says "and N more"
|
|
59
|
+
SAMPLE = 5
|
|
60
|
+
private_constant :SAMPLE
|
|
61
|
+
|
|
62
|
+
# A list a message names inline, held to a readable length — a wall
|
|
63
|
+
# of schema coordinates says less than the first few and a count.
|
|
64
|
+
def sample(list)
|
|
65
|
+
return list.join(", ") if list.size <= SAMPLE
|
|
66
|
+
|
|
67
|
+
"#{list.first(SAMPLE).join(", ")} and #{list.size - SAMPLE} more"
|
|
68
|
+
end
|
|
69
|
+
|
|
53
70
|
# The module a .graphql file generates, and the basename of the file
|
|
54
71
|
# it generates into: the camelized file name plus the operation's own
|
|
55
72
|
# word. Every run of non-alphanumerics in the name is a word boundary,
|
|
@@ -226,14 +243,14 @@ module GraphWeaver
|
|
|
226
243
|
"or cache one: GraphWeaver.new(url, cache: true).schema"
|
|
227
244
|
end
|
|
228
245
|
|
|
229
|
-
# The graphql-ruby schema class
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
# the class object is
|
|
233
|
-
|
|
246
|
+
# The graphql-ruby schema class a client executes against, when it
|
|
247
|
+
# runs in-process — a Client wrapping one, or the class in the slot
|
|
248
|
+
# bare. nil for every network client. Defaults to the app's own, and
|
|
249
|
+
# a graph passes its client. Not memoized: in dev the class object is
|
|
250
|
+
# replaced on reload.
|
|
251
|
+
def live_schema(client = GraphWeaver.client)
|
|
234
252
|
# through #transport, not #schema: a url client's #schema
|
|
235
253
|
# introspects, so asking it would answer over the network
|
|
236
|
-
client = GraphWeaver.client
|
|
237
254
|
target = client.is_a?(Client) ? client.transport : client
|
|
238
255
|
target = target.schema if target.is_a?(InProcess)
|
|
239
256
|
target if target.is_a?(Class) && target <= GraphQL::Schema
|
|
@@ -14,17 +14,20 @@ module GraphWeaver
|
|
|
14
14
|
# GraphWeaver PersonQuery (12.3ms) ok
|
|
15
15
|
# GraphWeaver PersonQuery (8.1ms) errors [THROTTLED]
|
|
16
16
|
# GraphWeaver PersonQuery (31.2ms) failed GraphWeaver::TransportError
|
|
17
|
+
# GraphWeaver PersonQuery (44.0ms) failed GraphWeaver::CastError
|
|
17
18
|
# GraphWeaver billing/InvoicesQuery (12.3ms) ok
|
|
18
19
|
#
|
|
19
20
|
# Attached by the railtie wherever ActiveSupport is, and fed by the
|
|
20
21
|
# instrumenter it sets. Requires ActiveSupport — `require` this yourself
|
|
21
22
|
# only if you subscribe by hand.
|
|
22
23
|
#
|
|
23
|
-
# **One rule decides which line you get: the
|
|
24
|
-
# debug.**
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
24
|
+
# **One rule decides which line you get: the operation is info, the
|
|
25
|
+
# attempt is debug.** The info line is one CALL of a generated module, so
|
|
26
|
+
# it says what the caller got — a cast that raised reads `failed`, and a
|
|
27
|
+
# test mode's stand-in gets a line like every other client. A production
|
|
28
|
+
# log gets one per operation and nothing that could carry PII; turning
|
|
29
|
+
# GraphWeaver.logger up to debug adds each attempt, with the url, the HTTP
|
|
30
|
+
# status and which retry it was, beneath it.
|
|
28
31
|
#
|
|
29
32
|
# It writes through GraphWeaver.logger rather than Rails.logger, so
|
|
30
33
|
# `GraphWeaver.logger = nil` — the documented way to silence the gem —
|
|
@@ -33,17 +36,10 @@ module GraphWeaver
|
|
|
33
36
|
class LogSubscriber < ActiveSupport::LogSubscriber
|
|
34
37
|
# attach_to(:graph_weaver) subscribes "#{method}.graph_weaver" and
|
|
35
38
|
# ActiveSupport::Subscriber#call dispatches on the name up to the first
|
|
36
|
-
# dot — so
|
|
37
|
-
def
|
|
38
|
-
payload = event.payload
|
|
39
|
+
# dot — so these method names are the events' first halves, both ways.
|
|
40
|
+
def operation(event) = write(:info, event)
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
# duration_ms is the instrumenter's own measurement; event.duration
|
|
42
|
-
# covers a subscriber attached to something that didn't set it
|
|
43
|
-
ms = payload[:duration_ms] || event.duration
|
|
44
|
-
"GraphWeaver #{subject(payload)} (#{format("%.1f", ms)}ms) #{outcome(payload)}"
|
|
45
|
-
end
|
|
46
|
-
end
|
|
42
|
+
def execute(event) = write(:debug, event)
|
|
47
43
|
|
|
48
44
|
# GraphWeaver's logger, not Rails' — LogSubscriber#call skips a
|
|
49
45
|
# subscriber whose logger is nil, which is what makes the gem's own
|
|
@@ -52,7 +48,20 @@ module GraphWeaver
|
|
|
52
48
|
|
|
53
49
|
private
|
|
54
50
|
|
|
55
|
-
#
|
|
51
|
+
# One shape for both, so the attempt beneath an operation reads as the
|
|
52
|
+
# same line rather than a second format to learn.
|
|
53
|
+
def write(level, event)
|
|
54
|
+
payload = event.payload
|
|
55
|
+
|
|
56
|
+
GraphWeaver::Internal::Log.log(level) do
|
|
57
|
+
# duration_ms is the instrumenter's own measurement; event.duration
|
|
58
|
+
# covers a subscriber attached to something that didn't set it
|
|
59
|
+
ms = payload[:duration_ms] || event.duration
|
|
60
|
+
"GraphWeaver #{subject(payload)} (#{format("%.1f", ms)}ms) #{outcome(payload)}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# What ran: the operation, prefixed by its graph when the payload names
|
|
56
65
|
# one — an app with several graphs reads `billing/InvoicesQuery` without
|
|
57
66
|
# a second line shape to learn, and one with a single graph never sees it.
|
|
58
67
|
def subject(payload)
|
|
@@ -61,7 +70,8 @@ module GraphWeaver
|
|
|
61
70
|
end
|
|
62
71
|
|
|
63
72
|
# status, then whatever narrows it: the error class, the reason an alert
|
|
64
|
-
# groups by, and
|
|
73
|
+
# groups by, and — on a debug attempt line — which try this was. The last
|
|
74
|
+
# two are attempt facts, so only the debug line ever carries them.
|
|
65
75
|
def outcome(payload)
|
|
66
76
|
parts = [payload[:status], payload[:error]]
|
|
67
77
|
# the GraphQL code, or the HTTP status where the request never got one
|
data/lib/graph_weaver/logging.rb
CHANGED
|
@@ -43,19 +43,22 @@ module GraphWeaver
|
|
|
43
43
|
@filter_parameters = filters
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
# One callable wrapping every
|
|
47
|
-
#
|
|
48
|
-
#
|
|
46
|
+
# One callable wrapping every call GraphWeaver makes — one generated
|
|
47
|
+
# module's execute, and every request under it, over the wire or
|
|
48
|
+
# in-process — so an APM can time it and count errors. A no-op until
|
|
49
|
+
# you set one (Rails sets this one for you):
|
|
49
50
|
#
|
|
50
51
|
# GraphWeaver.instrumenter = lambda do |event, payload, &block|
|
|
51
52
|
# ActiveSupport::Notifications.instrument(event, payload, &block)
|
|
52
53
|
# end
|
|
53
54
|
#
|
|
54
|
-
# It must call the block and return its value.
|
|
55
|
-
#
|
|
56
|
-
# :operation, :
|
|
57
|
-
# :
|
|
58
|
-
#
|
|
55
|
+
# It must call the block and return its value. Two events, and their
|
|
56
|
+
# payloads are the contract in docs/logging.md: OPERATION_EVENT is one
|
|
57
|
+
# call of a generated module (:operation, :module, :graph, :kind,
|
|
58
|
+
# :client, :status, :duration_ms), and EXECUTE_EVENT is one request
|
|
59
|
+
# inside it (:url/:http_status over the wire, :schema in-process,
|
|
60
|
+
# :retries under a Retry). Both add :error on a raise and :code on a
|
|
61
|
+
# response that carried GraphQL errors. Never the query text or the
|
|
59
62
|
# variables: the payload fans out to subscribers that know none of the
|
|
60
63
|
# filtering rules, so PII belongs at debug on the logger, where the
|
|
61
64
|
# level gates it and filter_parameters scrubs it.
|
|
@@ -101,7 +104,11 @@ module GraphWeaver
|
|
|
101
104
|
# about the key the value arrived under, so it reads a filtered key one
|
|
102
105
|
# level in as safe; this scrubs at every depth, like #value. The key is
|
|
103
106
|
# optional because a coercer refusing a value hasn't been told one.
|
|
104
|
-
def shown(raw, key = nil) = filtered?(key) ? FILTERED : cap(value(key, raw)
|
|
107
|
+
def shown(raw, key = nil) = filtered?(key) ? FILTERED : cap(spell(value(key, raw)))
|
|
108
|
+
|
|
109
|
+
# How a value reads inside a sentence. inspect, except that
|
|
110
|
+
# BigDecimal#inspect is scientific ("0.25e1" for the 2.5 a caller wrote).
|
|
111
|
+
def spell(value) = defined?(BigDecimal) && value.is_a?(BigDecimal) ? value.to_s("F") : value.inspect
|
|
105
112
|
|
|
106
113
|
# A short server-chosen string the library republishes inside its own
|
|
107
114
|
# text — the APM's :code, the [CODE] in the one line info writes, a
|
|
@@ -133,14 +140,24 @@ module GraphWeaver
|
|
|
133
140
|
|
|
134
141
|
self.filter_parameters = DEFAULT_FILTER_PARAMETERS
|
|
135
142
|
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
# is how every notification in this
|
|
139
|
-
# (sql.active_record, execute_multiplex.graphql) —
|
|
140
|
-
# ActiveSupport::LogSubscriber.attach_to and an APM's namespace
|
|
141
|
-
# key on, so a backwards name made both of them a puzzle.
|
|
143
|
+
# One GraphQL REQUEST, start to parsed response, whichever client slot
|
|
144
|
+
# served it — one attempt, so a call a Retry made three goes at is three
|
|
145
|
+
# of these. `<event>.<namespace>` is how every notification in this
|
|
146
|
+
# ecosystem is spelled (sql.active_record, execute_multiplex.graphql) —
|
|
147
|
+
# it's what ActiveSupport::LogSubscriber.attach_to and an APM's namespace
|
|
148
|
+
# routing key on, so a backwards name made both of them a puzzle.
|
|
142
149
|
EXECUTE_EVENT = "execute.graph_weaver"
|
|
143
150
|
|
|
151
|
+
# One CALL of a generated module's execute/execute!: the request it makes,
|
|
152
|
+
# every retry and backoff beneath it, and the cast into the typed structs.
|
|
153
|
+
# One or more EXECUTE_EVENTs nest inside it.
|
|
154
|
+
#
|
|
155
|
+
# It says what the CALLER got, which a request can't: a CastError is raised
|
|
156
|
+
# after the response is back, so the request closed :ok while the app saw a
|
|
157
|
+
# failure. And it fires at the module seam, which every client slot passes
|
|
158
|
+
# through — a fake, the test router and a cassette report here too.
|
|
159
|
+
OPERATION_EVENT = "operation.graph_weaver"
|
|
160
|
+
|
|
144
161
|
module Internal
|
|
145
162
|
# The emitting half of the narration the three accessors above
|
|
146
163
|
# configure. Setting a logger is API; writing to it is not, and the
|
|
@@ -170,81 +187,39 @@ module GraphWeaver
|
|
|
170
187
|
result
|
|
171
188
|
end
|
|
172
189
|
|
|
173
|
-
# Wrap
|
|
174
|
-
# supplies what only it knows (:url, :schema, :client); this
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
hook = GraphWeaver.instrumenter
|
|
180
|
-
return yield unless hook
|
|
190
|
+
# Wrap one REQUEST in the instrumenter, if one is set. The caller
|
|
191
|
+
# supplies what only it knows (:url, :schema, :client); this adds the
|
|
192
|
+
# attempt facts — the graph in scope, what a Retry had already spent —
|
|
193
|
+
# so one subscriber reads one shape whichever client slot served it.
|
|
194
|
+
def instrument_request(payload, &block)
|
|
195
|
+
return yield unless GraphWeaver.instrumenter
|
|
181
196
|
|
|
182
|
-
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
183
197
|
retries = Thread.current[RETRIES]
|
|
184
198
|
payload[:retries] = retries if retries
|
|
185
199
|
payload[:graph] = Thread.current[GRAPH]
|
|
186
|
-
# pessimistic, so :status is set even for what a rescue can't
|
|
187
|
-
# see — an Interrupt, a killed thread — and never silently absent
|
|
188
|
-
payload[:status] = :failed
|
|
189
200
|
|
|
190
201
|
# One dispatch labels one request. Whatever THIS request reaches —
|
|
191
202
|
# a resolver serving it that calls out — is a request of its own,
|
|
192
203
|
# and the caller's graph would be a wrong label on it.
|
|
193
|
-
with_graph(nil)
|
|
194
|
-
hook.call(event, payload) do
|
|
195
|
-
result = yield
|
|
196
|
-
errors = response_errors(result)
|
|
197
|
-
if errors.empty?
|
|
198
|
-
payload[:status] = :ok
|
|
199
|
-
else
|
|
200
|
-
payload[:status] = :errors
|
|
201
|
-
code = errors.grep(Hash).filter_map { |e| GraphWeaver::GraphQLError.from_h(e).code }.first
|
|
202
|
-
payload[:code] = Redact.tag(code)
|
|
203
|
-
end
|
|
204
|
-
result
|
|
205
|
-
rescue => e
|
|
206
|
-
payload[:error] = e.class.name
|
|
207
|
-
# :code stays the GraphQL error code and nothing else — it used
|
|
208
|
-
# to hold a ServerError's status here, so one tag carried two
|
|
209
|
-
# dimensions ("THROTTLED" and 429). The number is :http_status.
|
|
210
|
-
raise
|
|
211
|
-
ensure
|
|
212
|
-
payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
|
|
213
|
-
end
|
|
214
|
-
end
|
|
204
|
+
with_graph(nil) { measure(GraphWeaver::EXECUTE_EVENT, payload, &block) }
|
|
215
205
|
end
|
|
216
206
|
|
|
217
|
-
#
|
|
218
|
-
#
|
|
219
|
-
#
|
|
220
|
-
#
|
|
221
|
-
def
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
previous = Thread.current[RETRIES]
|
|
225
|
-
Thread.current[RETRIES] = count
|
|
226
|
-
begin
|
|
227
|
-
yield
|
|
228
|
-
ensure
|
|
229
|
-
Thread.current[RETRIES] = previous
|
|
230
|
-
end
|
|
207
|
+
# Wrap one CALL of a generated module: the request under it and the
|
|
208
|
+
# cast that follows. The caller names :graph here rather than reading
|
|
209
|
+
# it out of the fiber-local, because it IS the module that set it —
|
|
210
|
+
# and it stays set, so the request below wears the same label.
|
|
211
|
+
def instrument_operation(payload, &block)
|
|
212
|
+
measure(GraphWeaver::OPERATION_EVENT, payload, &block)
|
|
231
213
|
end
|
|
232
214
|
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
|
|
236
|
-
# labels, so exactly one request wears the label.
|
|
237
|
-
def with_graph(name)
|
|
238
|
-
return yield unless GraphWeaver.instrumenter
|
|
215
|
+
# What a Retry has already spent, read by the attempt it is about
|
|
216
|
+
# to make.
|
|
217
|
+
def with_retries(count, &block) = during(RETRIES, count, &block)
|
|
239
218
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
ensure
|
|
245
|
-
Thread.current[GRAPH] = previous
|
|
246
|
-
end
|
|
247
|
-
end
|
|
219
|
+
# The graph a generated module is dispatching, read by the request it
|
|
220
|
+
# is about to make — and instrument clears it for the duration of the
|
|
221
|
+
# request it labels, so exactly one request wears the label.
|
|
222
|
+
def with_graph(name, &block) = during(GRAPH, name, &block)
|
|
248
223
|
|
|
249
224
|
# The variables as one JSON line for a log: filtered, and unable to
|
|
250
225
|
# raise. A value with no JSON form (NaN, binary) is the caller's bug
|
|
@@ -278,13 +253,71 @@ module GraphWeaver
|
|
|
278
253
|
|
|
279
254
|
private
|
|
280
255
|
|
|
281
|
-
#
|
|
282
|
-
#
|
|
283
|
-
#
|
|
284
|
-
#
|
|
256
|
+
# One fiber-local, set for the length of one call. A dynamic extent
|
|
257
|
+
# rather than a global: the value is only visible while the call it
|
|
258
|
+
# describes is on the stack, so a client that never reaches instrument
|
|
259
|
+
# can't leave a stale one behind.
|
|
260
|
+
def during(key, value)
|
|
261
|
+
return yield unless GraphWeaver.instrumenter
|
|
262
|
+
|
|
263
|
+
previous = Thread.current[key]
|
|
264
|
+
Thread.current[key] = value
|
|
265
|
+
begin
|
|
266
|
+
yield
|
|
267
|
+
ensure
|
|
268
|
+
Thread.current[key] = previous
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# The half both events share: run the block inside the hook, and
|
|
273
|
+
# record how it ended, the code an alert groups by, and how long it
|
|
274
|
+
# took — every one of them before the hook's block returns, so a
|
|
275
|
+
# subscriber reads a complete payload.
|
|
276
|
+
def measure(event, payload)
|
|
277
|
+
hook = GraphWeaver.instrumenter
|
|
278
|
+
return yield unless hook
|
|
279
|
+
|
|
280
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
281
|
+
# pessimistic, so :status is set even for what a rescue can't
|
|
282
|
+
# see — an Interrupt, a killed thread — and never silently absent
|
|
283
|
+
payload[:status] = :failed
|
|
284
|
+
|
|
285
|
+
hook.call(event, payload) do
|
|
286
|
+
result = yield
|
|
287
|
+
errors = response_errors(result)
|
|
288
|
+
if errors.empty?
|
|
289
|
+
payload[:status] = :ok
|
|
290
|
+
else
|
|
291
|
+
payload[:status] = :errors
|
|
292
|
+
code = errors.grep(GraphWeaver::GraphQLError).filter_map(&:code).first
|
|
293
|
+
payload[:code] = Redact.tag(code)
|
|
294
|
+
end
|
|
295
|
+
result
|
|
296
|
+
rescue => e
|
|
297
|
+
payload[:error] = e.class.name
|
|
298
|
+
# :code stays the GraphQL error code and nothing else — it used
|
|
299
|
+
# to hold a ServerError's status here, so one tag carried two
|
|
300
|
+
# dimensions ("THROTTLED" and 429). The number is :http_status.
|
|
301
|
+
raise
|
|
302
|
+
ensure
|
|
303
|
+
payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# The GraphQL errors a result carries, whatever answered it — a Hash
|
|
308
|
+
# from a transport, a graphql-ruby Result in-process, a fake, or the
|
|
309
|
+
# typed Response a generated cast built. A Hash becomes a GraphQLError
|
|
310
|
+
# so one reading answers for :code whichever arrived; anything else in
|
|
311
|
+
# the list is left as it came, since its presence alone already means
|
|
312
|
+
# the response carried errors. Never raises: an instrumenter that
|
|
313
|
+
# decides which exception a caller sees is worse than a missing tag.
|
|
285
314
|
def response_errors(result)
|
|
315
|
+
return result.errors if result.is_a?(GraphWeaver::Response)
|
|
316
|
+
|
|
286
317
|
errors = result.to_h["errors"] if result.respond_to?(:to_h)
|
|
287
|
-
errors.is_a?(Array)
|
|
318
|
+
return [] unless errors.is_a?(Array)
|
|
319
|
+
|
|
320
|
+
errors.map { |e| e.is_a?(Hash) ? GraphWeaver::GraphQLError.from_h(e) : e }
|
|
288
321
|
rescue StandardError
|
|
289
322
|
[]
|
|
290
323
|
end
|
data/lib/graph_weaver/parsing.rb
CHANGED
|
@@ -7,9 +7,9 @@ require_relative "codegen"
|
|
|
7
7
|
require_relative "internal"
|
|
8
8
|
|
|
9
9
|
module GraphWeaver
|
|
10
|
-
# Anything that holds a schema parses against it. That's a
|
|
11
|
-
# InProcess wrapper, a FakeClient, and the test Router — each of
|
|
12
|
-
# already has the two things parsing needs, a schema to check the query
|
|
10
|
+
# Anything that holds a schema parses and checks against it. That's a
|
|
11
|
+
# Client, an InProcess wrapper, a FakeClient, and the test Router — each of
|
|
12
|
+
# which already has the two things parsing needs, a schema to check the query
|
|
13
13
|
# against and a client for the module to run on:
|
|
14
14
|
#
|
|
15
15
|
# DashboardQuery = router.parse("query { me { username } }")
|
|
@@ -31,6 +31,35 @@ module GraphWeaver
|
|
|
31
31
|
GraphWeaver.parse(schema: T.unsafe(self).schema, query:, name:, client: self)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Does this query validate? The string form of GraphWeaver.check_queries,
|
|
35
|
+
# answering with the same JSON-ready hashes — `message`, `line`, `column`,
|
|
36
|
+
# plus `subgraphs` where a supergraph brands them — so an empty array means
|
|
37
|
+
# it validates:
|
|
38
|
+
#
|
|
39
|
+
# client.check_query("query { viewer { login } }") # => []
|
|
40
|
+
# client.check_query("query { viewer { lgoin } }")
|
|
41
|
+
# # => [{ "message" => "Field 'lgoin' doesn't exist on type 'User'",
|
|
42
|
+
# # "line" => 1, "column" => 17 }]
|
|
43
|
+
#
|
|
44
|
+
# Checked against this object's own schema — what `execute` would run
|
|
45
|
+
# against — so a url client introspects on first use as it always does, and
|
|
46
|
+
# nothing re-introspects the way check_queries defaults to. That is why it
|
|
47
|
+
# lives here: inside an example `GraphWeaver.client` is a fake, an InProcess
|
|
48
|
+
# or the test router, and each of those holds the schema the question is
|
|
49
|
+
# about. An unparseable source is an entry like any other; nothing here
|
|
50
|
+
# raises for a bad query. Shared fragments are inlined from fragments: the
|
|
51
|
+
# same way every other door inlines them.
|
|
52
|
+
def check_query(source, fragments: GraphWeaver.fragments_paths)
|
|
53
|
+
# a dump-backed Client can brand each error with the subgraph it is
|
|
54
|
+
# about; nothing else in the slot has a file behind its schema
|
|
55
|
+
holder = T.unsafe(self)
|
|
56
|
+
dump = holder.schema_source if holder.respond_to?(:schema_source)
|
|
57
|
+
GraphWeaver::Internal::QueryCheck.errors(
|
|
58
|
+
holder.schema, source, GraphWeaver::Codegen.load_fragments(fragments),
|
|
59
|
+
GraphWeaver::Internal::QueryCheck.routing_table_for(dump),
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
34
63
|
# Parse every query in a directory (subdirectories included) into typed
|
|
35
64
|
# modules, named like generation would name them — the no-build-step
|
|
36
65
|
# analog of generate! + load_generated!:
|
|
@@ -7,8 +7,6 @@ require_relative "internal"
|
|
|
7
7
|
require_relative "internal/test_clients"
|
|
8
8
|
|
|
9
9
|
module GraphWeaver
|
|
10
|
-
# Called by generated code — not semver'd for direct use.
|
|
11
|
-
#
|
|
12
10
|
# Runtime for generated query modules: the client plumbing, which is the
|
|
13
11
|
# one part of a generated module that carries no per-query type
|
|
14
12
|
# information — every module's copy was identical. `extend
|
|
@@ -16,6 +14,13 @@ module GraphWeaver
|
|
|
16
14
|
# stay generated, since their sigs are the query's types and those are the
|
|
17
15
|
# point.
|
|
18
16
|
#
|
|
17
|
+
# It is also the type every generated module satisfies, so code that takes
|
|
18
|
+
# any of them says `GraphWeaver::QueryModule` and reads `query_string` /
|
|
19
|
+
# `operation_name` with a sig behind each — rather than `const_get(:QUERY)`
|
|
20
|
+
# on a Module, which is what rubocop-sorbet forbids (ConstantsFromStrings,
|
|
21
|
+
# and ForbidTUnsafe for the T.unsafe that gets around it). Those readers
|
|
22
|
+
# and `client` are the supported surface; the rest is generated code's.
|
|
23
|
+
#
|
|
19
24
|
# Resolution order, per the docs: per call → a test mode's stand-in
|
|
20
25
|
# (Internal::TestClients) → the client the module's graph names →
|
|
21
26
|
# `GraphWeaver.client`. A module has no fifth slot you can set: a parsed
|
|
@@ -32,6 +37,18 @@ module GraphWeaver
|
|
|
32
37
|
@client || default_client
|
|
33
38
|
end
|
|
34
39
|
|
|
40
|
+
# The operation, verbatim — what goes on the wire as `query`.
|
|
41
|
+
sig { returns(String) }
|
|
42
|
+
def query_string
|
|
43
|
+
T.unsafe(self).const_get(:QUERY)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# What goes on the wire as `operationName`; nil for an anonymous operation.
|
|
47
|
+
sig { returns(T.nilable(String)) }
|
|
48
|
+
def operation_name
|
|
49
|
+
T.unsafe(self).const_get(:OPERATION_NAME)
|
|
50
|
+
end
|
|
51
|
+
|
|
35
52
|
private
|
|
36
53
|
|
|
37
54
|
# Bound by GraphWeaver.parse, which is the only caller: a parsed module
|
|
@@ -42,32 +59,69 @@ module GraphWeaver
|
|
|
42
59
|
attr_writer :client
|
|
43
60
|
|
|
44
61
|
# The one call a generated `execute` makes: resolve the client, run this
|
|
45
|
-
# module's own operation,
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
62
|
+
# module's own operation, and cast the raw response with the block the
|
|
63
|
+
# caller hands over — under one OPERATION_EVENT, which is the only seam
|
|
64
|
+
# that sees what the caller actually GOT. Here rather than emitted, so
|
|
65
|
+
# what has to bracket a call — the graph label, the event — costs nothing
|
|
66
|
+
# in every generated file, and one reading of it covers every module.
|
|
67
|
+
#
|
|
68
|
+
# Without a block it is the request alone, unreported: that is a module
|
|
69
|
+
# generated before the operation event existed, and an event closing :ok
|
|
70
|
+
# over half a call is worse than no event, since the cast it can't see is
|
|
71
|
+
# exactly where a CastError comes from.
|
|
49
72
|
#
|
|
50
73
|
# The constants come off the module rather than the caller: a generated
|
|
51
74
|
# `execute` already knows them, but reading them here is what makes this
|
|
52
75
|
# the whole of the call instead of three arguments' worth of it.
|
|
53
|
-
sig
|
|
54
|
-
|
|
76
|
+
sig do
|
|
77
|
+
params(
|
|
78
|
+
variables: T::Hash[String, T.untyped],
|
|
79
|
+
client: T.untyped,
|
|
80
|
+
cast: T.nilable(T.proc.params(raw: T.untyped).returns(T.untyped)),
|
|
81
|
+
).returns(T.untyped)
|
|
82
|
+
end
|
|
83
|
+
def dispatch(variables, client:, &cast)
|
|
55
84
|
# A value with no JSON form is a bug in the call, not in the client that
|
|
56
85
|
# would have carried it — so it is refused here, where every mode passes,
|
|
57
86
|
# rather than in the transport, which :in_process and :fake never reach.
|
|
58
87
|
# (A transport asks the same question of a raw query string, which never
|
|
59
88
|
# comes through here.)
|
|
60
89
|
GraphWeaver::Internal::Wire.check_variables!(variables)
|
|
90
|
+
target = client_for(client)
|
|
61
91
|
|
|
62
|
-
mod = T.unsafe(self)
|
|
63
92
|
# the graph codegen baked in, never one inferred from the client — a
|
|
64
93
|
# wrong label on a request is worse than no label
|
|
65
94
|
GraphWeaver::Internal::Log.with_graph(graph_name) do
|
|
66
|
-
|
|
67
|
-
|
|
95
|
+
next request(target, variables) unless cast
|
|
96
|
+
|
|
97
|
+
GraphWeaver::Internal::Log.instrument_operation(operation_payload(target)) do
|
|
98
|
+
cast.call(request(target, variables))
|
|
99
|
+
end
|
|
68
100
|
end
|
|
69
101
|
end
|
|
70
102
|
|
|
103
|
+
# This module's own operation, through the client this call resolved to.
|
|
104
|
+
sig { params(target: T.untyped, variables: T::Hash[String, T.untyped]).returns(T.untyped) }
|
|
105
|
+
def request(target, variables)
|
|
106
|
+
target.execute(query_string, variables:, operation_name:)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# What one call of this module is, for an APM. :module is the fact this
|
|
110
|
+
# seam has and the request below it doesn't — two graphs can name the same
|
|
111
|
+
# operation, and a trace that is slow wants the file. :client is what the
|
|
112
|
+
# module RESOLVED to, so a test mode's stand-in names itself; the request
|
|
113
|
+
# event underneath names the transport that carried it.
|
|
114
|
+
sig { params(target: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
|
|
115
|
+
def operation_payload(target)
|
|
116
|
+
{
|
|
117
|
+
operation: operation_name,
|
|
118
|
+
module: T.unsafe(self).name,
|
|
119
|
+
graph: graph_name,
|
|
120
|
+
kind: GraphWeaver::Internal::Wire.kind(query_string),
|
|
121
|
+
client: target.class,
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
71
125
|
# The client one execute runs through: the per-call `client:`, else the
|
|
72
126
|
# module's, else the app default. Checked here so a wrong one names the
|
|
73
127
|
# contract and the module, rather than surfacing as a NoMethodError from
|
|
@@ -81,7 +135,8 @@ module GraphWeaver
|
|
|
81
135
|
# Kernel.raise: this module is extended into another, so sorbet can't
|
|
82
136
|
# see that its host is an Object
|
|
83
137
|
Kernel.raise GraphWeaver::Error,
|
|
84
|
-
"#{self}: client must respond to #execute(query, variables:),
|
|
138
|
+
"#{self}: client must respond to #execute(query, variables:, operation_name:), " \
|
|
139
|
+
"got #{target.class}"
|
|
85
140
|
end
|
|
86
141
|
|
|
87
142
|
# A module knows which graph it belongs to, and the graph knows how to
|
data/lib/graph_weaver/railtie.rb
CHANGED
|
@@ -35,7 +35,7 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
35
35
|
KEYS = %i[watch].freeze
|
|
36
36
|
|
|
37
37
|
def method_missing(name, *args)
|
|
38
|
-
key = name
|
|
38
|
+
key = setting(name)
|
|
39
39
|
return super if KEYS.include?(key)
|
|
40
40
|
|
|
41
41
|
raise ArgumentError, refusal(key)
|
|
@@ -53,11 +53,16 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
53
53
|
alias_method :store, :[]=
|
|
54
54
|
|
|
55
55
|
def respond_to_missing?(name, _private = false)
|
|
56
|
-
KEYS.include?(name
|
|
56
|
+
KEYS.include?(setting(name))
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
private
|
|
60
60
|
|
|
61
|
+
# the setting a reader, writer or predicate is about
|
|
62
|
+
def setting(name)
|
|
63
|
+
name.to_s.delete_suffix("=").delete_suffix("?").delete_suffix("!").to_sym
|
|
64
|
+
end
|
|
65
|
+
|
|
61
66
|
def refusal(key)
|
|
62
67
|
near = GraphWeaver::Internal::Util.did_you_mean(KEYS.map(&:to_s), key.to_s)
|
|
63
68
|
fix =
|