graph_weaver 0.6.1 → 0.7.0

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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +40 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +474 -106
  70. metadata +56 -1
@@ -47,6 +47,9 @@ 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
55
  # word.
@@ -67,6 +70,30 @@ module GraphWeaver
67
70
  # just the module name — see generated_names
68
71
  def module_name(path, source) = generated_names(path, source).first
69
72
 
73
+ # The one sentence about scalars nothing registered — said on the
74
+ # logger per parse and once per run by the build, and worth saying
75
+ # identically in both. Keyed by graph name (nil for an app with no
76
+ # declared graphs): a registration is scoped to one graph, so merging
77
+ # the names across several would read as "forgotten everywhere" for a
78
+ # scalar registered for one of them and forgotten for the next.
79
+ def untyped_scalars_report(by_graph)
80
+ found = by_graph.reject { |_, names| names.empty? }
81
+ return if found.empty?
82
+
83
+ advice = "(register with GraphWeaver.register_scalar)"
84
+ # One graph ran, so there is nothing to attribute — including when it
85
+ # is the only one with findings is what made a forgetful graph read
86
+ # as a forgetful app.
87
+ if by_graph.one?
88
+ names = found.values.first.sort
89
+ return "#{names.size} unregistered custom scalar#{"s" unless names.one?} → T.untyped: " \
90
+ "#{names.join(", ")} #{advice}"
91
+ end
92
+
93
+ ["unregistered custom scalars → T.untyped #{advice}:",
94
+ *found.map { |graph, names| " graph #{graph.inspect}: #{names.sort.join(", ")}" }].join("\n")
95
+ end
96
+
70
97
  # A path setting, as a real path: relative to GraphWeaver.root, which
71
98
  # is the app root and not wherever the process was started. Every
72
99
  # filesystem access on a configured path goes through here; the
@@ -85,12 +112,95 @@ module GraphWeaver
85
112
  path.start_with?(prefix) ? path.delete_prefix(prefix) : path
86
113
  end
87
114
 
115
+ # The registrations a schema generates with: the graph that named it,
116
+ # or the default graph's. The testing fakes ask, so a fabricated
117
+ # scalar is the shape the module generated against that schema will
118
+ # cast — a `Money` registered for one graph is not a `Money` for the
119
+ # next one along. Matched on the schema class a graph runs in-process,
120
+ # which is the only identity cheap enough to ask per fake; anything
121
+ # else falls back to the default, which is where a single-schema app
122
+ # has always read from.
123
+ def registry_for(schema) = graph_for(schema)&.registry || Codegen.registry
124
+
125
+ # The declared graph that runs `schema` in-process, or nil. Matched on
126
+ # the schema class a graph runs, which is the only identity cheap
127
+ # enough to ask per call — a dump would have to be re-read, and
128
+ # re-reading it gives a different object every time. GraphWeaver.parse
129
+ # asks too, to bake the GRAPH a generated file would have carried.
130
+ def graph_for(schema)
131
+ schema && GraphWeaver.graphs.find { |candidate| candidate.live_schema.equal?(schema) }
132
+ end
133
+
134
+ # Where generated modules are READ from: the configured patterns, plus
135
+ # any graph writing somewhere they don't already cover. generated_paths'
136
+ # default glob (app/graphql/*/generated) covers the conventional layout,
137
+ # so listing a graph's output as well would name the same directory
138
+ # twice — in the log, and in the globbing.
139
+ #
140
+ # FNM_PATHNAME because Dir.glob is what expands these patterns
141
+ # everywhere else (Zeitwerk's ignore, load_generated!) and its * stops
142
+ # at a /. Without it app/graphql/*/generated "covered"
143
+ # app/graphql/a/b/generated, which was then neither ignored nor loaded.
144
+ def generated_dirs
145
+ extra = GraphWeaver.graphs.map(&:output).reject do |dir|
146
+ GraphWeaver.generated_paths.any? do |pattern|
147
+ File.fnmatch?(resolve(pattern), resolve(dir), File::FNM_PATHNAME)
148
+ end
149
+ end
150
+ GraphWeaver.generated_paths | extra
151
+ end
152
+
88
153
  # Every query document under these directories, sorted — the files
