graph_weaver 0.7.5 → 0.7.7
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 +43 -43
- data/docs/errors.md +7 -3
- data/docs/generated_modules.md +66 -16
- data/docs/getting_started.md +39 -19
- 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 +43 -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 +67 -6
- 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 +23 -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/railtie.rb +250 -11
- 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 +76 -43
- metadata +2 -2
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
|