graph_weaver 0.7.4 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +1 -0
  4. data/docs/errors.md +12 -5
  5. data/docs/generated_modules.md +134 -17
  6. data/docs/getting_started.md +182 -20
  7. data/docs/logging.md +79 -35
  8. data/docs/migrating.md +126 -0
  9. data/docs/scalars.md +50 -6
  10. data/docs/testing.md +78 -14
  11. data/docs/upgrading.md +44 -2
  12. data/examples/README.md +4 -2
  13. data/examples/github/generate.rb +22 -8
  14. data/examples/github/generated/star_mutation.rb +2 -2
  15. data/examples/github/generated/stargazers_query.rb +2 -2
  16. data/examples/github/generated/starred_query.rb +2 -2
  17. data/examples/github/run.rb +1 -0
  18. data/examples/github/setup.rb +16 -8
  19. data/graph_weaver.gemspec +15 -6
  20. data/lib/generators/graph_weaver/install_generator.rb +49 -2
  21. data/lib/graph_weaver/client.rb +0 -23
  22. data/lib/graph_weaver/codegen/aliases.rb +36 -3
  23. data/lib/graph_weaver/codegen/emit.rb +20 -15
  24. data/lib/graph_weaver/codegen/enum_type.rb +52 -11
  25. data/lib/graph_weaver/codegen/nodes.rb +75 -32
  26. data/lib/graph_weaver/codegen.rb +260 -100
  27. data/lib/graph_weaver/coerce.rb +1 -1
  28. data/lib/graph_weaver/federation.rb +1 -6
  29. data/lib/graph_weaver/graph.rb +55 -5
  30. data/lib/graph_weaver/hints.rb +20 -5
  31. data/lib/graph_weaver/in_process.rb +2 -4
  32. data/lib/graph_weaver/input_struct.rb +50 -10
  33. data/lib/graph_weaver/internal/overrides.rb +126 -14
  34. data/lib/graph_weaver/internal/subgraphs.rb +1 -10
  35. data/lib/graph_weaver/internal/test_clients.rb +29 -7
  36. data/lib/graph_weaver/internal/unused.rb +62 -18
  37. data/lib/graph_weaver/internal/values.rb +24 -7
  38. data/lib/graph_weaver/internal.rb +23 -6
  39. data/lib/graph_weaver/log_subscriber.rb +27 -17
  40. data/lib/graph_weaver/logging.rb +115 -82
  41. data/lib/graph_weaver/parsing.rb +32 -3
  42. data/lib/graph_weaver/query_module.rb +67 -12
  43. data/lib/graph_weaver/railtie.rb +7 -2
  44. data/lib/graph_weaver/rspec.rb +41 -18
  45. data/lib/graph_weaver/schema_diff.rb +24 -5
  46. data/lib/graph_weaver/schema_loader.rb +29 -17
  47. data/lib/graph_weaver/tasks.rb +98 -16
  48. data/lib/graph_weaver/testing/fake_client.rb +28 -31
  49. data/lib/graph_weaver/testing/router.rb +26 -25
  50. data/lib/graph_weaver/testing.rb +27 -8
  51. data/lib/graph_weaver/transport.rb +1 -1
  52. data/lib/graph_weaver/version.rb +1 -1
  53. data/lib/graph_weaver.rb +72 -42
  54. metadata +3 -2
@@ -18,6 +18,11 @@ module GraphWeaver
18
18
  # lexical scope, so a private constant would be unreachable from exactly
19
19
  # the files that need it. The name and the surface lock carry the rule.
20
20
  module Internal
21
+ # The wire value the member register_enum fallback: true adds to a
22
+ # generated enum serializes to. The GraphQL spec reserves a leading `__`,
23
+ # so no schema can declare a value that collides with it.
24
+ ENUM_FALLBACK_WIRE = "__other__"
25
+
21
26
  # Odds and ends several files share. Each is here because more than one
22
27
  # caller needs it, not because it belongs together with the others.
23
28
  module Util
@@ -50,6 +55,18 @@ module GraphWeaver
50
55
  # "a" or "an" for a word an error message is about to name.
51
56
  def article(word) = word.downcase.start_with?(/[aeiou]/) ? "an" : "a"