89
154
  # generate!, verify_generated!, check_queries and load_queries! read.
90
155
  def query_files(paths = GraphWeaver.queries_paths)
91
156
  Array(paths).flat_map { |dir| Dir[File.join(resolve(dir), Codegen::DOCUMENT_GLOB)].sort }
92
157
  end
93
158
 
159
+ # Anywhere GraphWeaver takes schema:, a Client stands for its schema — so
160
+ # the console object and the rake task point at the same thing. A path
161
+ # (String or Pathname) or SDL loads like it does everywhere else in the
162
+ # library; without that it reached `schema.validate` as itself and failed
163
+ # as `undefined method 'validate' for an instance of String`.
164
+ def schema_for(source)
165
+ return source.schema if source.is_a?(Client)
166
+ return SchemaLoader.load(source) if source.is_a?(String) || source.respond_to?(:to_path)
167
+
168
+ source
169
+ end
170
+
171
+ # Whether this source carries the @join__* routing table, i.e. is a
172
+ # composed supergraph rather than an API schema. The one place that
173
+ # asks: Graph#supergraph reads it per graph, Testing::Config for the
174
+ # app-wide fallbacks, and the federation rake tasks through both.
175
+ #
176
+ # Kept per source for the life of the process — parsing a supergraph
177
+ # is milliseconds and :wire asks per example — and keyed on what the
178
+ # file IS, since the answer is a property of its content. A supergraph
179
+ # recomposed at a stable path (a `before` hook, chained rake tasks)
180
+ # used to be answered from the previous composition, which routed a
181
+ # graph into the wrong plan or refused it as being in none.
182
+ def composed?(source)
183
+ @composed ||= {}
184
+ key = composed_key(source)
185
+ return @composed[key] if @composed.key?(key)
186
+
187
+ @composed[key] = begin
188
+ SchemaLoader.routing_table?(source)
189
+ rescue GraphWeaver::Error
190
+ # a source that can't even be read is in no supergraph either —
191
+ # and that Error is worth its warn line, where "not federated"
192
+ # never was
193
+ false
194
+ end
195
+ end
196
+
197
+ # the conventional schema dump, required
198
+ def locate_schema!
199
+ SchemaLoader.locate or raise GraphWeaver::Error,
200
+ "no schema dump at #{GraphWeaver.schema_path} (.json/.graphql/.gql) — pass schema:, " \
201
+ "or cache one: GraphWeaver.new(url, cache: true).schema"
202
+ end
203
+
94
204
  # The graphql-ruby schema class the app default executes against,
95
205
  # when it runs in-process — a Client wrapping one, or the class in
96
206
  # the slot bare. nil for every network client. Not memoized: in dev
@@ -104,8 +214,32 @@ module GraphWeaver
104
214
  target if target.is_a?(Class) && target <= GraphQL::Schema
105
215
  end
106
216
 
217
+ # The context to hand resolvers. A proc is answered from a request's
218
+ # headers (Testing::Endpoint resolves it), so off the wire there is
219
+ # nothing to answer it with — and a Proc reaching graphql-ruby as a
220
+ # context fails far from the line that set it.
221
+ def context!(context)
222
+ return context unless context.respond_to?(:call)
223
+
224
+ raise GraphWeaver::Error, "context: is a proc, so it is answered from a request's " \
225
+ "headers — and nothing here made a request. Tag the example graphql: :wire, which " \
226
+ "serves your resolvers at your client's endpoint so your transport's headers reach " \
227
+ "them; off the wire, pass the hash."
228
+ end
229
+
107
230
  private
108
231
 
