graph_weaver 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +4 -4
- data/README.md +40 -88
- data/docs/alternatives.md +1 -7
- data/docs/cassettes.md +54 -59
- data/docs/editors.md +32 -47
- data/docs/errors.md +261 -369
- data/docs/federation.md +650 -837
- data/docs/generated_modules.md +380 -463
- data/docs/getting_started.md +211 -428
- data/docs/i18n.md +114 -177
- data/docs/logging.md +127 -116
- data/docs/real_world.md +26 -39
- data/docs/scalars.md +277 -310
- data/docs/testing.md +343 -486
- data/docs/transports.md +203 -268
- data/docs/upgrading.md +211 -560
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +12 -3
- data/lib/graph_weaver/client.rb +30 -1
- data/lib/graph_weaver/codegen/emit.rb +5 -11
- data/lib/graph_weaver/codegen.rb +23 -55
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +23 -15
- data/lib/graph_weaver/federation.rb +11 -2
- data/lib/graph_weaver/graph.rb +39 -29
- data/lib/graph_weaver/in_process.rb +15 -9
- data/lib/graph_weaver/internal/endpoint.rb +7 -5
- data/lib/graph_weaver/internal/headers.rb +19 -0
- data/lib/graph_weaver/internal/test_clients.rb +7 -11
- data/lib/graph_weaver/internal.rb +81 -13
- data/lib/graph_weaver/log_subscriber.rb +10 -2
- data/lib/graph_weaver/logging.rb +33 -13
- data/lib/graph_weaver/query_module.rb +44 -23
- data/lib/graph_weaver/retry.rb +12 -8
- data/lib/graph_weaver/rspec.rb +13 -24
- data/lib/graph_weaver/schema_loader.rb +52 -14
- data/lib/graph_weaver/tasks.rb +10 -2
- data/lib/graph_weaver/testing/cassette.rb +28 -5
- data/lib/graph_weaver/testing/endpoint.rb +14 -13
- data/lib/graph_weaver/testing/fake_client.rb +33 -3
- data/lib/graph_weaver/testing/router.rb +7 -3
- data/lib/graph_weaver/testing.rb +12 -4
- data/lib/graph_weaver/transport/http.rb +2 -2
- data/lib/graph_weaver/transport.rb +47 -23
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +32 -10
- metadata +16 -3
- data/CHANGELOG.md +0 -3801
data/lib/graph_weaver/graph.rb
CHANGED
|
@@ -65,7 +65,24 @@ module GraphWeaver
|
|
|
65
65
|
|
|
66
66
|
def queries = @queries || GraphWeaver.queries_paths
|
|
67
67
|
def output = @output || GraphWeaver.generated_paths.first
|
|
68
|
-
|
|
68
|
+
|
|
69
|
+
# The client this graph's modules call: the object `client` named, or the
|
|
70
|
+
# constant its name spells. nil when the graph names none — its modules
|
|
71
|
+
# go to GraphWeaver.client, like every other module.
|
|
72
|
+
#
|
|
73
|
+
# Resolved here, at call time, rather than spelled into generated source:
|
|
74
|
+
# renaming the constant is then an initializer edit and not a regeneration
|
|
75
|
+
# of every module, and a graph can name a live object.
|
|
76
|
+
def client
|
|
77
|
+
return @client unless @client.is_a?(String)
|
|
78
|
+
|
|
79
|
+
Object.const_get(@client)
|
|
80
|
+
rescue NameError
|
|
81
|
+
raise GraphWeaver::Error, "the client#{described} names #{@client.inspect} and nothing " \
|
|
82
|
+
"defines that constant, so its modules have no server to reach. Define it where the graph " \
|
|
83
|
+
"block can see it (config/initializers), or name the object itself: client " \
|
|
84
|
+
"GraphWeaver.new(\"https://api.example.com/graphql\")"
|
|
85
|
+
end
|
|
69
86
|
|
|
70
87
|
# Every constant this graph generates lives under `namespace:` — the query
|
|
71
88
|
# modules and the shared types module alike. Two schemas that each have a
|
|
@@ -95,7 +112,7 @@ module GraphWeaver
|
|
|
95
112
|
|
|
96
113
|
# The dump this graph's schema was named by, when it was named by a file —
|
|
97
114
|
# what a validation error's subgraph branding is read off. nil for a live
|
|
98
|
-
# class,
|
|
115
|
+
# class, inline SDL, or a Client built from any of those.
|
|
99
116
|
def dump_path
|
|
100
117
|
path = named_dump_path
|
|
101
118
|
path if path && File.exist?(path)
|
|
@@ -109,6 +126,9 @@ module GraphWeaver
|
|
|
109
126
|
return GraphWeaver::SchemaLoader.locate_path unless @schema
|
|
110
127
|
|
|
111
128
|
source = named_source
|
|
129
|
+
# a client built from a dump names that dump: the file is where a
|
|
130
|
+
# supergraph's routing table is, and the loaded schema is not
|
|
131
|
+
source = source.schema_source if source.respond_to?(:schema_source) && source.schema_source
|
|
112
132
|
path = source.respond_to?(:to_path) ? source.to_path : source
|
|
113
133
|
path if path.is_a?(String) && GraphWeaver::SchemaLoader.dump_path?(path)
|
|
114
134
|
end
|
|
@@ -133,26 +153,16 @@ module GraphWeaver
|
|
|
133
153
|
url || live_schema
|
|
134
154
|
end
|
|
135
155
|
|
|
136
|
-
# The url this graph's modules post to, or nil
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
156
|
+
# The url this graph's modules post to, or nil — the graph's own client,
|
|
157
|
+
# else the app default, which is where its modules go too. What
|
|
158
|
+
# `schema:refresh` bootstraps a missing dump from, and what `rake
|
|
159
|
+
# graph_weaver:graphs` reports; a client with no url (a schema class
|
|
160
|
+
# running in-process) has none to report.
|
|
140
161
|
def client_url
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
target = (client.transport if client.respond_to?(:transport)) || client
|
|
162
|
+
target = client || GraphWeaver.client
|
|
163
|
+
target = (target.transport if target.respond_to?(:transport)) || target
|
|
144
164
|
target.url if target.respond_to?(:url)
|
|
145
165
|
end
|
|
146
|
-
private :client_url
|
|
147
|
-
|
|
148
|
-
def resolve_client!
|
|
149
|
-
Object.const_get(@client)
|
|
150
|
-
rescue NameError
|
|
151
|
-
raise GraphWeaver::Error, "graph #{name.inspect} bakes client #{@client.inspect} into its " \
|
|
152
|
-
"modules and nothing defines that constant, so there is no endpoint to introspect " \
|
|
153
|
-
"#{named_dump_path} from"
|
|
154
|
-
end
|
|
155
|
-
private :resolve_client!
|
|
156
166
|
|
|
157
167
|
# The composed supergraph this graph plans against, or nil — the dump it
|
|
158
168
|
# names (for the default graph, the conventional one) when that dump
|
|
@@ -222,13 +232,12 @@ module GraphWeaver
|
|
|
222
232
|
# The same three calls an app already writes at the top level, scoped here
|
|
223
233
|
# to this graph alone.
|
|
224
234
|
REGISTRATIONS = %i[register_scalar register_enum extend_type].freeze
|
|
225
|
-
# These
|
|
226
|
-
# constant or its name and stores the name
|
|
227
|
-
|
|
228
|
-
#
|
|
229
|
-
# reference, which is why a root anchor is refused on them below.
|
|
235
|
+
# These two are spelled in generated source, as a `module` DEFINITION,
|
|
236
|
+
# so each takes the constant or its name and stores the name — and a
|
|
237
|
+
# root anchor is refused on them below. `client` isn't spelled anywhere:
|
|
238
|
+
# the graph resolves it at call time, so it takes the object.
|
|
230
239
|
MODULE_SETTINGS = %i[namespace types_module].freeze
|
|
231
|
-
private_constant :
|
|
240
|
+
private_constant :MODULE_SETTINGS
|
|
232
241
|
|
|
233
242
|
attr_reader :settings, :registrations
|
|
234
243
|
|
|
@@ -283,17 +292,18 @@ module GraphWeaver
|
|
|
283
292
|
def respond_to_missing?(name, _private = false) = false
|
|
284
293
|
|
|
285
294
|
# A Module where a constant's name goes says the same thing, and is what
|
|
286
|
-
# `
|
|
287
|
-
#
|
|
295
|
+
# `namespace Billing` reads like. Anything else passes through: a schema
|
|
296
|
+
# is a path, SDL, a class, a Client, or a callable, and a client is
|
|
297
|
+
# whatever object answers #execute.
|
|
288
298
|
# On the singleton so the define_method setters above can reach it — srb
|
|
289
299
|
# reads a define_method block's self as the class.
|
|
290
300
|
def self.constant_name(setting, value)
|
|
291
|
-
return value unless
|
|
301
|
+
return value unless MODULE_SETTINGS.include?(setting)
|
|
292
302
|
|
|
293
303
|
# Generated modules are defined at the top level, where a root anchor
|
|
294
304
|
# says nothing — and `module ::A::B` is not a name const_get can spell,
|
|
295
305
|
# so it used to surface as a verdict on the .graphql file's name.
|
|
296
|
-
if
|
|
306
|
+
if value.is_a?(String) && value.start_with?("::")
|
|
297
307
|
raise ArgumentError, "#{setting} #{value.inspect}: drop the leading `::` — #{setting} " \
|
|
298
308
|
"names a module generated source defines, and it defines it at the top level either way"
|
|
299
309
|
end
|
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
|
|
6
6
|
require_relative "errors"
|
|
7
7
|
require_relative "internal"
|
|
8
|
+
require_relative "context_seam"
|
|
8
9
|
require_relative "parsing"
|
|
9
10
|
require_relative "transport"
|
|
10
11
|
|
|
@@ -32,13 +33,13 @@ require_relative "transport"
|
|
|
32
33
|
# is usually the whole reason you're running in-process.
|
|
33
34
|
class GraphWeaver::InProcess
|
|
34
35
|
include GraphWeaver::Parsing
|
|
36
|
+
# #context/#context= plus the lock over them: Testing::Endpoint answers a
|
|
37
|
+
# `context:` proc from one request's headers by writing this field, so the
|
|
38
|
+
# field's owner owns the lock
|
|
39
|
+
include GraphWeaver::ContextSeam
|
|
35
40
|
|
|
36
|
-
# the schema queries run against
|
|
37
|
-
attr_reader :schema
|
|
38
|
-
|
|
39
|
-
# settable so Testing::Endpoint can answer a `context:` proc from the
|
|
40
|
-
# request's headers and put it back — the same seam Router#context= is
|
|
41
|
-
attr_writer :context
|
|
41
|
+
# the schema queries run against
|
|
42
|
+
attr_reader :schema
|
|
42
43
|
|
|
43
44
|
def initialize(schema, context: {})
|
|
44
45
|
unless schema.respond_to?(:execute)
|
|
@@ -46,12 +47,13 @@ class GraphWeaver::InProcess
|
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
@schema = schema
|
|
49
|
-
|
|
50
|
+
init_context_seam(context)
|
|
50
51
|
end
|
|
51
52
|
|
|
52
53
|
def execute(query, variables: {}, operation_name: nil)
|
|
53
54
|
operation_name ||= GraphWeaver::Internal::Wire.operation_name(query)
|
|
54
|
-
payload = { url: nil, schema: schema_label, operation: operation_name, client: self.class
|
|
55
|
+
payload = { url: nil, schema: schema_label, operation: operation_name, client: self.class,
|
|
56
|
+
kind: GraphWeaver::Internal::Wire.kind(query) }
|
|
55
57
|
|
|
56
58
|
GraphWeaver::Internal::Log.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
|
|
57
59
|
perform(query, variables, operation_name)
|
|
@@ -84,7 +86,11 @@ class GraphWeaver::InProcess
|
|
|
84
86
|
# a resolver blew up. The same failure over HTTP arrives as a 500, so
|
|
85
87
|
# raise what HTTP would — code that rescues GraphWeaver::Error, or
|
|
86
88
|
# branches on ServerError#status, behaves the same either side.
|
|
87
|
-
|
|
89
|
+
# detail:, not body: — there was no response, so there are no bytes to
|
|
90
|
+
# hold, and the diagnosis is this process's own exception
|
|
91
|
+
raise GraphWeaver::ServerError.new(
|
|
92
|
+
status: 500, detail: "#{e.class}: #{GraphWeaver::Internal::Redact.cap(e.message)}",
|
|
93
|
+
)
|
|
88
94
|
end
|
|
89
95
|
|
|
90
96
|
# never leak the context (session tokens, current_user) through logs or
|
|
@@ -54,20 +54,22 @@ module GraphWeaver
|
|
|
54
54
|
def drop_secrets(query)
|
|
55
55
|
kept = query.split("&").reject do |pair|
|
|
56
56
|
name, value = pair.split("=", 2)
|
|
57
|
-
value && Redact.
|
|
57
|
+
value && Redact.credential?(URI.decode_www_form_component(name))
|
|
58
58
|
end
|
|
59
59
|
kept.join("&") unless kept.empty?
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
# Which query parameters are secret is the same question
|
|
63
63
|
# GraphWeaver.filter_parameters already answers for variables, so a
|
|
64
|
-
# scrubbed log reads the same either side of the seam
|
|
65
|
-
#
|
|
66
|
-
#
|
|
64
|
+
# scrubbed log reads the same either side of the seam — widened by the
|
|
65
|
+
# default names, which apply here even when the app has emptied its
|
|
66
|
+
# list (see Redact.credential?). Split rather than decoded and
|
|
67
|
+
# re-encoded: every parameter that stays is printed exactly as it was
|
|
68
|
+
# sent.
|
|
67
69
|
def scrub_query(query)
|
|
68
70
|
query.split("&").map do |pair|
|
|
69
71
|
name, value = pair.split("=", 2)
|
|
70
|
-
next pair if value.nil? || !Redact.
|
|
72
|
+
next pair if value.nil? || !Redact.credential?(URI.decode_www_form_component(name))
|
|
71
73
|
|
|
72
74
|
"#{name}=#{GraphWeaver::FILTERED}"
|
|
73
75
|
end.join("&")
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# typed: true
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "time" # Time.httpdate, for Retry-After
|
|
5
|
+
|
|
4
6
|
module GraphWeaver
|
|
5
7
|
module Internal
|
|
6
8
|
# Response headers, as ServerError carries them. HTTP field names are
|
|
@@ -42,6 +44,23 @@ module GraphWeaver
|
|
|
42
44
|
rest.empty? ? value : value&.dig(*rest)
|
|
43
45
|
end
|
|
44
46
|
|
|
47
|
+
# Seconds to wait per the server's Retry-After, which is either a delay
|
|
48
|
+
# in seconds or an HTTP-date. nil when absent or unparseable; a date
|
|
49
|
+
# already past clamps to 0. See RFC 9110 §10.2.3.
|
|
50
|
+
#
|
|
51
|
+
# Here rather than on ServerError because a rate limit reaches a caller
|
|
52
|
+
# two ways — raised, and returned as the envelope a 4xx/5xx WITH a
|
|
53
|
+
# GraphQL errors body makes — and both have to read the one rule.
|
|
54
|
+
def retry_after
|
|
55
|
+
value = self["retry-after"]&.strip
|
|
56
|
+
return if value.nil? || value.empty?
|
|
57
|
+
return value.to_f if value.match?(/\A\d+(\.\d+)?\z/)
|
|
58
|
+
|
|
59
|
+
[Time.httpdate(value) - Time.now, 0.0].max
|
|
60
|
+
rescue ArgumentError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
45
64
|
def key?(name) = super(Headers.fold(name))
|
|
46
65
|
alias_method :has_key?, :key?
|
|
47
66
|
alias_method :include?, :key?
|
|
@@ -8,13 +8,12 @@ module GraphWeaver
|
|
|
8
8
|
# a `graphql_*` helper writes to.
|
|
9
9
|
#
|
|
10
10
|
# A tag used to work by swapping GraphWeaver.client, which is the LAST
|
|
11
|
-
# place a module looks: one
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# example runs, bound or not.
|
|
11
|
+
# place a module looks: one whose graph names a client of its own reads
|
|
12
|
+
# that first and never got there, so the tag quietly didn't apply. The
|
|
13
|
+
# mode installs itself here instead, and QueryModule asks before it reads
|
|
14
|
+
# the graph — so a tag reaches every module the example runs.
|
|
16
15
|
#
|
|
17
|
-
# Keyed by the graph a module was generated from (its
|
|
16
|
+
# Keyed by the graph a module was generated from (its GRAPH), since
|
|
18
17
|
# the honest answer varies: :fake for a billing module has to fabricate
|
|
19
18
|
# billing's shapes, not the other schema's. A helper names its graphs the
|
|
20
19
|
# same way and lands in the same table, so what an example says applies to
|
|
@@ -254,11 +253,8 @@ module GraphWeaver
|
|
|
254
253
|
# generated before its graph was declared, or by an older release —
|
|
255
254
|
# and guessing would fake one schema's shapes at another's module.
|
|
256
255
|
def graph_for!(mod)
|
|
257
|
-
graphs = GraphWeaver.graphs
|
|
258
|
-
return graphs.first if graphs.one?
|
|
259
|
-
|
|
260
256
|
name = mod.const_defined?(:GRAPH, false) ? mod.const_get(:GRAPH) : nil
|
|
261
|
-
found =
|
|
257
|
+
found = Util.graph_named(name)
|
|
262
258
|
return found if found
|
|
263
259
|
|
|
264
260
|
# Two doors produce a module, so the fix has two spellings: a file
|
|
@@ -268,7 +264,7 @@ module GraphWeaver
|
|
|
268
264
|
raise GraphWeaver::Error, "#{mod} doesn't say which of this app's graphs " \
|
|
269
265
|
"(#{declared_names}) it was generated from, so #{@mode.inspect} has nothing to run " \
|
|
270
266
|
"it against — regenerate it (rake graph_weaver:generate), or, if it came from " \
|
|
271
|
-
"GraphWeaver.parse, say which there (graph: #{graphs.first.name.inspect})."
|
|
267
|
+
"GraphWeaver.parse, say which there (graph: #{GraphWeaver.graphs.first.name.inspect})."
|
|
272
268
|
end
|
|
273
269
|
end
|
|
274
270
|
end
|
|
@@ -52,19 +52,29 @@ module GraphWeaver
|
|
|
52
52
|
|
|
53
53
|
# The module a .graphql file generates, and the basename of the file
|
|
54
54
|
# it generates into: the camelized file name plus the operation's own
|
|
55
|
-
# 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.
|
|
56
58
|
#
|
|
57
59
|
# person.graphql => PersonQuery (person_query.rb)
|
|
58
60
|
# save_list_entry.graphql => SaveListEntryMutation
|
|
59
61
|
# (save_list_entry_mutation.rb)
|
|
62
|
+
# get-hello.graphql => GetHelloQuery (get_hello_query.rb)
|
|
63
|
+
# hello.query.graphql => HelloQuery (hello_query.rb)
|
|
60
64
|
#
|
|
61
65
|
# Every naming site goes through here — generate!, parse(path), and
|
|
62
66
|
# load_queries! — so the constant a file produces is the same one
|
|
63
67
|
# whichever door you came in by, and the file it lands in matches it.
|
|
64
68
|
def generated_names(path, source)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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"]
|
|
68
78
|
end
|
|
69
79
|
|
|
70
80
|
# just the module name — see generated_names
|
|
@@ -131,6 +141,21 @@ module GraphWeaver
|
|
|
131
141
|
schema && GraphWeaver.graphs.find { |candidate| candidate.live_schema.equal?(schema) }
|
|
132
142
|
end
|
|
133
143
|
|
|
144
|
+
# The declared graph `name` names, or nil — how a generated module
|
|
145
|
+
# finds the graph whose client it runs against, and whose schema a
|
|
146
|
+
# test mode fabricates from.
|
|
147
|
+
#
|
|
148
|
+
# One graph in an app is the answer whatever a module calls it: a
|
|
149
|
+
# module generated before its graph was named, or by an older release,
|
|
150
|
+
# still belongs to the only graph there is. With several, guessing
|
|
151
|
+
# would send one schema's query to another's endpoint.
|
|
152
|
+
def graph_named(name)
|
|
153
|
+
graphs = GraphWeaver.graphs
|
|
154
|
+
return graphs.first if graphs.one?
|
|
155
|
+
|
|
156
|
+
graphs.find { |graph| graph.name == name }
|
|
157
|
+
end
|
|
158
|
+
|
|
134
159
|
# Where generated modules are READ from: the configured patterns, plus
|
|
135
160
|
# any graph writing somewhere they don't already cover. generated_paths'
|
|
136
161
|
# default glob (app/graphql/*/generated) covers the conventional layout,
|
|
@@ -240,13 +265,36 @@ module GraphWeaver
|
|
|
240
265
|
source
|
|
241
266
|
end
|
|
242
267
|
|
|
243
|
-
#
|
|
244
|
-
|
|
268
|
+
# The document's operation kind — "query", "mutation" or
|
|
269
|
+
# "subscription" — or nil when it holds no operation or won't parse.
|
|
270
|
+
# The one source of truth for the word a module name ends in AND for
|
|
271
|
+
# the file-name extension that word makes redundant.
|
|
272
|
+
def operation_kind(source)
|
|
245
273
|
operation = GraphQL.parse(source).definitions
|
|
246
274
|
.grep(GraphQL::Language::Nodes::OperationDefinition).first
|
|
247
|
-
(operation
|
|
275
|
+
operation && (operation.operation_type || "query") # `{ hello }` is shorthand for a query
|
|
248
276
|
rescue GraphQL::ParseError
|
|
249
|
-
|
|
277
|
+
nil # unparseable: codegen brands the real error a moment later
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# GraphQL's operation kinds, as a file name spells them. Apollo, Relay
|
|
281
|
+
# and GitLab's frontend all name a query file for its operation, so
|
|
282
|
+
# `blob_content.query.graphql` says in the extension exactly what the
|
|
283
|
+
# module's own suffix says — drop it rather than emit BlobContentQueryQuery.
|
|
284
|
+
# `_query` inside a snake_case name is a word OF the name, not this, so
|
|
285
|
+
# nothing that generates today is renamed.
|
|
286
|
+
OPERATION_EXTENSION = /\.(query|mutation|subscription)\z/i
|
|
287
|
+
private_constant :OPERATION_EXTENSION
|
|
288
|
+
|
|
289
|
+
def strip_kind_extension(base, kind, path)
|
|
290
|
+
declared = base[OPERATION_EXTENSION, 1]&.downcase
|
|
291
|
+
stem = declared && base[0...-(declared.length + 1)]
|
|
292
|
+
return base if stem.nil? || stem.empty?
|
|
293
|
+
return stem if kind.nil? || kind == declared
|
|
294
|
+
|
|
295
|
+
raise GraphWeaver::Error, "#{relative(path)}: the file name ends .#{declared}, but the " \
|
|
296
|
+
"document defines a #{kind} — rename it #{stem}.#{kind}#{File.extname(path)} " \
|
|
297
|
+
"or drop the .#{declared}"
|
|
250
298
|
end
|
|
251
299
|
end
|
|
252
300
|
end
|
|
@@ -350,7 +398,8 @@ module GraphWeaver
|
|
|
350
398
|
# every request, and the only way to be wrong (a field literally named
|
|
351
399
|
# `mutation` opening a line) errs toward not retrying.
|
|
352
400
|
MUTATION_PATTERN = /^[ \t]*mutation\b/
|
|
353
|
-
|
|
401
|
+
SUBSCRIPTION_PATTERN = /^[ \t]*subscription\b/
|
|
402
|
+
private_constant :MUTATION_PATTERN, :SUBSCRIPTION_PATTERN
|
|
354
403
|
|
|
355
404
|
REQUEST_MUTEX = Mutex.new
|
|
356
405
|
private_constant :REQUEST_MUTEX
|
|
@@ -365,16 +414,35 @@ module GraphWeaver
|
|
|
365
414
|
|
|
366
415
|
def mutation?(query) = MUTATION_PATTERN.match?(query)
|
|
367
416
|
|
|
417
|
+
# What this document runs, for the instrumentation payload — :query
|
|
418
|
+
# for the shorthand `{ ... }` document too, which is what it is.
|
|
419
|
+
# Built on mutation? rather than beside it, so an APM's write-failure
|
|
420
|
+
# rate and the decision not to retry can't come to disagree.
|
|
421
|
+
def kind(query)
|
|
422
|
+
return :mutation if mutation?(query)
|
|
423
|
+
|
|
424
|
+
SUBSCRIPTION_PATTERN.match?(query) ? :subscription : :query
|
|
425
|
+
end
|
|
426
|
+
|
|
368
427
|
# one error in the shape a GraphQL response carries them
|
|
369
428
|
def graphql_error(message, code)
|
|
370
429
|
{ "message" => message, "extensions" => { "code" => code } }
|
|
371
430
|
end
|
|
372
431
|
|
|
373
|
-
# "[req 3 FilteredPokemon]" —
|
|
374
|
-
# operation name
|
|
432
|
+
# "[req 4123-3 FilteredPokemon]" — the pid, this process's own
|
|
433
|
+
# request count, and the operation name when there is one.
|
|
434
|
+
#
|
|
435
|
+
# Both halves, because a Puma cluster forks: the counter is inherited
|
|
436
|
+
# with everything else, so without the reset every worker continues
|
|
437
|
+
# the master's sequence, and without the pid two workers' "[req 3]"
|
|
438
|
+
# are two unrelated requests in one aggregated log.
|
|
375
439
|
def log_tag(operation_name = nil)
|
|
376
|
-
|
|
377
|
-
|
|
440
|
+
pid = Process.pid
|
|
441
|
+
id = REQUEST_MUTEX.synchronize do
|
|
442
|
+
@request_pid, @request_count = pid, 0 unless @request_pid == pid
|
|
443
|
+
@request_count += 1
|
|
444
|
+
end
|
|
445
|
+
"[req #{pid}-#{id}#{" #{operation_name}" if operation_name}]"
|
|
378
446
|
end
|
|
379
447
|
|
|
380
448
|
def truncate_for_log(query)
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# typed: ignore — ActiveSupport::LogSubscriber, which sorbet can't resolve here
|
|
2
2
|
# frozen_string_literal: true
|
|
3
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
|
+
|
|
4
10
|
module GraphWeaver
|
|
5
11
|
# One line per GraphQL operation in a Rails log, the shape ActiveRecord
|
|
6
12
|
# uses for a query:
|
|
@@ -54,11 +60,13 @@ module GraphWeaver
|
|
|
54
60
|
payload[:graph] ? "#{payload[:graph]}/#{operation}" : operation
|
|
55
61
|
end
|
|
56
62
|
|
|
57
|
-
# status, then whatever narrows it: the error class, the
|
|
63
|
+
# status, then whatever narrows it: the error class, the reason an alert
|
|
58
64
|
# groups by, and which attempt this was when a Retry is in the stack.
|
|
59
65
|
def outcome(payload)
|
|
60
66
|
parts = [payload[:status], payload[:error]]
|
|
61
|
-
|
|
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
|
|
62
70
|
parts << "(retry #{payload[:retries]})" if payload[:retries].to_i.positive?
|
|
63
71
|
parts.compact.join(" ")
|
|
64
72
|
end
|
data/lib/graph_weaver/logging.rb
CHANGED
|
@@ -53,7 +53,7 @@ module GraphWeaver
|
|
|
53
53
|
#
|
|
54
54
|
# It must call the block and return its value. The only event today is
|
|
55
55
|
# EXECUTE_EVENT; its payload is the contract in docs/logging.md —
|
|
56
|
-
# :operation, :client, :status, :duration_ms, :graph always;
|
|
56
|
+
# :operation, :client, :kind, :status, :duration_ms, :graph always;
|
|
57
57
|
# :url/:http_status over the wire, :schema in-process, :error/:code on a
|
|
58
58
|
# failure, :retries when a Retry wrapped it. Never the query text or the
|
|
59
59
|
# variables: the payload fans out to subscribers that know none of the
|
|
@@ -77,6 +77,15 @@ module GraphWeaver
|
|
|
77
77
|
!key.nil? && Log.filter_variables({ key.to_s => nil })[key.to_s] == FILTERED
|
|
78
78
|
end
|
|
79
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
|
+
|
|
80
89
|
# `detail` unless the key is filtered — free text a coercer or sorbet
|
|
81
90
|
# wrote can spell a value any way, so for a filtered key none of it
|
|
82
91
|
# survives, not the parts that would have been safe.
|
|
@@ -94,12 +103,18 @@ module GraphWeaver
|
|
|
94
103
|
# optional because a coercer refusing a value hasn't been told one.
|
|
95
104
|
def shown(raw, key = nil) = filtered?(key) ? FILTERED : cap(value(key, raw).inspect)
|
|
96
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
|
+
|
|
97
113
|
# Text the library didn't author — a value a caller sent, a sentence a
|
|
98
114
|
# server wrote — cut to what an error may carry. The number lives on
|
|
99
115
|
# InputError, which is the class that documents it and the one every
|
|
100
116
|
# capped string reaches.
|
|
101
|
-
def cap(text)
|
|
102
|
-
limit = GraphWeaver::InputError::VALUE_LIMIT
|
|
117
|
+
def cap(text, limit = GraphWeaver::InputError::VALUE_LIMIT)
|
|
103
118
|
return text if text.bytesize <= limit
|
|
104
119
|
|
|
105
120
|
# byteslice can land mid-character; scrub drops the partial tail
|
|
@@ -183,13 +198,15 @@ module GraphWeaver
|
|
|
183
198
|
payload[:status] = :ok
|
|
184
199
|
else
|
|
185
200
|
payload[:status] = :errors
|
|
186
|
-
|
|
201
|
+
code = errors.grep(Hash).filter_map { |e| GraphWeaver::GraphQLError.from_h(e).code }.first
|
|
202
|
+
payload[:code] = Redact.tag(code)
|
|
187
203
|
end
|
|
188
204
|
result
|
|
189
205
|
rescue => e
|
|
190
206
|
payload[:error] = e.class.name
|
|
191
|
-
#
|
|
192
|
-
|
|
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.
|
|
193
210
|
raise
|
|
194
211
|
ensure
|
|
195
212
|
payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
|
|
@@ -249,6 +266,16 @@ module GraphWeaver
|
|
|
249
266
|
filters.nil? ? variables : filters.filter(variables)
|
|
250
267
|
end
|
|
251
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
|
+
|
|
252
279
|
private
|
|
253
280
|
|
|
254
281
|
# The GraphQL errors a response carries, whatever answered it — a
|
|
@@ -269,13 +296,6 @@ module GraphWeaver
|
|
|
269
296
|
else value
|
|
270
297
|
end
|
|
271
298
|
end
|
|
272
|
-
|
|
273
|
-
def filtered?(key, filters)
|
|
274
|
-
name = key.to_s
|
|
275
|
-
filters.any? do |filter|
|
|
276
|
-
filter.is_a?(Regexp) ? name.match?(filter) : name.downcase.include?(filter.to_s.downcase)
|
|
277
|
-
end
|
|
278
|
-
end
|
|
279
299
|
end
|
|
280
300
|
end
|
|
281
301
|
end
|