52
57
 
58
+ # how many entries a message names before it says "and N more"
59
+ SAMPLE = 5
60
+ private_constant :SAMPLE
61
+
62
+ # A list a message names inline, held to a readable length — a wall
63
+ # of schema coordinates says less than the first few and a count.
64
+ def sample(list)
65
+ return list.join(", ") if list.size <= SAMPLE
66
+
67
+ "#{list.first(SAMPLE).join(", ")} and #{list.size - SAMPLE} more"
68
+ end
69
+
53
70
  # The module a .graphql file generates, and the basename of the file
54
71
  # it generates into: the camelized file name plus the operation's own
55
72
  # word. Every run of non-alphanumerics in the name is a word boundary,
@@ -226,14 +243,14 @@ module GraphWeaver
226
243
  "or cache one: GraphWeaver.new(url, cache: true).schema"
227
244
  end
228
245
 
229
- # The graphql-ruby schema class the app default executes against,
230
- # when it runs in-process — a Client wrapping one, or the class in
231
- # the slot bare. nil for every network client. Not memoized: in dev
232
- # the class object is replaced on reload.
233
- def live_schema
246
+ # The graphql-ruby schema class a client executes against, when it
247
+ # runs in-process — a Client wrapping one, or the class in the slot
248
+ # bare. nil for every network client. Defaults to the app's own, and
249
+ # a graph passes its client. Not memoized: in dev the class object is
250
+ # replaced on reload.
251
+ def live_schema(client = GraphWeaver.client)
234
252
  # through #transport, not #schema: a url client's #schema
235
253
  # introspects, so asking it would answer over the network
236
- client = GraphWeaver.client
237
254
  target = client.is_a?(Client) ? client.transport : client
238
255
  target = target.schema if target.is_a?(InProcess)
239
256
  target if target.is_a?(Class) && target <= GraphQL::Schema
@@ -14,17 +14,20 @@ module GraphWeaver
14
14
  # GraphWeaver PersonQuery (12.3ms) ok
15
15
  # GraphWeaver PersonQuery (8.1ms) errors [THROTTLED]
16
16
  # GraphWeaver PersonQuery (31.2ms) failed GraphWeaver::TransportError
17
+ # GraphWeaver PersonQuery (44.0ms) failed GraphWeaver::CastError
17
18
  # GraphWeaver billing/InvoicesQuery (12.3ms) ok
18
19
  #
19
20
  # Attached by the railtie wherever ActiveSupport is, and fed by the
20
21
  # instrumenter it sets. Requires ActiveSupport — `require` this yourself
21
22
  # only if you subscribe by hand.
22
23
  #
23
- # **One rule decides which line you get: the 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.
24
+ # **One rule decides which line you get: the operation is info, the
25
+ # attempt is debug.** The info line is one CALL of a generated module, so
26
+ # it says what the caller got — a cast that raised reads `failed`, and a
27
+ # test mode's stand-in gets a line like every other client. A production
28
+ # log gets one per operation and nothing that could carry PII; turning
29
+ # GraphWeaver.logger up to debug adds each attempt, with the url, the HTTP
30
+ # status and which retry it was, beneath it.
28
31
  #
29
32
  # It writes through GraphWeaver.logger rather than Rails.logger, so
30
33
  # `GraphWeaver.logger = nil` — the documented way to silence the gem —
@@ -33,17 +36,10 @@ module GraphWeaver
33
36
  class LogSubscriber < ActiveSupport::LogSubscriber
34
37
  # attach_to(:graph_weaver) subscribes "#{method}.graph_weaver" and
35
38
  # ActiveSupport::Subscriber#call dispatches on the name up to the first
36
- # dot — so this method name is EXECUTE_EVENT's first half, both ways.
37
- def execute(event)
38
- payload = event.payload
39
+ # dot — so these method names are the events' first halves, both ways.
40
+ def operation(event) = write(:info, event)
39
41
 
40
- 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
42
+ def execute(event) = write(:debug, event)
47
43
 
48
44
  # GraphWeaver's logger, not Rails' — LogSubscriber#call skips a
49
45
  # subscriber whose logger is nil, which is what makes the gem's own
@@ -52,7 +48,20 @@ module GraphWeaver
52
48
 