232
+ # What makes a composed? answer stale. Both callers pass a path that
233
+ # exists, so the file's identity is its stat — size as well as mtime,
234
+ # because a coarse mtime can miss two writes in one tick. Anything
235
+ # that isn't a path (SDL, a class) is its own key.
236
+ def composed_key(source)
237
+ stat = File.stat(source.to_s)
238
+ [source.to_s, stat.mtime, stat.size]
239
+ rescue SystemCallError
240
+ source
241
+ end
242
+
109
243
  # "Mutation" for a mutation document, "Query" for everything else.
110
244
  def operation_suffix(source)
111
245
  operation = GraphQL.parse(source).definitions
@@ -142,7 +276,7 @@ module GraphWeaver
142
276
  # JSON round-trip so symbol keys become strings — otherwise
143
277
  # YAML.dump writes Ruby symbols the safe loader rejects on the next
144
278
  # run, and lookup keys stay stable across processes
145
- def normalize_variables(variables) = JSON.parse(JSON.generate(variables || {}))
279
+ def normalize_variables(variables) = JSON.parse(Wire.json(variables || {}))
146
280
 
147
281
  # one readable line: an error naming a 60-line query is a wall, not a hint
148
282
  def summarize(query, limit: 160)
@@ -157,6 +291,54 @@ module GraphWeaver
157
291
  # carries an error in. Lived on Transport and Router, both of which
158
292
  # users touch — the worst place for it.
159
293
  module Wire
294
+ # JSON for the wire, or the caller's bug named under the umbrella: a
295
+ # value with no JSON form (NaN, Infinity, binary) raised a raw JSON::
296
+ # error from wherever it was first encoded — the transport, a cassette
297
+ # key, a log line — so every encoder goes through here.
298
+ def self.json(value)
299
+ JSON.generate(value)
300
+ rescue JSON::GeneratorError => e
301
+ raise GraphWeaver::Error, "variables are not JSON-serializable: #{e.message}"
302
+ end
303
+
304
+ # The half of that discipline JSON doesn't raise for. JSON.generate
305
+ # carries a String, a number, a boolean, null, a list and an object;
306
+ # anything else it renders as the value's #to_s — right for a Date or
307
+ # a Symbol, a memory address for a File, which then sits in the
308
+ # server's database looking like it meant something. So a variable
309
+ # whose #to_s is Ruby's debug form, or that is a stream whose bytes
310
+ # JSON can't carry at all, is refused before the body is built.
311
+ def self.check_variables!(variables)
312
+ variables&.each { |name, value| check_variable!(name.to_s, value) }
313
+ end
314
+
315
+ def self.check_variable!(path, value)
316
+ case value
317
+ when Hash then value.each { |key, nested| check_variable!("#{path}.#{key}", nested) }
318
+ when Array then value.each_with_index { |nested, i| check_variable!("#{path}[#{i}]", nested) }
319
+ when String, Symbol, Numeric, true, false, nil then nil
320
+ else
321
+ raise GraphWeaver::Error, variable_refusal(path, value) if value.respond_to?(:read) ||
322
+ value.to_s.start_with?("#<")
323
+ end
324
+ end
325
+ private_class_method :check_variable!
326
+
327
+ # A stream and an anonymous object fail the same way and need different
328
+ # next steps: one is a feature this client doesn't have, the other is a
329
+ # value that never said what it is.
330
+ def self.variable_refusal(path, value)
331
+ if value.respond_to?(:read)
332
+ "$#{path} is a #{value.class} — graph_weaver posts application/json and doesn't implement " \
333
+ "the GraphQL multipart request spec, so a file can't ride along; send what the server " \
334
+ "expects as JSON, or POST the upload with your own transport"
335
+ else
336
+ "$#{path} is a #{value.class}, which has no JSON form — it would go on the wire as " \
337
+ "#{value.to_s.inspect}; send a String, a number, a boolean, a list, or an object"
338
+ end
339
+ end
340
+ private_class_method :variable_refusal
341
+
160
342
  # The name of the document's FIRST operation, nil when anonymous. Only
