graph_weaver 0.7.5 → 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/docs/errors.md +7 -3
- data/docs/generated_modules.md +10 -6
- data/docs/getting_started.md +34 -17
- data/docs/logging.md +79 -35
- data/docs/migrating.md +11 -4
- data/docs/scalars.md +12 -3
- data/docs/testing.md +75 -13
- data/docs/upgrading.md +25 -3
- 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 +20 -2
- data/lib/graph_weaver/client.rb +0 -23
- data/lib/graph_weaver/codegen/aliases.rb +15 -3
- data/lib/graph_weaver/codegen/emit.rb +7 -6
- data/lib/graph_weaver/codegen/enum_type.rb +26 -2
- data/lib/graph_weaver/codegen/nodes.rb +21 -2
- data/lib/graph_weaver/codegen.rb +106 -54
- data/lib/graph_weaver/graph.rb +55 -5
- data/lib/graph_weaver/in_process.rb +1 -1
- data/lib/graph_weaver/input_struct.rb +29 -4
- data/lib/graph_weaver/internal/overrides.rb +126 -14
- data/lib/graph_weaver/internal/test_clients.rb +29 -7
- data/lib/graph_weaver/internal/unused.rb +30 -11
- data/lib/graph_weaver/internal/values.rb +17 -5
- data/lib/graph_weaver/internal.rb +6 -6
- data/lib/graph_weaver/log_subscriber.rb +27 -17
- data/lib/graph_weaver/logging.rb +89 -53
- data/lib/graph_weaver/parsing.rb +32 -3
- data/lib/graph_weaver/query_module.rb +48 -8
- data/lib/graph_weaver/rspec.rb +41 -17
- data/lib/graph_weaver/schema_diff.rb +24 -5
- data/lib/graph_weaver/schema_loader.rb +22 -9
- data/lib/graph_weaver/tasks.rb +71 -44
- data/lib/graph_weaver/testing/fake_client.rb +24 -21
- data/lib/graph_weaver/testing.rb +27 -7
- data/lib/graph_weaver/transport.rb +1 -1
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +59 -35
- metadata +2 -2
|
@@ -26,10 +26,17 @@ module GraphWeaver
|
|
|
26
26
|
# call that names none of them. Caught where the sink line carries the
|
|
27
27
|
# module's own name, or a local a line above assigned from it.
|
|
28
28
|
SINKS = /\b(?:to_h|to_json|as_json|serialize|deconstruct_keys)\b|render\s+json:/
|
|
29
|
-
# `result = PersonQuery.execute!(...)` — the
|
|
29
|
+
# `result = PersonQuery.execute!(...)` — the name a response lands in.
|
|
30
30
|
# Following one is what lets the sink be on the NEXT line, which is how
|
|
31
|
-
# anyone actually writes a controller.
|
|
32
|
-
|
|
31
|
+
# anyone actually writes a controller. The `@` is part of the capture:
|
|
32
|
+
# it is what says the name outlives the method. Excludes == and =~.
|
|
33
|
+
ASSIGN = /(@?\b[a-z_]\w*)\s*=[^=~]/
|
|
34
|
+
# Where a plain local stops standing for the module it was assigned
|
|
35
|
+
# from: the next method is a new scope, and a block param there that
|
|
36
|
+
# happens to share the name holds someone else's value. An ivar crosses
|
|
37
|
+
# it — `before_action` loading `@result` for the action to render is
|
|
38
|
+
# the shape every Rails controller has.
|
|
39
|
+
SCOPE = /^[ \t]*def\s/
|
|
33
40
|
# A graphql-ruby TYPE class NAMES every field the server offers, as
|
|
34
41
|
# `field :sku` and as a resolver method — which is the server answering,
|
|
35
42
|
# not this app reading a prop back. Without this an app that serves the
|
|
@@ -189,6 +196,7 @@ module GraphWeaver
|
|
|
189
196
|
# `result = Q.execute!(...)`, then `render json: result.person`.
|
|
190
197
|
locals = Hash.new { |hash, key| hash[key] = [] }
|
|
191
198
|
body.each_line.with_index(1) do |line, number|
|
|
199
|
+
locals.each_value { |names| names.select! { |n| n.start_with?("@") } } if SCOPE.match?(line)
|
|
192
200
|
candidates.each do |name, base|
|
|
193
201
|
locals[name] << Regexp.last_match(1) if line.include?(base) && ASSIGN.match(line)
|
|
194
202
|
end
|
|
@@ -197,7 +205,7 @@ module GraphWeaver
|
|
|
197
205
|
candidates.each do |name, base|
|
|
198
206
|
# the line naming the module is the better evidence; the local
|
|
199
207
|
# is what it falls back to
|
|
200
|
-
via = locals[name].find { |local| line.match?(
|
|
208
|
+
via = locals[name].find { |local| line.match?(/(?<![\w@])#{Regexp.escape(local)}\b/) } \
|
|
201
209
|
unless line.include?(base)
|
|
202
210
|
next unless via || line.include?(base)
|
|
203
211
|
|
|
@@ -221,20 +229,31 @@ module GraphWeaver
|
|
|
221
229
|
result = Object.const_get(name)
|
|
222
230
|
next [] unless result.const_defined?(:Result, false)
|
|
223
231
|
|
|
224
|
-
|
|
232
|
+
keys = response_keys(GraphQL.parse(source).definitions)
|
|
225
233
|
props(result.const_get(:Result, false))
|
|
226
|
-
.map { |struct, prop| Selection.new(path, name, struct, prop, wire_word(
|
|
234
|
+
.map { |struct, prop| Selection.new(path, name, struct, prop, wire_word(keys, prop)) }
|
|
227
235
|
end
|
|
228
236
|
end
|
|
229
237
|
end
|
|
230
238
|
|
|
231
239
|
# How the query spells a prop, when that isn't the prop's own name — a
|
|
232
240
|
# camelCase field, an alias, a reserved rename. Read back off the query
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
241
|
+
# rather than derived from the prop, since no rule inverts an alias; nil
|
|
242
|
+
# when the query spells it the same way, which is most of the time.
|
|
243
|
+
def wire_word(keys, prop)
|
|
244
|
+
keys.find { |key| key != prop.to_s && GraphWeaver::Codegen.prop_name(key) == prop.to_s }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Every response key the document asks for. The coordinate is the
|
|
248
|
+
# selection to go and delete, so it can only be one of these: scanning
|
|
249
|
+
# the file's text let `$id: ID!` — or a comment — name a field the query
|
|
250
|
+
# never selected.
|
|
251
|
+
def response_keys(nodes, found = [])
|
|
252
|
+
nodes.each do |node|
|
|
253
|
+
found << (node.alias || node.name) if node.is_a?(GraphQL::Language::Nodes::Field)
|
|
254
|
+
response_keys(node.selections, found) if node.respond_to?(:selections)
|
|
255
|
+
end
|
|
256
|
+
found
|
|
238
257
|
end
|
|
239
258
|
|
|
240
259
|
# Nested structs are nested constants, so the props of a whole response
|
|
@@ -88,7 +88,12 @@ class GraphWeaver::Internal::Values
|
|
|
88
88
|
# for the next, and only a caller holding the graph can say which. Left
|
|
89
89
|
# unsaid they are read back off the schema, which is the answer for every
|
|
90
90
|
# app with one graph running a live class.
|
|
91
|
-
|
|
91
|
+
# pin_advice: how to pin, in the words of the door the caller came in by —
|
|
92
|
+
# a callable taking the scalar's name. Left unsaid it is the three doors
|
|
93
|
+
# onto a fake, which is where this is reached from unless something says
|
|
94
|
+
# otherwise (check_scalars! runs outside all three).
|
|
95
|
+
def initialize(seed: nil, values: nil, pins: nil, schema: nil, registry: nil, pin_advice: nil)
|
|
96
|
+
@pin_advice = pin_advice
|
|
92
97
|
@registry = registry || GraphWeaver::Internal::Util.registry_for(schema)
|
|
93
98
|
@rng = Random.new(seed || GraphWeaver::Testing.config.seed || Random.new_seed)
|
|
94
99
|
@pins = (pins || GraphWeaver::Testing.config.overrides).transform_keys(&:to_s)
|
|
@@ -202,12 +207,19 @@ class GraphWeaver::Internal::Values
|
|
|
202
207
|
# for — `Money.parse` accepts what its author decided it accepts — and
|
|
203
208
|
# guessing hands the generated cast a placeholder, which fails deep inside
|
|
204
209
|
# from_h blaming the codec.
|
|
210
|
+
#
|
|
211
|
+
# Each door onto a fake spells a pin differently and this can't know which
|
|
212
|
+
# you came in by, so the advice names all three — unless the caller said,
|
|
213
|
+
# in which case it is the one that works there.
|
|
205
214
|
def unfakeable!(type_name, field_name, registered, coordinate, at)
|
|
215
|
+
type = type_name.inspect
|
|
216
|
+
advice = @pin_advice&.call(type_name) ||
|
|
217
|
+
"Pin the type (#{type}) or just this field (#{(coordinate || field_name).inspect}), " \
|
|
218
|
+
"wherever the fake is built — graphql_fake(#{type} => ...) in an rspec example, " \
|
|
219
|
+
"FakeClient.new(#{type} => ...) outside one, or GraphWeaver::Testing.config.overrides " \
|
|
220
|
+
"for the whole suite."
|
|
206
221
|
raise GraphWeaver::Error, "can't fabricate a #{type_name} #{at ? "at #{at}" : "for #{field_name.inspect}"}: " \
|
|
207
|
-
"it deserializes into #{registered.type}, and only you know what wire value that accepts. "
|
|
208
|
-
"Pin the type — overrides: { #{type_name.inspect} => ... } — or this one field: " \
|
|
209
|
-
"overrides: { #{(coordinate || field_name).inspect} => ... }. Suite-wide, that's " \
|
|
210
|
-
"GraphWeaver::Testing.config.overrides."
|
|
222
|
+
"it deserializes into #{registered.type}, and only you know what wire value that accepts. #{advice}"
|
|
211
223
|
end
|
|
212
224
|
|
|
213
225
|
# :faker is an explicit ask — fail loudly when the gem is missing; auto
|
|
@@ -243,14 +243,14 @@ module GraphWeaver
|
|
|
243
243
|
"or cache one: GraphWeaver.new(url, cache: true).schema"
|
|
244
244
|
end
|
|
245
245
|
|
|
246
|
-
# The graphql-ruby schema class
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
# the class object is
|
|
250
|
-
|
|
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)
|
|
251
252
|
# through #transport, not #schema: a url client's #schema
|
|
252
253
|
# introspects, so asking it would answer over the network
|
|
253
|
-
client = GraphWeaver.client
|
|
254
254
|
target = client.is_a?(Client) ? client.transport : client
|
|
255
255
|
target = target.schema if target.is_a?(InProcess)
|
|
256
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.
|
|
@@ -137,14 +140,24 @@ module GraphWeaver
|
|
|
137
140
|
|
|
138
141
|
self.filter_parameters = DEFAULT_FILTER_PARAMETERS
|
|
139
142
|
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
# is how every notification in this
|
|
143
|
-
# (sql.active_record, execute_multiplex.graphql) —
|
|
144
|
-
# ActiveSupport::LogSubscriber.attach_to and an APM's namespace
|
|
145
|
-
# 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.
|
|
146
149
|
EXECUTE_EVENT = "execute.graph_weaver"
|
|
147
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
|
+
|
|
148
161
|
module Internal
|
|
149
162
|
# The emitting half of the narration the three accessors above
|
|
150
163
|
# configure. Setting a logger is API; writing to it is not, and the
|
|
@@ -174,48 +187,29 @@ module GraphWeaver
|
|
|
174
187
|
result
|
|
175
188
|
end
|
|
176
189
|
|
|
177
|
-
# Wrap
|
|
178
|
-
# supplies what only it knows (:url, :schema, :client); this
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
hook = GraphWeaver.instrumenter
|
|
184
|
-
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
|
|
185
196
|
|
|
186
|
-
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
187
197
|
retries = Thread.current[RETRIES]
|
|
188
198
|
payload[:retries] = retries if retries
|
|
189
199
|
payload[:graph] = Thread.current[GRAPH]
|
|
190
|
-
# pessimistic, so :status is set even for what a rescue can't
|
|
191
|
-
# see — an Interrupt, a killed thread — and never silently absent
|
|
192
|
-
payload[:status] = :failed
|
|
193
200
|
|
|
194
201
|
# One dispatch labels one request. Whatever THIS request reaches —
|
|
195
202
|
# a resolver serving it that calls out — is a request of its own,
|
|
196
203
|
# and the caller's graph would be a wrong label on it.
|
|
197
|
-
with_graph(nil)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
payload[:code] = Redact.tag(code)
|
|
207
|
-
end
|
|
208
|
-
result
|
|
209
|
-
rescue => e
|
|
210
|
-
payload[:error] = e.class.name
|
|
211
|
-
# :code stays the GraphQL error code and nothing else — it used
|
|
212
|
-
# to hold a ServerError's status here, so one tag carried two
|
|
213
|
-
# dimensions ("THROTTLED" and 429). The number is :http_status.
|
|
214
|
-
raise
|
|
215
|
-
ensure
|
|
216
|
-
payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
|
|
217
|
-
end
|
|
218
|
-
end
|
|
204
|
+
with_graph(nil) { measure(GraphWeaver::EXECUTE_EVENT, payload, &block) }
|
|
205
|
+
end
|
|
206
|
+
|
|
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)
|
|
219
213
|
end
|
|
220
214
|
|
|
221
215
|
# What a Retry has already spent, read by the attempt it is about
|
|
@@ -275,13 +269,55 @@ module GraphWeaver
|
|
|
275
269
|
end
|
|
276
270
|
end
|
|
277
271
|
|
|
278
|
-
# The
|
|
279
|
-
#
|
|
280
|
-
#
|
|
281
|
-
#
|
|
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.
|
|
282
314
|
def response_errors(result)
|
|
315
|
+
return result.errors if result.is_a?(GraphWeaver::Response)
|
|
316
|
+
|
|
283
317
|
errors = result.to_h["errors"] if result.respond_to?(:to_h)
|
|
284
|
-
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 }
|
|
285
321
|
rescue StandardError
|
|
286
322
|
[]
|
|
287
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!:
|
|
@@ -59,30 +59,69 @@ module GraphWeaver
|
|
|
59
59
|
attr_writer :client
|
|
60
60
|
|
|
61
61
|
# The one call a generated `execute` makes: resolve the client, run this
|
|
62
|
-
# module's own operation,
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
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.
|
|
66
72
|
#
|
|
67
73
|
# The constants come off the module rather than the caller: a generated
|
|
68
74
|
# `execute` already knows them, but reading them here is what makes this
|
|
69
75
|
# the whole of the call instead of three arguments' worth of it.
|
|
70
|
-
sig
|
|
71
|
-
|
|
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)
|
|
72
84
|
# A value with no JSON form is a bug in the call, not in the client that
|
|
73
85
|
# would have carried it — so it is refused here, where every mode passes,
|
|
74
86
|
# rather than in the transport, which :in_process and :fake never reach.
|
|
75
87
|
# (A transport asks the same question of a raw query string, which never
|
|
76
88
|
# comes through here.)
|
|
77
89
|
GraphWeaver::Internal::Wire.check_variables!(variables)
|
|
90
|
+
target = client_for(client)
|
|
78
91
|
|
|
79
92
|
# the graph codegen baked in, never one inferred from the client — a
|
|
80
93
|
# wrong label on a request is worse than no label
|
|
81
94
|
GraphWeaver::Internal::Log.with_graph(graph_name) do
|
|
82
|
-
|
|
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
|
|
83
100
|
end
|
|
84
101
|
end
|
|
85
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
|
+
|
|
86
125
|
# The client one execute runs through: the per-call `client:`, else the
|
|
87
126
|
# module's, else the app default. Checked here so a wrong one names the
|
|
88
127
|
# contract and the module, rather than surfacing as a NoMethodError from
|
|
@@ -96,7 +135,8 @@ module GraphWeaver
|
|
|
96
135
|
# Kernel.raise: this module is extended into another, so sorbet can't
|
|
97
136
|
# see that its host is an Object
|
|
98
137
|
Kernel.raise GraphWeaver::Error,
|
|
99
|
-
"#{self}: client must respond to #execute(query, variables:),
|
|
138
|
+
"#{self}: client must respond to #execute(query, variables:, operation_name:), " \
|
|
139
|
+
"got #{target.class}"
|
|
100
140
|
end
|
|
101
141
|
|
|
102
142
|
# A module knows which graph it belongs to, and the graph knows how to
|
data/lib/graph_weaver/rspec.rb
CHANGED
|
@@ -161,7 +161,9 @@ module GraphWeaver
|
|
|
161
161
|
# suite's WebMock setup is touched.
|
|
162
162
|
def self.serve!
|
|
163
163
|
webmock!
|
|
164
|
-
|
|
164
|
+
targets, above = wire_targets
|
|
165
|
+
above.each { |graph| disclose_above!(graph) }
|
|
166
|
+
targets.map do |url, graph|
|
|
165
167
|
# built here, so a graph with nothing to serve refuses before the
|
|
166
168
|
# example runs rather than from inside its first request
|
|
167
169
|
disclose!(GraphWeaver::Internal::TestClients.standin(graph), url, graph)
|
|
@@ -210,6 +212,17 @@ module GraphWeaver
|
|
|
210
212
|
end
|
|
211
213
|
end
|
|
212
214
|
|
|
215
|
+
# Say which graph ran above the wire. Its stand-in is built when one of
|
|
216
|
+
# its modules first runs, not here — a graph the example never touches
|
|
217
|
+
# must not refuse it — so this names the graph rather than what is
|
|
218
|
+
# behind it.
|
|
219
|
+
def self.disclose_above!(graph)
|
|
220
|
+
GraphWeaver::Internal::Log.log(:info) do
|
|
221
|
+
":wire has no endpoint for #{graph.name ? "graph #{graph.name.inspect}" : "this app"} — " \
|
|
222
|
+
"its client posts to none, so its modules run above the wire, as #{TAG}: :in_process would"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
213
226
|
# What the stand-in IS, read off the object rather than re-deciding —
|
|
214
227
|
# one answer, and it can't drift from what was built. A fake of a dump
|
|
215
228
|
# has no name to give: the dump loads as an anonymous class, and
|
|
@@ -254,21 +267,24 @@ module GraphWeaver
|
|
|
254
267
|
# inside the cleanup.
|
|
255
268
|
def self.unserve!(stub) = WebMock::StubRegistry.instance.request_stubs.delete(stub)
|
|
256
269
|
|
|
257
|
-
#
|
|
258
|
-
# whose resolvers belong behind it
|
|
259
|
-
#
|
|
260
|
-
#
|
|
270
|
+
# What :wire does with each graph, in two lists: the endpoints to stub,
|
|
271
|
+
# each with the graph whose resolvers belong behind it, and the graphs
|
|
272
|
+
# there is no endpoint for. One graph per endpoint — an app whose graphs
|
|
273
|
+
# all name clients needs no app default at all.
|
|
274
|
+
#
|
|
275
|
+
# A graph whose client posts to no url has no wire to be served at, so
|
|
276
|
+
# it runs above one instead of refusing the example — including the
|
|
277
|
+
# examples that never touch it. An example where NO graph posts anywhere
|
|
278
|
+
# is refused, since a :wire that serves nothing tests no transport.
|
|
261
279
|
def self.wire_targets
|
|
262
|
-
targets = GraphWeaver.graphs.
|
|
263
|
-
client = graph.client || GraphWeaver.client
|
|
264
|
-
[endpoint!(client, graph), graph] if client
|
|
265
|
-
end
|
|
280
|
+
targets, above = GraphWeaver.graphs.map { |graph| [graph.client_url, graph] }.partition(&:first)
|
|
266
281
|
refuse_shared_endpoint!(targets)
|
|
267
|
-
return targets if targets.any?
|
|
282
|
+
return [targets, above.map(&:last)] if targets.any?
|
|
268
283
|
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
|
|
284
|
+
# the endpoint refusal names the client that posts to none, which is
|
|
285
|
+
# the thing to fix
|
|
286
|
+
graph = GraphWeaver.graphs.first
|
|
287
|
+
endpoint!(graph&.client || GraphWeaver.client, graph)
|
|
272
288
|
end
|
|
273
289
|
|
|
274
290
|
# One stub per url, so two graphs on one endpoint used to mean the
|
|
@@ -343,7 +359,8 @@ module GraphWeaver
|
|
|
343
359
|
end
|
|
344
360
|
|
|
345
361
|
private_class_method :wire_targets, :refuse_shared_endpoint!, :whose_client,
|
|
346
|
-
:webmock!, :webmock_enabled?, :disclose!, :served, :unnamed_schemas,
|
|
362
|
+
:webmock!, :webmock_enabled?, :disclose!, :disclose_above!, :served, :unnamed_schemas,
|
|
363
|
+
:loaded_schemas
|
|
347
364
|
|
|
348
365
|
# Included into every example group, so graphql_context is there
|
|
349
366
|
# whether or not this example took a client from the hook.
|
|
@@ -570,10 +587,17 @@ module GraphWeaver
|
|
|
570
587
|
raise GraphWeaver::Error, "graphql_context needs resolvers to receive it, and a " \
|
|
571
588
|
"#{TAG}: :fake example runs against fabricated data — tag it #{TAG}: :in_process or " \
|
|
572
589
|
"#{TAG}: :router (or pin the data itself: " \
|
|
573
|
-
"graphql_fake(
|
|
590
|
+
"graphql_fake(\"Person.name\" => \"Ada\"))"
|
|
574
591
|
else
|
|
575
|
-
|
|
576
|
-
|
|
592
|
+
# :live, which is what an untagged example is. The app's own client
|
|
593
|
+
# stays in the slot holding the context it was built with, so there
|
|
594
|
+
# is nothing here to merge onto — and saying "you need resolvers"
|
|
595
|
+
# reads as false to an app whose own client is an InProcess, which
|
|
596
|
+
# is running them
|
|
597
|
+
raise GraphWeaver::Error, "graphql_context says what THIS example's resolvers see, " \
|
|
598
|
+
"and #{TAG}: :live leaves your app's own client exactly as it is — carrying the " \
|
|
599
|
+
"context it was built with. Tag the example #{TAG}: :in_process or #{TAG}: :router, " \
|
|
600
|
+
"which build a client per example."
|
|
577
601
|
end
|
|
578
602
|
end
|
|
579
603
|
|