53
49
  private
54
50
 
55
- # What ran: the operation, prefixed by its graph when the request carried
51
+ # One shape for both, so the attempt beneath an operation reads as the
52
+ # same line rather than a second format to learn.
53
+ def write(level, event)
54
+ payload = event.payload
55
+
56
+ GraphWeaver::Internal::Log.log(level) do
57
+ # duration_ms is the instrumenter's own measurement; event.duration
58
+ # covers a subscriber attached to something that didn't set it
59
+ ms = payload[:duration_ms] || event.duration
60
+ "GraphWeaver #{subject(payload)} (#{format("%.1f", ms)}ms) #{outcome(payload)}"
61
+ end
62
+ end
63
+
64
+ # What ran: the operation, prefixed by its graph when the payload names
56
65
  # one — an app with several graphs reads `billing/InvoicesQuery` without
57
66
  # a second line shape to learn, and one with a single graph never sees it.
58
67
  def subject(payload)
@@ -61,7 +70,8 @@ module GraphWeaver
61
70
  end
62
71
 
63
72
  # status, then whatever narrows it: the error class, the reason an alert
64
- # groups by, and which attempt this was when a Retry is in the stack.
73
+ # groups by, and on a debug attempt line which try this was. The last
74
+ # two are attempt facts, so only the debug line ever carries them.
65
75
  def outcome(payload)
66
76
  parts = [payload[:status], payload[:error]]
67
77
  # the GraphQL code, or the HTTP status where the request never got one
@@ -43,19 +43,22 @@ module GraphWeaver
43
43
  @filter_parameters = filters
44
44
  end
45
45
 
46
- # One callable wrapping every request GraphWeaver makes — over the
47
- # wire or in-process so an APM can time it and count errors. A
48
- # no-op until you set one (Rails sets this one for you):
46
+ # One callable wrapping every call GraphWeaver makes — one generated
47
+ # module's execute, and every request under it, over the wire or
48
+ # in-process so an APM can time it and count errors. A no-op until
49
+ # you set one (Rails sets this one for you):
49
50
  #
50
51
  # GraphWeaver.instrumenter = lambda do |event, payload, &block|
51
52
  # ActiveSupport::Notifications.instrument(event, payload, &block)
52
53
  # end
53
54
  #
54
- # It must call the block and return its value. 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
55
+ # It must call the block and return its value. Two events, and their
56
+ # payloads are the contract in docs/logging.md: OPERATION_EVENT is one
57
+ # call of a generated module (:operation, :module, :graph, :kind,
58
+ # :client, :status, :duration_ms), and EXECUTE_EVENT is one request
59
+ # inside it (:url/:http_status over the wire, :schema in-process,
60
+ # :retries under a Retry). Both add :error on a raise and :code on a
61
+ # response that carried GraphQL errors. Never the query text or the
59
62
  # variables: the payload fans out to subscribers that know none of the
60
63
  # filtering rules, so PII belongs at debug on the logger, where the
61
64
  # level gates it and filter_parameters scrubs it.
@@ -101,7 +104,11 @@ module GraphWeaver
101
104
  # about the key the value arrived under, so it reads a filtered key one
102
105
  # level in as safe; this scrubs at every depth, like #value. The key is
103
106
  # 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)
107
+ def shown(raw, key = nil) = filtered?(key) ? FILTERED : cap(spell(value(key, raw)))
108
+
109
+ # How a value reads inside a sentence. inspect, except that
110
+ # BigDecimal#inspect is scientific ("0.25e1" for the 2.5 a caller wrote).
111
+ def spell(value) = defined?(BigDecimal) && value.is_a?(BigDecimal) ? value.to_s("F") : value.inspect
105
112
 
106
113
  # A short server-chosen string the library republishes inside its own
107
114
  # text — the APM's :code, the [CODE] in the one line info writes, a
@@ -133,14 +140,24 @@ module GraphWeaver
133
140
 
134
141
  self.filter_parameters = DEFAULT_FILTER_PARAMETERS
135
142
 