161
343
  # the fallback for a raw query string handed straight to a transport —
162
344
  # generated modules pass their OPERATION_NAME, parsed properly.
@@ -0,0 +1,66 @@
1
+ # typed: ignore — ActiveSupport::LogSubscriber, which sorbet can't resolve here
2
+ # frozen_string_literal: true
3
+
4
+ module GraphWeaver
5
+ # One line per GraphQL operation in a Rails log, the shape ActiveRecord
6
+ # uses for a query:
7
+ #
8
+ # GraphWeaver PersonQuery (12.3ms) ok
9
+ # GraphWeaver PersonQuery (8.1ms) errors [THROTTLED]
10
+ # GraphWeaver PersonQuery (31.2ms) failed GraphWeaver::TransportError
11
+ # GraphWeaver billing/InvoicesQuery (12.3ms) ok
12
+ #
13
+ # Attached by the railtie wherever ActiveSupport is, and fed by the
14
+ # instrumenter it sets. Requires ActiveSupport — `require` this yourself
15
+ # only if you subscribe by hand.
16
+ #
17
+ # **One rule decides which line you get: the summary is info, the wire is
18
+ # debug.** This is the only GraphWeaver line at info, so a production log
19
+ # gets one per operation and nothing that could carry PII; turning
20
+ # GraphWeaver.logger up to debug adds the query, the variables and the
21
+ # response *beneath* it rather than repeating it.
22
+ #
23
+ # It writes through GraphWeaver.logger rather than Rails.logger, so
24
+ # `GraphWeaver.logger = nil` — the documented way to silence the gem —
25
+ # silences this too, and the line carries the same `graph_weaver`
26
+ # progname as every other one.
27
+ class LogSubscriber < ActiveSupport::LogSubscriber
28
+ # attach_to(:graph_weaver) subscribes "#{method}.graph_weaver" and
29
+ # ActiveSupport::Subscriber#call dispatches on the name up to the first
30
+ # dot — so this method name is EXECUTE_EVENT's first half, both ways.
31
+ def execute(event)
32
+ payload = event.payload
33
+
34
+ GraphWeaver::Internal::Log.log(:info) do
35
+ # duration_ms is the instrumenter's own measurement; event.duration
36
+ # covers a subscriber attached to something that didn't set it
37
+ ms = payload[:duration_ms] || event.duration
38
+ "GraphWeaver #{subject(payload)} (#{format("%.1f", ms)}ms) #{outcome(payload)}"
39
+ end
40
+ end
41
+
42
+ # GraphWeaver's logger, not Rails' — LogSubscriber#call skips a
43
+ # subscriber whose logger is nil, which is what makes the gem's own
44
+ # opt-out reach this line too.
45
+ def logger = GraphWeaver.logger
46
+
47
+ private
48
+
49
+ # What ran: the operation, prefixed by its graph when the request carried
50
+ # one — an app with several graphs reads `billing/InvoicesQuery` without
51
+ # a second line shape to learn, and one with a single graph never sees it.
52
+ def subject(payload)
53
+ operation = payload[:operation] || "query"
54
+ payload[:graph] ? "#{payload[:graph]}/#{operation}" : operation
55
+ end
56
+
57
+ # status, then whatever narrows it: the error class, the code an alert
58
+ # groups by, and which attempt this was when a Retry is in the stack.
59
+ def outcome(payload)
60
+ parts = [payload[:status], payload[:error]]
61
+ parts << "[#{payload[:code]}]" if payload[:code]
62
+ parts << "(retry #{payload[:retries]})" if payload[:retries].to_i.positive?
63
+ parts.compact.join(" ")
64
+ end
65
+ end
66
+ end
@@ -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
- # is EXECUTE_EVENT; its payload carries :url (nil in-process),
54
- # :schema (in-process only), :operation (the document's operation
55
- # name, nil for an anonymous one), and added after the response
56
- # lands :status. Never the query text or the variables: those
57
- # carry PII and belong at debug on the logger, where they're gated.
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, :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
 
