graph_weaver 0.6.1 → 0.7.1
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 +8 -0
- data/Gemfile.lock +153 -4
- data/README.md +45 -79
- data/docs/alternatives.md +195 -0
- data/docs/cassettes.md +61 -50
- data/docs/editors.md +32 -47
- data/docs/errors.md +360 -103
- data/docs/federation.md +692 -473
- data/docs/generated_modules.md +441 -314
- data/docs/getting_started.md +370 -194
- data/docs/i18n.md +171 -0
- data/docs/logging.md +197 -50
- data/docs/real_world.md +42 -27
- data/docs/scalars.md +307 -176
- data/docs/testing.md +473 -220
- data/docs/transports.md +224 -151
- data/docs/upgrading.md +258 -305
- 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 +19 -3
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +69 -11
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +406 -195
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +284 -46
- data/lib/graph_weaver/federation.rb +129 -27
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +27 -15
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +80 -0
- data/lib/graph_weaver/internal/headers.rb +70 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +40 -4
- data/lib/graph_weaver/internal.rb +249 -14
- data/lib/graph_weaver/log_subscriber.rb +74 -0
- data/lib/graph_weaver/logging.rb +163 -19
- data/lib/graph_weaver/query_module.rb +44 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +45 -13
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +266 -56
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +34 -10
- data/lib/graph_weaver/testing/endpoint.rb +107 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +164 -45
- data/lib/graph_weaver/testing/router.rb +64 -13
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +48 -6
- data/lib/graph_weaver/transport.rb +134 -27
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +495 -106
- metadata +71 -3
- data/CHANGELOG.md +0 -2355
|
@@ -47,26 +47,63 @@ module GraphWeaver
|
|
|
47
47
|
DidYouMean::SpellChecker.new(dictionary: dictionary).correct(term).first
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# "a" or "an" for a word an error message is about to name.
|
|
51
|
+
def article(word) = word.downcase.start_with?(/[aeiou]/) ? "an" : "a"
|
|
52
|
+
|
|
50
53
|
# The module a .graphql file generates, and the basename of the file
|
|
51
54
|
# it generates into: the camelized file name plus the operation's own
|
|
52
|
-
# word.
|
|
55
|
+
# word. Every run of non-alphanumerics in the name is a word boundary,
|
|
56
|
+
# and a trailing extension naming the document's own operation kind is
|
|
57
|
+
# dropped rather than doubled.
|
|
53
58
|
#
|
|
54
59
|
# person.graphql => PersonQuery (person_query.rb)
|
|
55
60
|
# save_list_entry.graphql => SaveListEntryMutation
|
|
56
61
|
# (save_list_entry_mutation.rb)
|
|
62
|
+
# get-hello.graphql => GetHelloQuery (get_hello_query.rb)
|
|
63
|
+
# hello.query.graphql => HelloQuery (hello_query.rb)
|
|
57
64
|
#
|
|
58
65
|
# Every naming site goes through here — generate!, parse(path), and
|
|
59
66
|
# load_queries! — so the constant a file produces is the same one
|
|
60
67
|
# whichever door you came in by, and the file it lands in matches it.
|
|
61
68
|
def generated_names(path, source)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
kind = operation_kind(source)
|
|
70
|
+
base = strip_kind_extension(File.basename(path, ".*"), kind, path)
|
|
71
|
+
stem = base.gsub(/[^A-Za-z0-9]+/, "_")
|
|
72
|
+
suffix = (kind == "mutation") ? "Mutation" : "Query"
|
|
73
|
+
name = Inflect.camelize(stem)
|
|
74
|
+
# all punctuation camelizes to nothing, which would leave the suffix
|
|
75
|
+
# standing alone as the whole name — keep the base so it stays refusable
|
|
76
|
+
name = base if name.empty?
|
|
77
|
+
["#{name}#{suffix}", "#{stem}_#{suffix.downcase}.rb"]
|
|
65
78
|
end
|
|
66
79
|
|
|
67
80
|
# just the module name — see generated_names
|
|
68
81
|
def module_name(path, source) = generated_names(path, source).first
|
|
69
82
|
|
|
83
|
+
# The one sentence about scalars nothing registered — said on the
|
|
84
|
+
# logger per parse and once per run by the build, and worth saying
|
|
85
|
+
# identically in both. Keyed by graph name (nil for an app with no
|
|
86
|
+
# declared graphs): a registration is scoped to one graph, so merging
|
|
87
|
+
# the names across several would read as "forgotten everywhere" for a
|
|
88
|
+
# scalar registered for one of them and forgotten for the next.
|
|
89
|
+
def untyped_scalars_report(by_graph)
|
|
90
|
+
found = by_graph.reject { |_, names| names.empty? }
|
|
91
|
+
return if found.empty?
|
|
92
|
+
|
|
93
|
+
advice = "(register with GraphWeaver.register_scalar)"
|
|
94
|
+
# One graph ran, so there is nothing to attribute — including when it
|
|
95
|
+
# is the only one with findings is what made a forgetful graph read
|
|
96
|
+
# as a forgetful app.
|
|
97
|
+
if by_graph.one?
|
|
98
|
+
names = found.values.first.sort
|
|
99
|
+
return "#{names.size} unregistered custom scalar#{"s" unless names.one?} → T.untyped: " \
|
|
100
|
+
"#{names.join(", ")} #{advice}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
["unregistered custom scalars → T.untyped #{advice}:",
|
|
104
|
+
*found.map { |graph, names| " graph #{graph.inspect}: #{names.sort.join(", ")}" }].join("\n")
|
|
105
|
+
end
|
|
106
|
+
|
|
70
107
|
# A path setting, as a real path: relative to GraphWeaver.root, which
|
|
71
108
|
# is the app root and not wherever the process was started. Every
|
|
72
109
|
# filesystem access on a configured path goes through here; the
|
|
@@ -85,12 +122,95 @@ module GraphWeaver
|
|
|
85
122
|
path.start_with?(prefix) ? path.delete_prefix(prefix) : path
|
|
86
123
|
end
|
|
87
124
|
|
|
125
|
+
# The registrations a schema generates with: the graph that named it,
|
|
126
|
+
# or the default graph's. The testing fakes ask, so a fabricated
|
|
127
|
+
# scalar is the shape the module generated against that schema will
|
|
128
|
+
# cast — a `Money` registered for one graph is not a `Money` for the
|
|
129
|
+
# next one along. Matched on the schema class a graph runs in-process,
|
|
130
|
+
# which is the only identity cheap enough to ask per fake; anything
|
|
131
|
+
# else falls back to the default, which is where a single-schema app
|
|
132
|
+
# has always read from.
|
|
133
|
+
def registry_for(schema) = graph_for(schema)&.registry || Codegen.registry
|
|
134
|
+
|
|
135
|
+
# The declared graph that runs `schema` in-process, or nil. Matched on
|
|
136
|
+
# the schema class a graph runs, which is the only identity cheap
|
|
137
|
+
# enough to ask per call — a dump would have to be re-read, and
|
|
138
|
+
# re-reading it gives a different object every time. GraphWeaver.parse
|
|
139
|
+
# asks too, to bake the GRAPH a generated file would have carried.
|
|
140
|
+
def graph_for(schema)
|
|
141
|
+
schema && GraphWeaver.graphs.find { |candidate| candidate.live_schema.equal?(schema) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Where generated modules are READ from: the configured patterns, plus
|
|
145
|
+
# any graph writing somewhere they don't already cover. generated_paths'
|
|
146
|
+
# default glob (app/graphql/*/generated) covers the conventional layout,
|
|
147
|
+
# so listing a graph's output as well would name the same directory
|
|
148
|
+
# twice — in the log, and in the globbing.
|
|
149
|
+
#
|
|
150
|
+
# FNM_PATHNAME because Dir.glob is what expands these patterns
|
|
151
|
+
# everywhere else (Zeitwerk's ignore, load_generated!) and its * stops
|
|
152
|
+
# at a /. Without it app/graphql/*/generated "covered"
|
|
153
|
+
# app/graphql/a/b/generated, which was then neither ignored nor loaded.
|
|
154
|
+
def generated_dirs
|
|
155
|
+
extra = GraphWeaver.graphs.map(&:output).reject do |dir|
|
|
156
|
+
GraphWeaver.generated_paths.any? do |pattern|
|
|
157
|
+
File.fnmatch?(resolve(pattern), resolve(dir), File::FNM_PATHNAME)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
GraphWeaver.generated_paths | extra
|
|
161
|
+
end
|
|
162
|
+
|
|
88
163
|
# Every query document under these directories, sorted — the files
|
|
89
164
|
# generate!, verify_generated!, check_queries and load_queries! read.
|
|
90
165
|
def query_files(paths = GraphWeaver.queries_paths)
|
|
91
166
|
Array(paths).flat_map { |dir| Dir[File.join(resolve(dir), Codegen::DOCUMENT_GLOB)].sort }
|
|
92
167
|
end
|
|
93
168
|
|
|
169
|
+
# Anywhere GraphWeaver takes schema:, a Client stands for its schema — so
|
|
170
|
+
# the console object and the rake task point at the same thing. A path
|
|
171
|
+
# (String or Pathname) or SDL loads like it does everywhere else in the
|
|
172
|
+
# library; without that it reached `schema.validate` as itself and failed
|
|
173
|
+
# as `undefined method 'validate' for an instance of String`.
|
|
174
|
+
def schema_for(source)
|
|
175
|
+
return source.schema if source.is_a?(Client)
|
|
176
|
+
return SchemaLoader.load(source) if source.is_a?(String) || source.respond_to?(:to_path)
|
|
177
|
+
|
|
178
|
+
source
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Whether this source carries the @join__* routing table, i.e. is a
|
|
182
|
+
# composed supergraph rather than an API schema. The one place that
|
|
183
|
+
# asks: Graph#supergraph reads it per graph, Testing::Config for the
|
|
184
|
+
# app-wide fallbacks, and the federation rake tasks through both.
|
|
185
|
+
#
|
|
186
|
+
# Kept per source for the life of the process — parsing a supergraph
|
|
187
|
+
# is milliseconds and :wire asks per example — and keyed on what the
|
|
188
|
+
# file IS, since the answer is a property of its content. A supergraph
|
|
189
|
+
# recomposed at a stable path (a `before` hook, chained rake tasks)
|
|
190
|
+
# used to be answered from the previous composition, which routed a
|
|
191
|
+
# graph into the wrong plan or refused it as being in none.
|
|
192
|
+
def composed?(source)
|
|
193
|
+
@composed ||= {}
|
|
194
|
+
key = composed_key(source)
|
|
195
|
+
return @composed[key] if @composed.key?(key)
|
|
196
|
+
|
|
197
|
+
@composed[key] = begin
|
|
198
|
+
SchemaLoader.routing_table?(source)
|
|
199
|
+
rescue GraphWeaver::Error
|
|
200
|
+
# a source that can't even be read is in no supergraph either —
|
|
201
|
+
# and that Error is worth its warn line, where "not federated"
|
|
202
|
+
# never was
|
|
203
|
+
false
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# the conventional schema dump, required
|
|
208
|
+
def locate_schema!
|
|
209
|
+
SchemaLoader.locate or raise GraphWeaver::Error,
|
|
210
|
+
"no schema dump at #{GraphWeaver.schema_path} (.json/.graphql/.gql) — pass schema:, " \
|
|
211
|
+
"or cache one: GraphWeaver.new(url, cache: true).schema"
|
|
212
|
+
end
|
|
213
|
+
|
|
94
214
|
# The graphql-ruby schema class the app default executes against,
|
|
95
215
|
# when it runs in-process — a Client wrapping one, or the class in
|
|
96
216
|
# the slot bare. nil for every network client. Not memoized: in dev
|
|
@@ -104,15 +224,62 @@ module GraphWeaver
|
|
|
104
224
|
target if target.is_a?(Class) && target <= GraphQL::Schema
|
|
105
225
|
end
|
|
106
226
|
|
|
227
|
+
# The context to hand resolvers. A proc is answered from a request's
|
|
228
|
+
# headers (Testing::Endpoint resolves it), so off the wire there is
|
|
229
|
+
# nothing to answer it with — and a Proc reaching graphql-ruby as a
|
|
230
|
+
# context fails far from the line that set it.
|
|
231
|
+
def context!(context)
|
|
232
|
+
return context unless context.respond_to?(:call)
|
|
233
|
+
|
|
234
|
+
raise GraphWeaver::Error, "context: is a proc, so it is answered from a request's " \
|
|
235
|
+
"headers — and nothing here made a request. Tag the example graphql: :wire, which " \
|
|
236
|
+
"serves your resolvers at your client's endpoint so your transport's headers reach " \
|
|
237
|
+
"them; off the wire, pass the hash."
|
|
238
|
+
end
|
|
239
|
+
|
|
107
240
|
private
|
|
108
241
|
|
|
109
|
-
#
|
|
110
|
-
|
|
242
|
+
# What makes a composed? answer stale. Both callers pass a path that
|
|
243
|
+
# exists, so the file's identity is its stat — size as well as mtime,
|
|
244
|
+
# because a coarse mtime can miss two writes in one tick. Anything
|
|
245
|
+
# that isn't a path (SDL, a class) is its own key.
|
|
246
|
+
def composed_key(source)
|
|
247
|
+
stat = File.stat(source.to_s)
|
|
248
|
+
[source.to_s, stat.mtime, stat.size]
|
|
249
|
+
rescue SystemCallError
|
|
250
|
+
source
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# The document's operation kind — "query", "mutation" or
|
|
254
|
+
# "subscription" — or nil when it holds no operation or won't parse.
|
|
255
|
+
# The one source of truth for the word a module name ends in AND for
|
|
256
|
+
# the file-name extension that word makes redundant.
|
|
257
|
+
def operation_kind(source)
|
|
111
258
|
operation = GraphQL.parse(source).definitions
|
|
112
259
|
.grep(GraphQL::Language::Nodes::OperationDefinition).first
|
|
113
|
-
(operation
|
|
260
|
+
operation && (operation.operation_type || "query") # `{ hello }` is shorthand for a query
|
|
114
261
|
rescue GraphQL::ParseError
|
|
115
|
-
|
|
262
|
+
nil # unparseable: codegen brands the real error a moment later
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# GraphQL's operation kinds, as a file name spells them. Apollo, Relay
|
|
266
|
+
# and GitLab's frontend all name a query file for its operation, so
|
|
267
|
+
# `blob_content.query.graphql` says in the extension exactly what the
|
|
268
|
+
# module's own suffix says — drop it rather than emit BlobContentQueryQuery.
|
|
269
|
+
# `_query` inside a snake_case name is a word OF the name, not this, so
|
|
270
|
+
# nothing that generates today is renamed.
|
|
271
|
+
OPERATION_EXTENSION = /\.(query|mutation|subscription)\z/i
|
|
272
|
+
private_constant :OPERATION_EXTENSION
|
|
273
|
+
|
|
274
|
+
def strip_kind_extension(base, kind, path)
|
|
275
|
+
declared = base[OPERATION_EXTENSION, 1]&.downcase
|
|
276
|
+
stem = declared && base[0...-(declared.length + 1)]
|
|
277
|
+
return base if stem.nil? || stem.empty?
|
|
278
|
+
return stem if kind.nil? || kind == declared
|
|
279
|
+
|
|
280
|
+
raise GraphWeaver::Error, "#{relative(path)}: the file name ends .#{declared}, but the " \
|
|
281
|
+
"document defines a #{kind} — rename it #{stem}.#{kind}#{File.extname(path)} " \
|
|
282
|
+
"or drop the .#{declared}"
|
|
116
283
|
end
|
|
117
284
|
end
|
|
118
285
|
end
|
|
@@ -142,7 +309,7 @@ module GraphWeaver
|
|
|
142
309
|
# JSON round-trip so symbol keys become strings — otherwise
|
|
143
310
|
# YAML.dump writes Ruby symbols the safe loader rejects on the next
|
|
144
311
|
# run, and lookup keys stay stable across processes
|
|
145
|
-
def normalize_variables(variables) = JSON.parse(
|
|
312
|
+
def normalize_variables(variables) = JSON.parse(Wire.json(variables || {}))
|
|
146
313
|
|
|
147
314
|
# one readable line: an error naming a 60-line query is a wall, not a hint
|
|
148
315
|
def summarize(query, limit: 160)
|
|
@@ -157,6 +324,54 @@ module GraphWeaver
|
|
|
157
324
|
# carries an error in. Lived on Transport and Router, both of which
|
|
158
325
|
# users touch — the worst place for it.
|
|
159
326
|
module Wire
|
|
327
|
+
# JSON for the wire, or the caller's bug named under the umbrella: a
|
|
328
|
+
# value with no JSON form (NaN, Infinity, binary) raised a raw JSON::
|
|
329
|
+
# error from wherever it was first encoded — the transport, a cassette
|
|
330
|
+
# key, a log line — so every encoder goes through here.
|
|
331
|
+
def self.json(value)
|
|
332
|
+
JSON.generate(value)
|
|
333
|
+
rescue JSON::GeneratorError => e
|
|
334
|
+
raise GraphWeaver::Error, "variables are not JSON-serializable: #{e.message}"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# The half of that discipline JSON doesn't raise for. JSON.generate
|
|
338
|
+
# carries a String, a number, a boolean, null, a list and an object;
|
|
339
|
+
# anything else it renders as the value's #to_s — right for a Date or
|
|
340
|
+
# a Symbol, a memory address for a File, which then sits in the
|
|
341
|
+
# server's database looking like it meant something. So a variable
|
|
342
|
+
# whose #to_s is Ruby's debug form, or that is a stream whose bytes
|
|
343
|
+
# JSON can't carry at all, is refused before the body is built.
|
|
344
|
+
def self.check_variables!(variables)
|
|
345
|
+
variables&.each { |name, value| check_variable!(name.to_s, value) }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def self.check_variable!(path, value)
|
|
349
|
+
case value
|
|
350
|
+
when Hash then value.each { |key, nested| check_variable!("#{path}.#{key}", nested) }
|
|
351
|
+
when Array then value.each_with_index { |nested, i| check_variable!("#{path}[#{i}]", nested) }
|
|
352
|
+
when String, Symbol, Numeric, true, false, nil then nil
|
|
353
|
+
else
|
|
354
|
+
raise GraphWeaver::Error, variable_refusal(path, value) if value.respond_to?(:read) ||
|
|
355
|
+
value.to_s.start_with?("#<")
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
private_class_method :check_variable!
|
|
359
|
+
|
|
360
|
+
# A stream and an anonymous object fail the same way and need different
|
|
361
|
+
# next steps: one is a feature this client doesn't have, the other is a
|
|
362
|
+
# value that never said what it is.
|
|
363
|
+
def self.variable_refusal(path, value)
|
|
364
|
+
if value.respond_to?(:read)
|
|
365
|
+
"$#{path} is a #{value.class} — graph_weaver posts application/json and doesn't implement " \
|
|
366
|
+
"the GraphQL multipart request spec, so a file can't ride along; send what the server " \
|
|
367
|
+
"expects as JSON, or POST the upload with your own transport"
|
|
368
|
+
else
|
|
369
|
+
"$#{path} is a #{value.class}, which has no JSON form — it would go on the wire as " \
|
|
370
|
+
"#{value.to_s.inspect}; send a String, a number, a boolean, a list, or an object"
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
private_class_method :variable_refusal
|
|
374
|
+
|
|
160
375
|
# The name of the document's FIRST operation, nil when anonymous. Only
|
|
161
376
|
# the fallback for a raw query string handed straight to a transport —
|
|
162
377
|
# generated modules pass their OPERATION_NAME, parsed properly.
|
|
@@ -168,7 +383,8 @@ module GraphWeaver
|
|
|
168
383
|
# every request, and the only way to be wrong (a field literally named
|
|
169
384
|
# `mutation` opening a line) errs toward not retrying.
|
|
170
385
|
MUTATION_PATTERN = /^[ \t]*mutation\b/
|
|
171
|
-
|
|
386
|
+
SUBSCRIPTION_PATTERN = /^[ \t]*subscription\b/
|
|
387
|
+
private_constant :MUTATION_PATTERN, :SUBSCRIPTION_PATTERN
|
|
172
388
|
|
|
173
389
|
REQUEST_MUTEX = Mutex.new
|
|
174
390
|
private_constant :REQUEST_MUTEX
|
|
@@ -183,16 +399,35 @@ module GraphWeaver
|
|
|
183
399
|
|
|
184
400
|
def mutation?(query) = MUTATION_PATTERN.match?(query)
|
|
185
401
|
|
|
402
|
+
# What this document runs, for the instrumentation payload — :query
|
|
403
|
+
# for the shorthand `{ ... }` document too, which is what it is.
|
|
404
|
+
# Built on mutation? rather than beside it, so an APM's write-failure
|
|
405
|
+
# rate and the decision not to retry can't come to disagree.
|
|
406
|
+
def kind(query)
|
|
407
|
+
return :mutation if mutation?(query)
|
|
408
|
+
|
|
409
|
+
SUBSCRIPTION_PATTERN.match?(query) ? :subscription : :query
|
|
410
|
+
end
|
|
411
|
+
|
|
186
412
|
# one error in the shape a GraphQL response carries them
|
|
187
413
|
def graphql_error(message, code)
|
|
188
414
|
{ "message" => message, "extensions" => { "code" => code } }
|
|
189
415
|
end
|
|
190
416
|
|
|
191
|
-
# "[req 3 FilteredPokemon]" —
|
|
192
|
-
# operation name
|
|
417
|
+
# "[req 4123-3 FilteredPokemon]" — the pid, this process's own
|
|
418
|
+
# request count, and the operation name when there is one.
|
|
419
|
+
#
|
|
420
|
+
# Both halves, because a Puma cluster forks: the counter is inherited
|
|
421
|
+
# with everything else, so without the reset every worker continues
|
|
422
|
+
# the master's sequence, and without the pid two workers' "[req 3]"
|
|
423
|
+
# are two unrelated requests in one aggregated log.
|
|
193
424
|
def log_tag(operation_name = nil)
|
|
194
|
-
|
|
195
|
-
|
|
425
|
+
pid = Process.pid
|
|
426
|
+
id = REQUEST_MUTEX.synchronize do
|
|
427
|
+
@request_pid, @request_count = pid, 0 unless @request_pid == pid
|
|
428
|
+
@request_count += 1
|
|
429
|
+
end
|
|
430
|
+
"[req #{pid}-#{id}#{" #{operation_name}" if operation_name}]"
|
|
196
431
|
end
|
|
197
432
|
|
|
198
433
|
def truncate_for_log(query)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# typed: ignore — ActiveSupport::LogSubscriber, which sorbet can't resolve here
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# The superclass, so this file stands alone: requiring it by hand is the
|
|
5
|
+
# documented way to subscribe outside Rails, and there is no railtie out there
|
|
6
|
+
# to have loaded ActiveSupport first. Skipped when the constant already
|
|
7
|
+
# exists, which is how a stand-in can take its place.
|
|
8
|
+
require "active_support/log_subscriber" unless defined?(ActiveSupport::LogSubscriber)
|
|
9
|
+
|
|
10
|
+
module GraphWeaver
|
|
11
|
+
# One line per GraphQL operation in a Rails log, the shape ActiveRecord
|
|
12
|
+
# uses for a query:
|
|
13
|
+
#
|
|
14
|
+
# GraphWeaver PersonQuery (12.3ms) ok
|
|
15
|
+
# GraphWeaver PersonQuery (8.1ms) errors [THROTTLED]
|
|
16
|
+
# GraphWeaver PersonQuery (31.2ms) failed GraphWeaver::TransportError
|
|
17
|
+
# GraphWeaver billing/InvoicesQuery (12.3ms) ok
|
|
18
|
+
#
|
|
19
|
+
# Attached by the railtie wherever ActiveSupport is, and fed by the
|
|
20
|
+
# instrumenter it sets. Requires ActiveSupport — `require` this yourself
|
|
21
|
+
# only if you subscribe by hand.
|
|
22
|
+
#
|
|
23
|
+
# **One rule decides which line you get: the summary is info, the wire is
|
|
24
|
+
# debug.** This is the only GraphWeaver line at info, so a production log
|
|
25
|
+
# gets one per operation and nothing that could carry PII; turning
|
|
26
|
+
# GraphWeaver.logger up to debug adds the query, the variables and the
|
|
27
|
+
# response *beneath* it rather than repeating it.
|
|
28
|
+
#
|
|
29
|
+
# It writes through GraphWeaver.logger rather than Rails.logger, so
|
|
30
|
+
# `GraphWeaver.logger = nil` — the documented way to silence the gem —
|
|
31
|
+
# silences this too, and the line carries the same `graph_weaver`
|
|
32
|
+
# progname as every other one.
|
|
33
|
+
class LogSubscriber < ActiveSupport::LogSubscriber
|
|
34
|
+
# attach_to(:graph_weaver) subscribes "#{method}.graph_weaver" and
|
|
35
|
+
# ActiveSupport::Subscriber#call dispatches on the name up to the first
|
|
36
|
+
# dot — so this method name is EXECUTE_EVENT's first half, both ways.
|
|
37
|
+
def execute(event)
|
|
38
|
+
payload = event.payload
|
|
39
|
+
|
|
40
|
+
GraphWeaver::Internal::Log.log(:info) do
|
|
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
|
|
47
|
+
|
|
48
|
+
# GraphWeaver's logger, not Rails' — LogSubscriber#call skips a
|
|
49
|
+
# subscriber whose logger is nil, which is what makes the gem's own
|
|
50
|
+
# opt-out reach this line too.
|
|
51
|
+
def logger = GraphWeaver.logger
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# What ran: the operation, prefixed by its graph when the request carried
|
|
56
|
+
# one — an app with several graphs reads `billing/InvoicesQuery` without
|
|
57
|
+
# a second line shape to learn, and one with a single graph never sees it.
|
|
58
|
+
def subject(payload)
|
|
59
|
+
operation = payload[:operation] || "query"
|
|
60
|
+
payload[:graph] ? "#{payload[:graph]}/#{operation}" : operation
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# status, then whatever narrows it: the error class, the reason an alert
|
|
64
|
+
# groups by, and which attempt this was when a Retry is in the stack.
|
|
65
|
+
def outcome(payload)
|
|
66
|
+
parts = [payload[:status], payload[:error]]
|
|
67
|
+
# the GraphQL code, or the HTTP status where the request never got one
|
|
68
|
+
reason = payload[:code] || (payload[:http_status] if payload[:status] == :failed)
|
|
69
|
+
parts << "[#{reason}]" if reason
|
|
70
|
+
parts << "(retry #{payload[:retries]})" if payload[:retries].to_i.positive?
|
|
71
|
+
parts.compact.join(" ")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/graph_weaver/logging.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# typed: true
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
4
6
|
module GraphWeaver
|
|
5
7
|
class << self
|
|
6
8
|
# Where GraphWeaver narrates what it's doing — anything
|
|
@@ -43,18 +45,20 @@ module GraphWeaver
|
|
|
43
45
|
|
|
44
46
|
# One callable wrapping every request GraphWeaver makes — over the
|
|
45
47
|
# wire or in-process — so an APM can time it and count errors. A
|
|
46
|
-
# no-op until you set one:
|
|
48
|
+
# no-op until you set one (Rails sets this one for you):
|
|
47
49
|
#
|
|
48
50
|
# GraphWeaver.instrumenter = lambda do |event, payload, &block|
|
|
49
51
|
# ActiveSupport::Notifications.instrument(event, payload, &block)
|
|
50
52
|
# end
|
|
51
53
|
#
|
|
52
|
-
# It must call the block and return its value. The only event today
|
|
53
|
-
#
|
|
54
|
-
# :
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
54
|
+
# It must call the block and return its value. The only event today is
|
|
55
|
+
# EXECUTE_EVENT; its payload is the contract in docs/logging.md —
|
|
56
|
+
# :operation, :client, :kind, :status, :duration_ms, :graph always;
|
|
57
|
+
# :url/:http_status over the wire, :schema in-process, :error/:code on a
|
|
58
|
+
# failure, :retries when a Retry wrapped it. Never the query text or the
|
|
59
|
+
# variables: the payload fans out to subscribers that know none of the
|
|
60
|
+
# filtering rules, so PII belongs at debug on the logger, where the
|
|
61
|
+
# level gates it and filter_parameters scrubs it.
|
|
58
62
|
attr_accessor :instrumenter
|
|
59
63
|
end
|
|
60
64
|
|
|
@@ -73,10 +77,49 @@ module GraphWeaver
|
|
|
73
77
|
!key.nil? && Log.filter_variables({ key.to_s => nil })[key.to_s] == FILTERED
|
|
74
78
|
end
|
|
75
79
|
|
|
80
|
+
# True when this key names a credential — asked of a url's query
|
|
81
|
+
# parameters, which are scrubbed whatever the app's logging appetite.
|
|
82
|
+
# filter_parameters is a knob about log verbosity; emptying it must
|
|
83
|
+
# not un-scrub a token in an endpoint, any more than it un-scrubs the
|
|
84
|
+
# url's userinfo. The app's list widens this; it can't narrow it.
|
|
85
|
+
def credential?(key)
|
|
86
|
+
filtered?(key) || Log.filtered?(key, GraphWeaver::DEFAULT_FILTER_PARAMETERS)
|
|
87
|
+
end
|
|
88
|
+
|
|
76
89
|
# `detail` unless the key is filtered — free text a coercer or sorbet
|
|
77
90
|
# wrote can spell a value any way, so for a filtered key none of it
|
|
78
91
|
# survives, not the parts that would have been safe.
|
|
79
92
|
def detail(key, detail) = filtered?(key) ? FILTERED : detail
|
|
93
|
+
|
|
94
|
+
# A value the library reports as DATA rather than inside a sentence —
|
|
95
|
+
# InputError#value. Scrubbed at every depth, so a filtered key nested
|
|
96
|
+
# inside an input object is covered too, and the same list decides it
|
|
97
|
+
# as decides the debug log's variables line.
|
|
98
|
+
def value(key, value) = Log.filter_variables({ key.to_s => value })[key.to_s]
|
|
99
|
+
|
|
100
|
+
# A value the library spells INTO a sentence. `detail` can only ask
|
|
101
|
+
# about the key the value arrived under, so it reads a filtered key one
|
|
102
|
+
# level in as safe; this scrubs at every depth, like #value. The key is
|
|
103
|
+
# 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).inspect)
|
|
105
|
+
|
|
106
|
+
# A short server-chosen string the library republishes inside its own
|
|
107
|
+
# text — the APM's :code, the [CODE] in the one line info writes, a
|
|
108
|
+
# redirect's destination. Control characters are stripped because it
|
|
109
|
+
# lands where the log's own framing lives: a newline in
|
|
110
|
+
# extensions.code forges a second, complete-looking line.
|
|
111
|
+
def tag(value) = value.is_a?(String) ? cap(value.gsub(/[[:cntrl:]]+/, " ")) : value
|
|
112
|
+
|
|
113
|
+
# Text the library didn't author — a value a caller sent, a sentence a
|
|
114
|
+
# server wrote — cut to what an error may carry. The number lives on
|
|
115
|
+
# InputError, which is the class that documents it and the one every
|
|
116
|
+
# capped string reaches.
|
|
117
|
+
def cap(text, limit = GraphWeaver::InputError::VALUE_LIMIT)
|
|
118
|
+
return text if text.bytesize <= limit
|
|
119
|
+
|
|
120
|
+
# byteslice can land mid-character; scrub drops the partial tail
|
|
121
|
+
"#{text.byteslice(0, limit).scrub("")}…(#{text.bytesize - limit} more bytes)"
|
|
122
|
+
end
|
|
80
123
|
end
|
|
81
124
|
end
|
|
82
125
|
end
|
|
@@ -91,14 +134,23 @@ module GraphWeaver
|
|
|
91
134
|
self.filter_parameters = DEFAULT_FILTER_PARAMETERS
|
|
92
135
|
|
|
93
136
|
# The one instrumentation event: a single GraphQL request, start to
|
|
94
|
-
# parsed response, whichever client slot served it.
|
|
95
|
-
|
|
137
|
+
# parsed response, whichever client slot served it. `<event>.<namespace>`
|
|
138
|
+
# is how every notification in this ecosystem is spelled
|
|
139
|
+
# (sql.active_record, execute_multiplex.graphql) — it's what
|
|
140
|
+
# ActiveSupport::LogSubscriber.attach_to and an APM's namespace routing
|
|
141
|
+
# key on, so a backwards name made both of them a puzzle.
|
|
142
|
+
EXECUTE_EVENT = "execute.graph_weaver"
|
|
96
143
|
|
|
97
144
|
module Internal
|
|
98
145
|
# The emitting half of the narration the three accessors above
|
|
99
146
|
# configure. Setting a logger is API; writing to it is not, and the
|
|
100
147
|
# two read as a pair when they sit on the same object.
|
|
101
148
|
module Log
|
|
149
|
+
# fiber-local, set only for the duration of one attempt (with_retries)
|
|
150
|
+
RETRIES = :graph_weaver_retries
|
|
151
|
+
# fiber-local, set only for the duration of one dispatch (with_graph)
|
|
152
|
+
GRAPH = :graph_weaver_graph
|
|
153
|
+
|
|
102
154
|
class << self
|
|
103
155
|
# Level-gated and lazy — the block only runs when a logger is
|
|
104
156
|
# listening. Messages carry "graph_weaver" as progname.
|
|
@@ -118,13 +170,91 @@ module GraphWeaver
|
|
|
118
170
|
result
|
|
119
171
|
end
|
|
120
172
|
|
|
121
|
-
# Wrap the block in the instrumenter, if one is set. The
|
|
122
|
-
#
|
|
173
|
+
# Wrap the block in the instrumenter, if one is set. The caller
|
|
174
|
+
# supplies what only it knows (:url, :schema, :client); this fills
|
|
175
|
+
# in the half every path shares — how it ended, how long it took,
|
|
176
|
+
# what a Retry had already spent — so one subscriber reads one
|
|
177
|
+
# shape whichever client slot served the request.
|
|
123
178
|
def instrument(event, payload)
|
|
124
179
|
hook = GraphWeaver.instrumenter
|
|
125
180
|
return yield unless hook
|
|
126
181
|
|
|
127
|
-
|
|
182
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
183
|
+
retries = Thread.current[RETRIES]
|
|
184
|
+
payload[:retries] = retries if retries
|
|
185
|
+
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
|
+
|
|
190
|
+
# One dispatch labels one request. Whatever THIS request reaches —
|
|
191
|
+
# a resolver serving it that calls out — is a request of its own,
|
|
192
|
+
# and the caller's graph would be a wrong label on it.
|
|
193
|
+
with_graph(nil) do
|
|
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
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# What a Retry has already spent, read by the attempt it is about
|
|
218
|
+
# to make. A dynamic extent rather than a global: the count is only
|
|
219
|
+
# visible while the call it describes is on the stack, so a client
|
|
220
|
+
# that never reaches instrument can't leave a stale one behind.
|
|
221
|
+
def with_retries(count)
|
|
222
|
+
return yield unless GraphWeaver.instrumenter
|
|
223
|
+
|
|
224
|
+
previous = Thread.current[RETRIES]
|
|
225
|
+
Thread.current[RETRIES] = count
|
|
226
|
+
begin
|
|
227
|
+
yield
|
|
228
|
+
ensure
|
|
229
|
+
Thread.current[RETRIES] = previous
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The graph a generated module is dispatching, read by the request it
|
|
234
|
+
# is about to make. Same dynamic extent as with_retries, for the same
|
|
235
|
+
# reason — and instrument clears it for the duration of the request it
|
|
236
|
+
# labels, so exactly one request wears the label.
|
|
237
|
+
def with_graph(name)
|
|
238
|
+
return yield unless GraphWeaver.instrumenter
|
|
239
|
+
|
|
240
|
+
previous = Thread.current[GRAPH]
|
|
241
|
+
Thread.current[GRAPH] = name
|
|
242
|
+
begin
|
|
243
|
+
yield
|
|
244
|
+
ensure
|
|
245
|
+
Thread.current[GRAPH] = previous
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# The variables as one JSON line for a log: filtered, and unable to
|
|
250
|
+
# raise. A value with no JSON form (NaN, binary) is the caller's bug
|
|
251
|
+
# and the transport refuses it a few lines later — but a logger that
|
|
252
|
+
# decides WHICH exception a caller sees, or whether one is raised at
|
|
253
|
+
# all, is worse than a log line that says it couldn't render.
|
|
254
|
+
def variables_for_log(variables)
|
|
255
|
+
JSON.generate(filter_variables(variables))
|
|
256
|
+
rescue StandardError => e
|
|
257
|
+
"<unloggable: #{e.class}>"
|
|
128
258
|
end
|
|
129
259
|
|
|
130
260
|
# variables with the filtered keys blanked out
|
|
@@ -136,8 +266,29 @@ module GraphWeaver
|
|
|
136
266
|
filters.nil? ? variables : filters.filter(variables)
|
|
137
267
|
end
|
|
138
268
|
|
|
269
|
+
# Whether a key matches one of `filters` — the same matching the
|
|
270
|
+
# variables line uses, asked about a list other than the app's so
|
|
271
|
+
# Redact.credential? can hold url parameters to the default names.
|
|
272
|
+
def filtered?(key, filters)
|
|
273
|
+
name = key.to_s
|
|
274
|
+
filters.any? do |filter|
|
|
275
|
+
filter.is_a?(Regexp) ? name.match?(filter) : name.downcase.include?(filter.to_s.downcase)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
139
279
|
private
|
|
140
280
|
|
|
281
|
+
# The GraphQL errors a response carries, whatever answered it — a
|
|
282
|
+
# Hash from a transport, a graphql-ruby Result in-process, a fake.
|
|
283
|
+
# Never raises: an instrumenter that decides which exception a
|
|
284
|
+
# caller sees is worse than a missing tag.
|
|
285
|
+
def response_errors(result)
|
|
286
|
+
errors = result.to_h["errors"] if result.respond_to?(:to_h)
|
|
287
|
+
errors.is_a?(Array) ? errors : []
|
|
288
|
+
rescue StandardError
|
|
289
|
+
[]
|
|
290
|
+
end
|
|
291
|
+
|
|
141
292
|
def scrub(value, filters)
|
|
142
293
|
case value
|
|
143
294
|
when Hash then value.to_h { |k, v| [k, filtered?(k, filters) ? FILTERED : scrub(v, filters)] }
|
|
@@ -145,13 +296,6 @@ module GraphWeaver
|
|
|
145
296
|
else value
|
|
146
297
|
end
|
|
147
298
|
end
|
|
148
|
-
|
|
149
|
-
def filtered?(key, filters)
|
|
150
|
-
name = key.to_s
|
|
151
|
-
filters.any? do |filter|
|
|
152
|
-
filter.is_a?(Regexp) ? name.match?(filter) : name.downcase.include?(filter.to_s.downcase)
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
299
|
end
|
|
156
300
|
end
|
|
157
301
|
end
|