136
- # The one instrumentation event: a single GraphQL request, start to
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.
143
+ # One GraphQL REQUEST, start to parsed response, whichever client slot
144
+ # served it — one attempt, so a call a Retry made three goes at is three
145
+ # of these. `<event>.<namespace>` is how every notification in this
146
+ # ecosystem is spelled (sql.active_record, execute_multiplex.graphql) —
147
+ # it's what ActiveSupport::LogSubscriber.attach_to and an APM's namespace
148
+ # routing key on, so a backwards name made both of them a puzzle.
142
149
  EXECUTE_EVENT = "execute.graph_weaver"
143
150
 
151
+ # One CALL of a generated module's execute/execute!: the request it makes,
152
+ # every retry and backoff beneath it, and the cast into the typed structs.
153
+ # One or more EXECUTE_EVENTs nest inside it.
154
+ #
155
+ # It says what the CALLER got, which a request can't: a CastError is raised
156
+ # after the response is back, so the request closed :ok while the app saw a
157
+ # failure. And it fires at the module seam, which every client slot passes
158
+ # through — a fake, the test router and a cassette report here too.
159
+ OPERATION_EVENT = "operation.graph_weaver"
160
+
144
161
  module Internal
145
162
  # The emitting half of the narration the three accessors above
146
163
  # configure. Setting a logger is API; writing to it is not, and the
@@ -170,81 +187,39 @@ module GraphWeaver
170
187
  result
171
188
  end
172
189
 
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.
178
- def instrument(event, payload)
179
- hook = GraphWeaver.instrumenter
180
- return yield unless hook
190
+ # Wrap one REQUEST in the instrumenter, if one is set. The caller
191
+ # supplies what only it knows (:url, :schema, :client); this adds the
192
+ # attempt facts the graph in scope, what a Retry had already spent
193
+ # so one subscriber reads one shape whichever client slot served it.
194
+ def instrument_request(payload, &block)
195
+ return yield unless GraphWeaver.instrumenter
181
196
 
182
- start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
183
197
  retries = Thread.current[RETRIES]
184
198
  payload[:retries] = retries if retries
185
199
  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
200
 
190
201
  # One dispatch labels one request. Whatever THIS request reaches —
191
202
  # a resolver serving it that calls out — is a request of its own,
192
203
  # 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
204
+ with_graph(nil) { measure(GraphWeaver::EXECUTE_EVENT, payload, &block) }
215
205
  end
216
206
 
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
207
+ # Wrap one CALL of a generated module: the request under it and the
208
+ # cast that follows. The caller names :graph here rather than reading
209
+ # it out of the fiber-local, because it IS the module that set it
210
+ # and it stays set, so the request below wears the same label.
211
+ def instrument_operation(payload, &block)
212
+ measure(GraphWeaver::OPERATION_EVENT, payload, &block)
231
213
  end
232
214
 
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
215
+ # What a Retry has already spent, read by the attempt it is about
216
+ # to make.
217
+ def with_retries(count, &block) = during(RETRIES, count, &block)
239
218
 
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
219
+ # The graph a generated module is dispatching, read by the request it
220
+ # is about to make — and instrument clears it for the duration of the
221
+ # request it labels, so exactly one request wears the label.
222
+ def with_graph(name, &block) = during(GRAPH, name, &block)
248
223
 
249
224
  # The variables as one JSON line for a log: filtered, and unable to
250
225
  # raise. A value with no JSON form (NaN, binary) is the caller's bug
@@ -278,13 +253,71 @@ module GraphWeaver
278
253
 
279
254
  private
