graph_weaver 0.7.5 → 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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/docs/errors.md +7 -3
  4. data/docs/generated_modules.md +10 -6
  5. data/docs/getting_started.md +34 -17
  6. data/docs/logging.md +79 -35
  7. data/docs/migrating.md +11 -4
  8. data/docs/scalars.md +12 -3
  9. data/docs/testing.md +75 -13
  10. data/docs/upgrading.md +25 -3
  11. data/examples/README.md +4 -2
  12. data/examples/github/generate.rb +22 -8
  13. data/examples/github/generated/star_mutation.rb +2 -2
  14. data/examples/github/generated/stargazers_query.rb +2 -2
  15. data/examples/github/generated/starred_query.rb +2 -2
  16. data/examples/github/run.rb +1 -0
  17. data/examples/github/setup.rb +16 -8
  18. data/graph_weaver.gemspec +15 -6
  19. data/lib/generators/graph_weaver/install_generator.rb +20 -2
  20. data/lib/graph_weaver/client.rb +0 -23
  21. data/lib/graph_weaver/codegen/aliases.rb +15 -3
  22. data/lib/graph_weaver/codegen/emit.rb +7 -6
  23. data/lib/graph_weaver/codegen/enum_type.rb +26 -2
  24. data/lib/graph_weaver/codegen/nodes.rb +21 -2
  25. data/lib/graph_weaver/codegen.rb +106 -54
  26. data/lib/graph_weaver/graph.rb +55 -5
  27. data/lib/graph_weaver/in_process.rb +1 -1
  28. data/lib/graph_weaver/input_struct.rb +29 -4
  29. data/lib/graph_weaver/internal/overrides.rb +126 -14
  30. data/lib/graph_weaver/internal/test_clients.rb +29 -7
  31. data/lib/graph_weaver/internal/unused.rb +30 -11
  32. data/lib/graph_weaver/internal/values.rb +17 -5
  33. data/lib/graph_weaver/internal.rb +6 -6
  34. data/lib/graph_weaver/log_subscriber.rb +27 -17
  35. data/lib/graph_weaver/logging.rb +89 -53
  36. data/lib/graph_weaver/parsing.rb +32 -3
  37. data/lib/graph_weaver/query_module.rb +48 -8
  38. data/lib/graph_weaver/rspec.rb +41 -17
  39. data/lib/graph_weaver/schema_diff.rb +24 -5
  40. data/lib/graph_weaver/schema_loader.rb +22 -9
  41. data/lib/graph_weaver/tasks.rb +71 -44
  42. data/lib/graph_weaver/testing/fake_client.rb +24 -21
  43. data/lib/graph_weaver/testing.rb +27 -7
  44. data/lib/graph_weaver/transport.rb +1 -1
  45. data/lib/graph_weaver/version.rb +1 -1
  46. data/lib/graph_weaver.rb +59 -35
  47. metadata +2 -2
@@ -26,10 +26,17 @@ module GraphWeaver
26
26
  # call that names none of them. Caught where the sink line carries the
27
27
  # module's own name, or a local a line above assigned from it.
28
28
  SINKS = /\b(?:to_h|to_json|as_json|serialize|deconstruct_keys)\b|render\s+json:/
29
- # `result = PersonQuery.execute!(...)` — the local a response lands in.
29
+ # `result = PersonQuery.execute!(...)` — the name a response lands in.
30
30
  # Following one is what lets the sink be on the NEXT line, which is how
31
- # anyone actually writes a controller. Excludes == and =~.
32
- ASSIGN = /\b([a-z_]\w*)\s*=[^=~]/
31
+ # anyone actually writes a controller. The `@` is part of the capture:
32
+ # it is what says the name outlives the method. Excludes == and =~.
33
+ ASSIGN = /(@?\b[a-z_]\w*)\s*=[^=~]/
34
+ # Where a plain local stops standing for the module it was assigned
35
+ # from: the next method is a new scope, and a block param there that
36
+ # happens to share the name holds someone else's value. An ivar crosses
37
+ # it — `before_action` loading `@result` for the action to render is
38
+ # the shape every Rails controller has.
39
+ SCOPE = /^[ \t]*def\s/
33
40
  # A graphql-ruby TYPE class NAMES every field the server offers, as