@@ -77,6 +81,30 @@ module GraphWeaver
77
81
  # wrote can spell a value any way, so for a filtered key none of it
78
82
  # survives, not the parts that would have been safe.
79
83
  def detail(key, detail) = filtered?(key) ? FILTERED : detail
84
+
85
+ # A value the library reports as DATA rather than inside a sentence —
86
+ # InputError#value. Scrubbed at every depth, so a filtered key nested
87
+ # inside an input object is covered too, and the same list decides it
88
+ # as decides the debug log's variables line.
89
+ def value(key, value) = Log.filter_variables({ key.to_s => value })[key.to_s]
90
+
91
+ # A value the library spells INTO a sentence. `detail` can only ask
92
+ # about the key the value arrived under, so it reads a filtered key one
93
+ # level in as safe; this scrubs at every depth, like #value. The key is
94
+ # optional because a coercer refusing a value hasn't been told one.
95
+ def shown(raw, key = nil) = filtered?(key) ? FILTERED : cap(value(key, raw).inspect)
96
+
97
+ # Text the library didn't author — a value a caller sent, a sentence a
98
+ # server wrote — cut to what an error may carry. The number lives on
99
+ # InputError, which is the class that documents it and the one every
100
+ # capped string reaches.
101
+ def cap(text)
102
+ limit = GraphWeaver::InputError::VALUE_LIMIT
103
+ return text if text.bytesize <= limit
104
+
105
+ # byteslice can land mid-character; scrub drops the partial tail
106
+ "#{text.byteslice(0, limit).scrub("")}…(#{text.bytesize - limit} more bytes)"
107
+ end
80
108
  end
81
109
  end
82
110
  end
@@ -91,14 +119,23 @@ module GraphWeaver
91
119
  self.filter_parameters = DEFAULT_FILTER_PARAMETERS
92
120
 
93
121
  # The one instrumentation event: a single GraphQL request, start to
94
- # parsed response, whichever client slot served it.
95
- EXECUTE_EVENT = "graph_weaver.execute"
122
+ # parsed response, whichever client slot served it. `<event>.<namespace>`
123
+ # is how every notification in this ecosystem is spelled
124
+ # (sql.active_record, execute_multiplex.graphql) — it's what
125
+ # ActiveSupport::LogSubscriber.attach_to and an APM's namespace routing
126
+ # key on, so a backwards name made both of them a puzzle.
127
+ EXECUTE_EVENT = "execute.graph_weaver"
96
128
 
97
129
  module Internal
98
130
  # The emitting half of the narration the three accessors above
99
131
  # configure. Setting a logger is API; writing to it is not, and the
100
132
  # two read as a pair when they sit on the same object.
101
133
  module Log
134
+ # fiber-local, set only for the duration of one attempt (with_retries)
135
+ RETRIES = :graph_weaver_retries
136
+ # fiber-local, set only for the duration of one dispatch (with_graph)
137
+ GRAPH = :graph_weaver_graph
138
+
102
139
  class << self
103
140
  # Level-gated and lazy — the block only runs when a logger is
104
141
  # listening. Messages carry "graph_weaver" as progname.
@@ -118,13 +155,89 @@ module GraphWeaver
118
155
  result
119
156
  end
120
157
 
121
- # Wrap the block in the instrumenter, if one is set. The payload is
122
- # a plain Hash the caller may add to inside the block.
158
+ # Wrap the block in the instrumenter, if one is set. The caller
159
+ # supplies what only it knows (:url, :schema, :client); this fills
160
+ # in the half every path shares — how it ended, how long it took,
161
+ # what a Retry had already spent — so one subscriber reads one
162
+ # shape whichever client slot served the request.
123
163
  def instrument(event, payload)