280
255
 
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.
256
+ # One fiber-local, set for the length of one call. A dynamic extent
257
+ # rather than a global: the value is only visible while the call it
258
+ # describes is on the stack, so a client that never reaches instrument
259
+ # can't leave a stale one behind.
260
+ def during(key, value)
261
+ return yield unless GraphWeaver.instrumenter
262
+
263
+ previous = Thread.current[key]
264
+ Thread.current[key] = value
265
+ begin
266
+ yield
267
+ ensure
268
+ Thread.current[key] = previous
269
+ end
270
+ end
271
+
272
+ # The half both events share: run the block inside the hook, and
273
+ # record how it ended, the code an alert groups by, and how long it
274
+ # took — every one of them before the hook's block returns, so a
275
+ # subscriber reads a complete payload.
276
+ def measure(event, payload)
277
+ hook = GraphWeaver.instrumenter
278
+ return yield unless hook
279
+
280
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
281
+ # pessimistic, so :status is set even for what a rescue can't
282
+ # see — an Interrupt, a killed thread — and never silently absent
283
+ payload[:status] = :failed
284
+
285
+ hook.call(event, payload) do
286
+ result = yield
287
+ errors = response_errors(result)
288
+ if errors.empty?
289
+ payload[:status] = :ok
290
+ else
291
+ payload[:status] = :errors
292
+ code = errors.grep(GraphWeaver::GraphQLError).filter_map(&:code).first
293
+ payload[:code] = Redact.tag(code)
294
+ end
295
+ result
296
+ rescue => e
297
+ payload[:error] = e.class.name
298
+ # :code stays the GraphQL error code and nothing else — it used
299
+ # to hold a ServerError's status here, so one tag carried two
300
+ # dimensions ("THROTTLED" and 429). The number is :http_status.
301
+ raise
302
+ ensure
303
+ payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
304
+ end
305
+ end
306
+
307
+ # The GraphQL errors a result carries, whatever answered it — a Hash
308
+ # from a transport, a graphql-ruby Result in-process, a fake, or the
309
+ # typed Response a generated cast built. A Hash becomes a GraphQLError
310
+ # so one reading answers for :code whichever arrived; anything else in
311
+ # the list is left as it came, since its presence alone already means
312
+ # the response carried errors. Never raises: an instrumenter that
313
+ # decides which exception a caller sees is worse than a missing tag.
285
314
  def response_errors(result)
315
+ return result.errors if result.is_a?(GraphWeaver::Response)
316
+
286
317
  errors = result.to_h["errors"] if result.respond_to?(:to_h)
287
- errors.is_a?(Array) ? errors : []
318
+ return [] unless errors.is_a?(Array)
319
+
320
+ errors.map { |e| e.is_a?(Hash) ? GraphWeaver::GraphQLError.from_h(e) : e }
288
321
  rescue StandardError
289
322
  []
290
323
  end
@@ -7,9 +7,9 @@ require_relative "codegen"
7
7
  require_relative "internal"
8
8
 
9
9
  module GraphWeaver
10
- # Anything that holds a schema parses against it. That's a Client, an
11
- # InProcess wrapper, a FakeClient, and the test Router — each of which
12
- # already has the two things parsing needs, a schema to check the query
10
+ # Anything that holds a schema parses and checks against it. That's a
11
+ # Client, an InProcess wrapper, a FakeClient, and the test Router — each of
12
+ # which already has the two things parsing needs, a schema to check the query
13
13
  # against and a client for the module to run on:
14
14
  #
15
15
  # DashboardQuery = router.parse("query { me { username } }")
@@ -31,6 +31,35 @@ module GraphWeaver
31
31
  GraphWeaver.parse(schema: T.unsafe(self).schema, query:, name:, client: self)
32
32
  end
33
33
 
34
+ # Does this query validate? The string form of GraphWeaver.check_queries,
35
+ # answering with the same JSON-ready hashes — `message`, `line`, `column`,
36
+ # plus `subgraphs` where a supergraph brands them — so an empty array means
37
+ # it validates:
38
+ #
39
+ # client.check_query("query { viewer { login } }") # => []
40
+ # client.check_query("query { viewer { lgoin } }")
41
+ # # => [{ "message" => "Field 'lgoin' doesn't exist on type 'User'",
42
+ # # "line" => 1, "column" => 17 }]
43
+ #
44
+ # Checked against this object's own schema — what `execute` would run
45
+ # against — so a url client introspects on first use as it always does, and
46
+ # nothing re-introspects the way check_queries defaults to. That is why it
47
+ # lives here: inside an example `GraphWeaver.client` is a fake, an InProcess
48
+ # or the test router, and each of those holds the schema the question is
49
+ # about. An unparseable source is an entry like any other; nothing here
50
+ # raises for a bad query. Shared fragments are inlined from fragments: the
51
+ # same way every other door inlines them.
52
+ def check_query(source, fragments: GraphWeaver.fragments_paths)
53
+ # a dump-backed Client can brand each error with the subgraph it is
54
+ # about; nothing else in the slot has a file behind its schema
55
+ holder = T.unsafe(self)
56
+ dump = holder.schema_source if holder.respond_to?(:schema_source)
57
+ GraphWeaver::Internal::QueryCheck.errors(
58
+ holder.schema, source, GraphWeaver::Codegen.load_fragments(fragments),
59
+ GraphWeaver::Internal::QueryCheck.routing_table_for(dump),
60
+ )
61
+ end
62
+
34
63
  # Parse every query in a directory (subdirectories included) into typed
