graph_weaver 0.7.5 → 0.7.7

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +43 -43
  3. data/docs/errors.md +7 -3
  4. data/docs/generated_modules.md +66 -16
  5. data/docs/getting_started.md +39 -19
  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 +43 -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 +67 -6
  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 +23 -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/railtie.rb +250 -11
  39. data/lib/graph_weaver/rspec.rb +41 -17
  40. data/lib/graph_weaver/schema_diff.rb +24 -5
  41. data/lib/graph_weaver/schema_loader.rb +22 -9
  42. data/lib/graph_weaver/tasks.rb +71 -44
  43. data/lib/graph_weaver/testing/fake_client.rb +24 -21
  44. data/lib/graph_weaver/testing.rb +27 -7
  45. data/lib/graph_weaver/transport.rb +1 -1
  46. data/lib/graph_weaver/version.rb +1 -1
  47. data/lib/graph_weaver.rb +76 -43
  48. metadata +2 -2
@@ -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