124
164
  hook = GraphWeaver.instrumenter
125
165
  return yield unless hook
126
166
 
127
- hook.call(event, payload) { yield }
167
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
168
+ retries = Thread.current[RETRIES]
169
+ payload[:retries] = retries if retries
170
+ payload[:graph] = Thread.current[GRAPH]
171
+ # pessimistic, so :status is set even for what a rescue can't
172
+ # see — an Interrupt, a killed thread — and never silently absent
173
+ payload[:status] = :failed
174
+
175
+ # One dispatch labels one request. Whatever THIS request reaches —
176
+ # a resolver serving it that calls out — is a request of its own,
177
+ # and the caller's graph would be a wrong label on it.
178
+ with_graph(nil) do
179
+ hook.call(event, payload) do
180
+ result = yield
181
+ errors = response_errors(result)
182
+ if errors.empty?
183
+ payload[:status] = :ok
184
+ else
185
+ payload[:status] = :errors
186
+ payload[:code] = errors.grep(Hash).filter_map { |e| GraphWeaver::GraphQLError.from_h(e).code }.first
187
+ end
188
+ result
189
+ rescue => e
190
+ payload[:error] = e.class.name
191
+ # the one key an alert groups by, whichever kind of failure it was
192
+ payload[:code] = e.status if e.is_a?(GraphWeaver::ServerError)
193
+ raise
194
+ ensure
195
+ payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
196
+ end
197
+ end
198
+ end
199
+
200
+ # What a Retry has already spent, read by the attempt it is about
201
+ # to make. A dynamic extent rather than a global: the count is only
202
+ # visible while the call it describes is on the stack, so a client
203
+ # that never reaches instrument can't leave a stale one behind.
204
+ def with_retries(count)
205
+ return yield unless GraphWeaver.instrumenter
206
+
207
+ previous = Thread.current[RETRIES]
208
+ Thread.current[RETRIES] = count
209
+ begin
210
+ yield
211
+ ensure
212
+ Thread.current[RETRIES] = previous
213
+ end
214
+ end
215
+
216
+ # The graph a generated module is dispatching, read by the request it
217
+ # is about to make. Same dynamic extent as with_retries, for the same
218
+ # reason — and instrument clears it for the duration of the request it
219
+ # labels, so exactly one request wears the label.
220
+ def with_graph(name)
221
+ return yield unless GraphWeaver.instrumenter
222
+
223
+ previous = Thread.current[GRAPH]
224
+ Thread.current[GRAPH] = name
225
+ begin
226
+ yield
227
+ ensure
228
+ Thread.current[GRAPH] = previous
229
+ end
230
+ end
231
+
232
+ # The variables as one JSON line for a log: filtered, and unable to
233
+ # raise. A value with no JSON form (NaN, binary) is the caller's bug
234
+ # and the transport refuses it a few lines later — but a logger that
235
+ # decides WHICH exception a caller sees, or whether one is raised at
236
+ # all, is worse than a log line that says it couldn't render.
237
+ def variables_for_log(variables)
238
+ JSON.generate(filter_variables(variables))
239
+ rescue StandardError => e
240
+ "<unloggable: #{e.class}>"
128
241
  end
129
242
 
130
243
  # variables with the filtered keys blanked out
@@ -138,6 +251,17 @@ module GraphWeaver
138
251
 
139
252
  private
140
253
 
254
+ # The GraphQL errors a response carries, whatever answered it — a
255
+ # Hash from a transport, a graphql-ruby Result in-process, a fake.
256
+ # Never raises: an instrumenter that decides which exception a
257
+ # caller sees is worse than a missing tag.
258
+ def response_errors(result)
259
+ errors = result.to_h["errors"] if result.respond_to?(:to_h)
260
+ errors.is_a?(Array) ? errors : []
261
+ rescue StandardError
262
+ []
263
+ end
264
+
141
265
  def scrub(value, filters)