35
64
  # modules, named like generation would name them — the no-build-step
36
65
  # analog of generate! + load_generated!:
@@ -7,8 +7,6 @@ require_relative "internal"
7
7
  require_relative "internal/test_clients"
8
8
 
9
9
  module GraphWeaver
10
- # Called by generated code — not semver'd for direct use.
11
- #
12
10
  # Runtime for generated query modules: the client plumbing, which is the
13
11
  # one part of a generated module that carries no per-query type
14
12
  # information — every module's copy was identical. `extend
@@ -16,6 +14,13 @@ module GraphWeaver
16
14
  # stay generated, since their sigs are the query's types and those are the
17
15
  # point.
18
16
  #
17
+ # It is also the type every generated module satisfies, so code that takes
18
+ # any of them says `GraphWeaver::QueryModule` and reads `query_string` /
19
+ # `operation_name` with a sig behind each — rather than `const_get(:QUERY)`
20
+ # on a Module, which is what rubocop-sorbet forbids (ConstantsFromStrings,
21
+ # and ForbidTUnsafe for the T.unsafe that gets around it). Those readers
22
+ # and `client` are the supported surface; the rest is generated code's.
23
+ #
19
24
  # Resolution order, per the docs: per call → a test mode's stand-in
20
25
  # (Internal::TestClients) → the client the module's graph names →
21
26
  # `GraphWeaver.client`. A module has no fifth slot you can set: a parsed
@@ -32,6 +37,18 @@ module GraphWeaver
32
37
  @client || default_client
33
38
  end
34
39
 
40
+ # The operation, verbatim — what goes on the wire as `query`.
41
+ sig { returns(String) }
42
+ def query_string
43
+ T.unsafe(self).const_get(:QUERY)
44
+ end
45
+
46
+ # What goes on the wire as `operationName`; nil for an anonymous operation.
47
+ sig { returns(T.nilable(String)) }
48
+ def operation_name
49
+ T.unsafe(self).const_get(:OPERATION_NAME)
50
+ end
51
+
35
52
  private
36
53
 
37
54
  # Bound by GraphWeaver.parse, which is the only caller: a parsed module
@@ -42,32 +59,69 @@ module GraphWeaver
42
59
  attr_writer :client
43
60
 
44
61
  # The one call a generated `execute` makes: resolve the client, run this
45
- # module's own operation, hand the raw response back for from_response to
46
- # wrap. Here rather than emitted, so what has to BRACKET a request — the
47
- # graph label today costs nothing in every generated file, and one
48
- # reading of it covers every module in the app.
62
+ # module's own operation, and cast the raw response with the block the
63
+ # caller hands over under one OPERATION_EVENT, which is the only seam
64
+ # that sees what the caller actually GOT. Here rather than emitted, so
65
+ # what has to bracket a call the graph label, the event — costs nothing
66
+ # in every generated file, and one reading of it covers every module.
67
+ #
68
+ # Without a block it is the request alone, unreported: that is a module
69
+ # generated before the operation event existed, and an event closing :ok
70
+ # over half a call is worse than no event, since the cast it can't see is
71
+ # exactly where a CastError comes from.
49
72
  #
50
73
  # The constants come off the module rather than the caller: a generated
51
74
  # `execute` already knows them, but reading them here is what makes this
52
75
  # the whole of the call instead of three arguments' worth of it.
53
- sig { params(variables: T::Hash[String, T.untyped], client: T.untyped).returns(T.untyped) }
54
- def dispatch(variables, client:)
76
+ sig do
77
+ params(
78
+ variables: T::Hash[String, T.untyped],
79
+ client: T.untyped,
80
+ cast: T.nilable(T.proc.params(raw: T.untyped).returns(T.untyped)),
81
+ ).returns(T.untyped)
82
+ end
83
+ def dispatch(variables, client:, &cast)
55
84
  # A value with no JSON form is a bug in the call, not in the client that