34
41
  # `field :sku` and as a resolver method — which is the server answering,
35
42
  # not this app reading a prop back. Without this an app that serves the
@@ -189,6 +196,7 @@ module GraphWeaver
189
196
  # `result = Q.execute!(...)`, then `render json: result.person`.
190
197
  locals = Hash.new { |hash, key| hash[key] = [] }
191
198
  body.each_line.with_index(1) do |line, number|
199
+ locals.each_value { |names| names.select! { |n| n.start_with?("@") } } if SCOPE.match?(line)
192
200
  candidates.each do |name, base|
193
201
  locals[name] << Regexp.last_match(1) if line.include?(base) && ASSIGN.match(line)
194
202
  end
@@ -197,7 +205,7 @@ module GraphWeaver
197
205
  candidates.each do |name, base|
198
206
  # the line naming the module is the better evidence; the local
199
207
  # is what it falls back to
200
- via = locals[name].find { |local| line.match?(/\b#{Regexp.escape(local)}\b/) } \
208
+ via = locals[name].find { |local| line.match?(/(?<![\w@])#{Regexp.escape(local)}\b/) } \
201
209
  unless line.include?(base)
202
210
  next unless via || line.include?(base)
203
211
 
@@ -221,20 +229,31 @@ module GraphWeaver
221
229
  result = Object.const_get(name)
222
230
  next [] unless result.const_defined?(:Result, false)
223
231
 
224
- words = source.scan(/[A-Za-z_]\w*/).uniq
232
+ keys = response_keys(GraphQL.parse(source).definitions)
225
233
  props(result.const_get(:Result, false))
226
- .map { |struct, prop| Selection.new(path, name, struct, prop, wire_word(words, prop)) }
234
+ .map { |struct, prop| Selection.new(path, name, struct, prop, wire_word(keys, prop)) }
227
235
  end
228
236
  end
229
237
  end
230
238
 
231
239
  # How the query spells a prop, when that isn't the prop's own name — a
232
240
  # camelCase field, an alias, a reserved rename. Read back off the query
233
- # text rather than derived from the prop, since no rule inverts an
234
- # alias; nil when the query spells it the same way, which is most of
235
- # the time.
236
- def wire_word(words, prop)
237
- words.find { |word| word != prop.to_s && GraphWeaver::Codegen.prop_name(word) == prop.to_s }
241
+ # rather than derived from the prop, since no rule inverts an alias; nil
242
+ # when the query spells it the same way, which is most of the time.
243
+ def wire_word(keys, prop)
244
+ keys.find { |key| key != prop.to_s && GraphWeaver::Codegen.prop_name(key) == prop.to_s }
245
+ end
246
+
247
+ # Every response key the document asks for. The coordinate is the
248
+ # selection to go and delete, so it can only be one of these: scanning
249
+ # the file's text let `$id: ID!` — or a comment — name a field the query
250
+ # never selected.
251
+ def response_keys(nodes, found = [])
252
+ nodes.each do |node|
253
+ found << (node.alias || node.name) if node.is_a?(GraphQL::Language::Nodes::Field)
254
+ response_keys(node.selections, found) if node.respond_to?(:selections)
255
+ end
256
+ found
238
257
  end
239
258
 
240
259
  # Nested structs are nested constants, so the props of a whole response
@@ -88,7 +88,12 @@ class GraphWeaver::Internal::Values
88
88
  # for the next, and only a caller holding the graph can say which. Left
89
89
  # unsaid they are read back off the schema, which is the answer for every
90
90
  # app with one graph running a live class.
91
- def initialize(seed: nil, values: nil, pins: nil, schema: nil, registry: nil)
91
+ # pin_advice: how to pin, in the words of the door the caller came in by —
92
+ # a callable taking the scalar's name. Left unsaid it is the three doors
93
+ # onto a fake, which is where this is reached from unless something says
94
+ # otherwise (check_scalars! runs outside all three).
95
+ def initialize(seed: nil, values: nil, pins: nil, schema: nil, registry: nil, pin_advice: nil)
96
+ @pin_advice = pin_advice
92
97
  @registry = registry || GraphWeaver::Internal::Util.registry_for(schema)
93
98
  @rng = Random.new(seed || GraphWeaver::Testing.config.seed || Random.new_seed)
94
99
  @pins = (pins || GraphWeaver::Testing.config.overrides).transform_keys(&:to_s)
@@ -202,12 +207,19 @@ class GraphWeaver::Internal::Values
202
207
  # for — `Money.parse` accepts what its author decided it accepts — and
203
208
  # guessing hands the generated cast a placeholder, which fails deep inside
204
209
  # from_h blaming the codec.
210
+ #
211
+ # Each door onto a fake spells a pin differently and this can't know which
212
+ # you came in by, so the advice names all three — unless the caller said,
213
+ # in which case it is the one that works there.
205
214
  def unfakeable!(type_name, field_name, registered, coordinate, at)
215
+ type = type_name.inspect
216
+ advice = @pin_advice&.call(type_name) ||
217
+ "Pin the type (#{type}) or just this field (#{(coordinate || field_name).inspect}), " \
218
+ "wherever the fake is built — graphql_fake(#{type} => ...) in an rspec example, " \
219
+ "FakeClient.new(#{type} => ...) outside one, or GraphWeaver::Testing.config.overrides " \
220
+ "for the whole suite."
206
221
  raise GraphWeaver::Error, "can't fabricate a #{type_name} #{at ? "at #{at}" : "for #{field_name.inspect}"}: " \
207
- "it deserializes into #{registered.type}, and only you know what wire value that accepts. " \
208
- "Pin the type — overrides: { #{type_name.inspect} => ... } — or this one field: " \
209
- "overrides: { #{(coordinate || field_name).inspect} => ... }. Suite-wide, that's " \
210
- "GraphWeaver::Testing.config.overrides."
222
+ "it deserializes into #{registered.type}, and only you know what wire value that accepts. #{advice}"
211
223
  end
212
224
 
213
225
  # :faker is an explicit ask — fail loudly when the gem is missing; auto
@@ -243,14 +243,14 @@ module GraphWeaver
243
243
  "or cache one: GraphWeaver.new(url, cache: true).schema"
244
244
  end
245
245
 
246
- # The graphql-ruby schema class the app default executes against,
247
- # when it runs in-process — a Client wrapping one, or the class in
248
- # the slot bare. nil for every network client. Not memoized: in dev
249
- # the class object is replaced on reload.
250
- 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)
251
252
  # through #transport, not #schema: a url client's #schema
252
253
  # introspects, so asking it would answer over the network
253
- client = GraphWeaver.client
254
254
  target = client.is_a?(Client) ? client.transport : client
255
255
  target = target.schema if target.is_a?(InProcess)
256
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.
@@ -137,14 +140,24 @@ module GraphWeaver
137
140
 
138
141
  self.filter_parameters = DEFAULT_FILTER_PARAMETERS
139
142
 
140
- # The one instrumentation event: a single GraphQL request, start to
141
- # parsed response, whichever client slot served it. `<event>.<namespace>`
142
- # is how every notification in this ecosystem is spelled
143
- # (sql.active_record, execute_multiplex.graphql) — it's what
144
- # ActiveSupport::LogSubscriber.attach_to and an APM's namespace routing
145
- # key on, so a backwards name made both of them a puzzle.
143
+ # One GraphQL REQUEST, start to parsed response, whichever client slot
144
+ # served it — one attempt, so a call a Retry made three goes at is three
145
+ # of these. `<event>.<namespace>` is how every notification in this
146
+ # ecosystem is spelled (sql.active_record, execute_multiplex.graphql) —
147
+ # it's what ActiveSupport::LogSubscriber.attach_to and an APM's namespace
148
+ # routing key on, so a backwards name made both of them a puzzle.
146
149
  EXECUTE_EVENT = "execute.graph_weaver"
147
150
 
151
+ # One CALL of a generated module's execute/execute!: the request it makes,
152
+ # every retry and backoff beneath it, and the cast into the typed structs.
153
+ # One or more EXECUTE_EVENTs nest inside it.
154
+ #
155
+ # It says what the CALLER got, which a request can't: a CastError is raised
156
+ # after the response is back, so the request closed :ok while the app saw a
157
+ # failure. And it fires at the module seam, which every client slot passes
158
+ # through — a fake, the test router and a cassette report here too.
159
+ OPERATION_EVENT = "operation.graph_weaver"
160
+
148
161
  module Internal
149
162
  # The emitting half of the narration the three accessors above
150
163
  # configure. Setting a logger is API; writing to it is not, and the
@@ -174,48 +187,29 @@ module GraphWeaver
174
187
  result
175
188
  end
176
189
 
177
- # Wrap the block in the instrumenter, if one is set. The caller
178
- # supplies what only it knows (:url, :schema, :client); this fills
179
- # in the half every path shares how it ended, how long it took,
180
- # what a Retry had already spent so one subscriber reads one
181
- # shape whichever client slot served the request.
182
- def instrument(event, payload)
183
- hook = GraphWeaver.instrumenter
184
- return yield unless hook
190
+ # Wrap one REQUEST in the instrumenter, if one is set. The caller
191
+ # supplies what only it knows (:url, :schema, :client); this adds the
192
+ # attempt facts the graph in scope, what a Retry had already spent
193
+ # so one subscriber reads one shape whichever client slot served it.
194
+ def instrument_request(payload, &block)
195
+ return yield unless GraphWeaver.instrumenter
185
196
 
186
- start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
187
197
  retries = Thread.current[RETRIES]
188
198
  payload[:retries] = retries if retries
189
199
  payload[:graph] = Thread.current[GRAPH]
190
- # pessimistic, so :status is set even for what a rescue can't
191
- # see — an Interrupt, a killed thread — and never silently absent
192
- payload[:status] = :failed
193
200
 
194
201
  # One dispatch labels one request. Whatever THIS request reaches —
195
202
  # a resolver serving it that calls out — is a request of its own,
196
203
  # and the caller's graph would be a wrong label on it.
197
- with_graph(nil) do
198
- hook.call(event, payload) do
199
- result = yield
200
- errors = response_errors(result)
201
- if errors.empty?
202
- payload[:status] = :ok
203
- else
204
- payload[:status] = :errors
205
- code = errors.grep(Hash).filter_map { |e| GraphWeaver::GraphQLError.from_h(e).code }.first
206
- payload[:code] = Redact.tag(code)
207
- end
208
- result
209
- rescue => e
210
- payload[:error] = e.class.name
211
- # :code stays the GraphQL error code and nothing else — it used
212
- # to hold a ServerError's status here, so one tag carried two
213
- # dimensions ("THROTTLED" and 429). The number is :http_status.
214
- raise
215
- ensure
216
- payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
217
- end
218
- end
204
+ with_graph(nil) { measure(GraphWeaver::EXECUTE_EVENT, payload, &block) }
205
+ end
206
+
207
+ # Wrap one CALL of a generated module: the request under it and the
208
+ # cast that follows. The caller names :graph here rather than reading
209
+ # it out of the fiber-local, because it IS the module that set it —
210
+ # and it stays set, so the request below wears the same label.
211
+ def instrument_operation(payload, &block)
212
+ measure(GraphWeaver::OPERATION_EVENT, payload, &block)
219
213
  end
220
214
 
221
215
  # What a Retry has already spent, read by the attempt it is about
@@ -275,13 +269,55 @@ module GraphWeaver
275
269
  end
276
270
  end
277
271
 
278
- # The GraphQL errors a response carries, whatever answered it a
279
- # Hash from a transport, a graphql-ruby Result in-process, a fake.
280
- # Never raises: an instrumenter that decides which exception a
281
- # caller sees is worse than a missing tag.
272
+ # The half both events share: run the block inside the hook, and
273
+ # record how it ended, the code an alert groups by, and how long it
274
+ # took every one of them before the hook's block returns, so a
275
+ # subscriber reads a complete payload.
276
+ def measure(event, payload)
277
+ hook = GraphWeaver.instrumenter
278
+ return yield unless hook
279
+
280
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
281
+ # pessimistic, so :status is set even for what a rescue can't
282
+ # see — an Interrupt, a killed thread — and never silently absent
283
+ payload[:status] = :failed
284
+
285
+ hook.call(event, payload) do
286
+ result = yield
287
+ errors = response_errors(result)
288
+ if errors.empty?
289
+ payload[:status] = :ok
290
+ else
291
+ payload[:status] = :errors
292
+ code = errors.grep(GraphWeaver::GraphQLError).filter_map(&:code).first
293
+ payload[:code] = Redact.tag(code)
294
+ end
295
+ result
296
+ rescue => e
297
+ payload[:error] = e.class.name
298
+ # :code stays the GraphQL error code and nothing else — it used
299
+ # to hold a ServerError's status here, so one tag carried two
300
+ # dimensions ("THROTTLED" and 429). The number is :http_status.
301
+ raise
302
+ ensure
303
+ payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
304
+ end
305
+ end
306
+
307
+ # The GraphQL errors a result carries, whatever answered it — a Hash
308
+ # from a transport, a graphql-ruby Result in-process, a fake, or the
309
+ # typed Response a generated cast built. A Hash becomes a GraphQLError
310
+ # so one reading answers for :code whichever arrived; anything else in
311
+ # the list is left as it came, since its presence alone already means
312
+ # the response carried errors. Never raises: an instrumenter that
313
+ # decides which exception a caller sees is worse than a missing tag.
282
314
  def response_errors(result)
315
+ return result.errors if result.is_a?(GraphWeaver::Response)
316
+
283
317
  errors = result.to_h["errors"] if result.respond_to?(:to_h)
284
- errors.is_a?(Array) ? errors : []
318
+ return [] unless errors.is_a?(Array)
319
+
320
+ errors.map { |e| e.is_a?(Hash) ? GraphWeaver::GraphQLError.from_h(e) : e }
285
321
  rescue StandardError
286
322
  []
287
323
  end
@@ -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!:
@@ -59,30 +59,69 @@ module GraphWeaver
59
59
  attr_writer :client
60
60
 
61
61
  # The one call a generated `execute` makes: resolve the client, run this
62
- # module's own operation, hand the raw response back for from_response to
63
- # wrap. Here rather than emitted, so what has to BRACKET a request — the
64
- # graph label today costs nothing in every generated file, and one
65
- # 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.
66
72
  #
67
73
  # The constants come off the module rather than the caller: a generated
68
74
  # `execute` already knows them, but reading them here is what makes this
69
75
  # the whole of the call instead of three arguments' worth of it.
70
- sig { params(variables: T::Hash[String, T.untyped], client: T.untyped).returns(T.untyped) }
71
- 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)
72
84
  # A value with no JSON form is a bug in the call, not in the client that
73
85
  # would have carried it — so it is refused here, where every mode passes,
74
86
  # rather than in the transport, which :in_process and :fake never reach.
75
87
  # (A transport asks the same question of a raw query string, which never
76
88
  # comes through here.)
77
89
  GraphWeaver::Internal::Wire.check_variables!(variables)
90
+ target = client_for(client)
78
91
 
79
92
  # the graph codegen baked in, never one inferred from the client — a
80
93
  # wrong label on a request is worse than no label
81
94
  GraphWeaver::Internal::Log.with_graph(graph_name) do
82
- client_for(client).execute(query_string, variables:, 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
83
100
  end
84
101
  end
85
102
 
103
+ # This module's own operation, through the client this call resolved to.
104
+ sig { params(target: T.untyped, variables: T::Hash[String, T.untyped]).returns(T.untyped) }
105
+ def request(target, variables)
106
+ target.execute(query_string, variables:, operation_name:)
107
+ end
108
+
109
+ # What one call of this module is, for an APM. :module is the fact this
110
+ # seam has and the request below it doesn't — two graphs can name the same
111
+ # operation, and a trace that is slow wants the file. :client is what the
112
+ # module RESOLVED to, so a test mode's stand-in names itself; the request
113
+ # event underneath names the transport that carried it.
114
+ sig { params(target: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
115
+ def operation_payload(target)
116
+ {
117
+ operation: operation_name,
118
+ module: T.unsafe(self).name,
119
+ graph: graph_name,
120
+ kind: GraphWeaver::Internal::Wire.kind(query_string),
121
+ client: target.class,
122
+ }
123
+ end
124
+
86
125
  # The client one execute runs through: the per-call `client:`, else the
87
126
  # module's, else the app default. Checked here so a wrong one names the
88
127
  # contract and the module, rather than surfacing as a NoMethodError from
@@ -96,7 +135,8 @@ module GraphWeaver
96
135
  # Kernel.raise: this module is extended into another, so sorbet can't
97
136
  # see that its host is an Object
98
137
  Kernel.raise GraphWeaver::Error,
99
- "#{self}: client must respond to #execute(query, variables:), got #{target.class}"
138
+ "#{self}: client must respond to #execute(query, variables:, operation_name:), " \
139
+ "got #{target.class}"
100
140
  end
101
141
 
102
142
  # A module knows which graph it belongs to, and the graph knows how to
@@ -161,7 +161,9 @@ module GraphWeaver
161
161
  # suite's WebMock setup is touched.
162
162
  def self.serve!
163
163
  webmock!
164
- wire_targets.map do |url, graph|
164
+ targets, above = wire_targets
165
+ above.each { |graph| disclose_above!(graph) }
166
+ targets.map do |url, graph|
165
167
  # built here, so a graph with nothing to serve refuses before the
166
168
  # example runs rather than from inside its first request
167
169
  disclose!(GraphWeaver::Internal::TestClients.standin(graph), url, graph)
@@ -210,6 +212,17 @@ module GraphWeaver
210
212
  end
211
213
  end
212
214
 
215
+ # Say which graph ran above the wire. Its stand-in is built when one of
216
+ # its modules first runs, not here — a graph the example never touches
217
+ # must not refuse it — so this names the graph rather than what is
218
+ # behind it.
219
+ def self.disclose_above!(graph)
220
+ GraphWeaver::Internal::Log.log(:info) do
221
+ ":wire has no endpoint for #{graph.name ? "graph #{graph.name.inspect}" : "this app"} — " \
222
+ "its client posts to none, so its modules run above the wire, as #{TAG}: :in_process would"
223
+ end
224
+ end
225
+
213
226
  # What the stand-in IS, read off the object rather than re-deciding —
214
227
  # one answer, and it can't drift from what was built. A fake of a dump
215
228
  # has no name to give: the dump loads as an anonymous class, and
@@ -254,21 +267,24 @@ module GraphWeaver
254
267
  # inside the cleanup.
255
268
  def self.unserve!(stub) = WebMock::StubRegistry.instance.request_stubs.delete(stub)
256
269
 
257
- # Every endpoint an example's modules can post to, each with the graph
258
- # whose resolvers belong behind it: the client each graph names, or
259
- # GraphWeaver.client for a graph naming none. One graph per endpoint —
260
- # an app whose graphs all name clients needs no app default at all.
270
+ # What :wire does with each graph, in two lists: the endpoints to stub,
271
+ # each with the graph whose resolvers belong behind it, and the graphs
272
+ # there is no endpoint for. One graph per endpoint — an app whose graphs
273
+ # all name clients needs no app default at all.
274
+ #
275
+ # A graph whose client posts to no url has no wire to be served at, so
276
+ # it runs above one instead of refusing the example — including the
277
+ # examples that never touch it. An example where NO graph posts anywhere
278
+ # is refused, since a :wire that serves nothing tests no transport.
261
279
  def self.wire_targets
262
- targets = GraphWeaver.graphs.filter_map do |graph|
263
- client = graph.client || GraphWeaver.client
264
- [endpoint!(client, graph), graph] if client
265
- end
280
+ targets, above = GraphWeaver.graphs.map { |graph| [graph.client_url, graph] }.partition(&:first)
266
281
  refuse_shared_endpoint!(targets)
267
- return targets if targets.any?
282
+ return [targets, above.map(&:last)] if targets.any?
268
283
 
269
- # nothing bakes a client and the app has none: the endpoint refusal
270
- # names the empty slot, which is the thing to fix
271
- endpoint!(GraphWeaver.client)
284
+ # the endpoint refusal names the client that posts to none, which is
285
+ # the thing to fix
286
+ graph = GraphWeaver.graphs.first
287
+ endpoint!(graph&.client || GraphWeaver.client, graph)
272
288
  end
273
289
 
274
290
  # One stub per url, so two graphs on one endpoint used to mean the
@@ -343,7 +359,8 @@ module GraphWeaver
343
359
  end
344
360
 
345
361
  private_class_method :wire_targets, :refuse_shared_endpoint!, :whose_client,
346
- :webmock!, :webmock_enabled?, :disclose!, :served, :unnamed_schemas, :loaded_schemas
362
+ :webmock!, :webmock_enabled?, :disclose!, :disclose_above!, :served, :unnamed_schemas,
363
+ :loaded_schemas
347
364
 
348
365
  # Included into every example group, so graphql_context is there
349
366
  # whether or not this example took a client from the hook.
@@ -570,10 +587,17 @@ module GraphWeaver
570
587
  raise GraphWeaver::Error, "graphql_context needs resolvers to receive it, and a " \
571
588
  "#{TAG}: :fake example runs against fabricated data — tag it #{TAG}: :in_process or " \
572
589
  "#{TAG}: :router (or pin the data itself: " \
573
- "graphql_fake(overrides: { \"Person.name\" => \"Ada\" }))"
590
+ "graphql_fake(\"Person.name\" => \"Ada\"))"
574
591
  else
575
- raise GraphWeaver::Error, "graphql_context needs an example running against your " \
576
- "resolvers tag it #{TAG}: :in_process or #{TAG}: :router"
592
+ # :live, which is what an untagged example is. The app's own client
593
+ # stays in the slot holding the context it was built with, so there
594
+ # is nothing here to merge onto — and saying "you need resolvers"
595
+ # reads as false to an app whose own client is an InProcess, which
596
+ # is running them
597
+ raise GraphWeaver::Error, "graphql_context says what THIS example's resolvers see, " \
598
+ "and #{TAG}: :live leaves your app's own client exactly as it is — carrying the " \
599
+ "context it was built with. Tag the example #{TAG}: :in_process or #{TAG}: :router, " \
600
+ "which build a client per example."
577
601
  end
578
602
  end
579
603