142
266
  case value
143
267
  when Hash then value.to_h { |k, v| [k, filtered?(k, filters) ? FILTERED : scrub(v, filters)] }
@@ -3,6 +3,8 @@
3
3
 
4
4
  require "sorbet-runtime"
5
5
 
6
+ require_relative "internal/test_clients"
7
+
6
8
  module GraphWeaver
7
9
  # Called by generated code — not semver'd for direct use.
8
10
  #
@@ -14,7 +16,8 @@ module GraphWeaver
14
16
  # those are the point.
15
17
  #
16
18
  # Resolution order, per the docs: per call → per module (`MyQuery.client =`)
17
- # → the module's baked DEFAULT_CLIENT`GraphWeaver.client`.
19
+ # → a test mode's stand-in (Internal::TestClients)the module's baked
20
+ # DEFAULT_CLIENT → `GraphWeaver.client`.
18
21
  module QueryModule
19
22
  extend T::Sig
20
23
 
@@ -30,13 +33,36 @@ module GraphWeaver
30
33
 
31
34
  private
32
35
 
36
+ # The one call a generated `execute` makes: resolve the client, run this
37
+ # module's own operation, hand the raw response back for from_response to
38
+ # wrap. Here rather than emitted, so what has to BRACKET a request — the
39
+ # graph label today — costs nothing in every generated file, and one
40
+ # reading of it covers every module in the app.
41
+ #
42
+ # The constants come off the module rather than the caller: a generated
43
+ # `execute` already knows them, but reading them here is what makes this
44
+ # the whole of the call instead of three arguments' worth of it.
45
+ sig { params(variables: T::Hash[String, T.untyped], client: T.untyped).returns(T.untyped) }
46
+ def dispatch(variables, client:)
47
+ mod = T.unsafe(self)
48
+ # the graph codegen baked in, never one inferred from the client — a
49
+ # wrong label on a request is worse than no label
50
+ graph = mod.const_defined?(:GRAPH, false) ? mod.const_get(:GRAPH) : nil
51
+
52
+ GraphWeaver::Internal::Log.with_graph(graph) do
53
+ client_for(client).execute(mod.const_get(:QUERY), variables:,
54
+ operation_name: mod.const_get(:OPERATION_NAME))
55
+ end
56
+ end
57
+
33
58
  # The client one execute runs through: the per-call `client:`, else the
34
59
  # module's, else the app default. Checked here so a wrong one names the
35
60
  # contract and the module, rather than surfacing as a NoMethodError from
36
- # inside the call.
61
+ # inside the call — and put through Client.instrumented, the one place a
62
+ # bare schema class gets the seam it has no way to carry itself.
37
63
  sig { params(override: T.untyped).returns(T.untyped) }
38
64
  def client_for(override)
39
- target = override || client
65
+ target = GraphWeaver::Client.instrumented(override || client)
40
66
  return target if target.respond_to?(:execute)
41
67
 
42
68
  # Kernel.raise: this module is extended into another, so sorbet can't
@@ -48,9 +74,16 @@ module GraphWeaver
48
74
  # Codegen's `client:` constant, emitted as a DEFAULT_CLIENT lambda so the
49
75
  # constant it names is resolved on first use rather than at load — a
50
76
  # generated file may load before the initializer that builds the client.
77
+ #
78
+ # A test mode stands in for it: what codegen baked in is exactly what a
79
+ # `graphql:` tag means to replace, so a bound module is covered by the
80
+ # tag like every other one.
51
81
  sig { returns(T.untyped) }
52
82
  def default_client
53
83
  mod = T.unsafe(self)
84
+ stand_in = GraphWeaver::Internal::TestClients.for(mod)
85
+ return stand_in if stand_in
86
+
54
87
  mod.const_defined?(:DEFAULT_CLIENT, false) ? mod.const_get(:DEFAULT_CLIENT).call : GraphWeaver.client!
55
88
  end
56
89
  end