56
85
  # would have carried it — so it is refused here, where every mode passes,
57
86
  # rather than in the transport, which :in_process and :fake never reach.
58
87
  # (A transport asks the same question of a raw query string, which never
59
88
  # comes through here.)
60
89
  GraphWeaver::Internal::Wire.check_variables!(variables)
90
+ target = client_for(client)
61
91
 
62
- mod = T.unsafe(self)
63
92
  # the graph codegen baked in, never one inferred from the client — a
64
93
  # wrong label on a request is worse than no label
65
94
  GraphWeaver::Internal::Log.with_graph(graph_name) do
66
- client_for(client).execute(mod.const_get(:QUERY), variables:,
67
- operation_name: mod.const_get(:OPERATION_NAME))
95
+ next request(target, variables) unless cast
96
+
97
+ GraphWeaver::Internal::Log.instrument_operation(operation_payload(target)) do
98
+ cast.call(request(target, variables))
99
+ end
68
100
  end
69
101
  end
70
102
 
103
+ # This module's own operation, through the client this call resolved to.
104
+ sig { params(target: T.untyped, variables: T::Hash[String, T.untyped]).returns(T.untyped) }
105
+ def request(target, variables)
106
+ target.execute(query_string, variables:, operation_name:)
107
+ end
108
+
109
+ # What one call of this module is, for an APM. :module is the fact this
110
+ # seam has and the request below it doesn't — two graphs can name the same
111
+ # operation, and a trace that is slow wants the file. :client is what the
112
+ # module RESOLVED to, so a test mode's stand-in names itself; the request
113
+ # event underneath names the transport that carried it.
114
+ sig { params(target: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
115
+ def operation_payload(target)
116
+ {
117
+ operation: operation_name,
118
+ module: T.unsafe(self).name,
119
+ graph: graph_name,
120
+ kind: GraphWeaver::Internal::Wire.kind(query_string),
121
+ client: target.class,
122
+ }
123
+ end
124
+
71
125
  # The client one execute runs through: the per-call `client:`, else the
72
126
  # module's, else the app default. Checked here so a wrong one names the
73
127
  # contract and the module, rather than surfacing as a NoMethodError from
@@ -81,7 +135,8 @@ module GraphWeaver
81
135
  # Kernel.raise: this module is extended into another, so sorbet can't
82
136
  # see that its host is an Object
83
137
  Kernel.raise GraphWeaver::Error,
84
- "#{self}: client must respond to #execute(query, variables:), got #{target.class}"
138
+ "#{self}: client must respond to #execute(query, variables:, operation_name:), " \
139
+ "got #{target.class}"
85
140
  end
86
141
 
87
142
  # A module knows which graph it belongs to, and the graph knows how to
@@ -35,7 +35,7 @@ class GraphWeaver::Railtie < Rails::Railtie
35
35
  KEYS = %i[watch].freeze
36
36
 
37
37
  def method_missing(name, *args)
38
- key = name.to_s.delete_suffix("=").delete_suffix("?").delete_suffix("!").to_sym
38
+ key = setting(name)
39
39
  return super if KEYS.include?(key)
40
40
 
41
41
  raise ArgumentError, refusal(key)
@@ -53,11 +53,16 @@ class GraphWeaver::Railtie < Rails::Railtie
53
53
  alias_method :store, :[]=
54
54
 
55
55
  def respond_to_missing?(name, _private = false)
56
- KEYS.include?(name.to_s.delete_suffix("=").delete_suffix("?").delete_suffix("!").to_sym)
56
+ KEYS.include?(setting(name))
57
57
  end
58
58
 
59
59
  private
60
60
 
61
+ # the setting a reader, writer or predicate is about
62
+ def setting(name)
63
+ name.to_s.delete_suffix("=").delete_suffix("?").delete_suffix("!").to_sym
64
+ end
65
+
61
66
  def refusal(key)
62
67
  near = GraphWeaver::Internal::Util.did_you_mean(KEYS.map(&:to_s), key.to_s)
63
68
  fix =