graph_weaver 0.6.0 → 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 +1470 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +21 -7
  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 +498 -105
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +32 -4
  15. data/docs/scalars.md +286 -57
  16. data/docs/testing.md +458 -59
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +330 -5
  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 +218 -59
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +408 -206
  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 +43 -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 +490 -116
  70. metadata +56 -1
@@ -19,36 +19,217 @@
19
19
  # next request, the way editing a route or a locale takes effect.
20
20
  class GraphWeaver::Railtie < Rails::Railtie
21
21
  # config.graph_weaver.watch — false to never regenerate during a request.
22
- # Default: development only.
23
- config.graph_weaver = ActiveSupport::OrderedOptions.new
22
+ # Default: development only. Every other setting is a top-level one, and an
23
+ # OrderedOptions would take `config.graph_weaver.queries_paths = ...` without
24
+ # a word and do nothing with it, so this refuses what it doesn't read.
25
+ class Options < ActiveSupport::OrderedOptions
26
+ KEYS = %i[watch].freeze
27
+
28
+ def method_missing(name, *args)
29
+ key = name.to_s.delete_suffix("=").delete_suffix("?").delete_suffix("!").to_sym
30
+ return super if KEYS.include?(key)
31
+
32
+ raise ArgumentError, refusal(key)
33
+ end
34
+
35
+ # the other door: `config.graph_weaver[:queries_paths] = ...` reaches
36
+ # Hash#[]= without passing method_missing, and was the exact silent no-op
37
+ # the refusal exists to prevent. store is Hash's own synonym for it, so it
38
+ # stays one.
39
+ def []=(key, value)
40
+ raise ArgumentError, refusal(key) unless KEYS.include?(key.to_sym)
41
+
42
+ super
43
+ end
44
+ alias_method :store, :[]=
45
+
46
+ def respond_to_missing?(name, _private = false)
47
+ KEYS.include?(name.to_s.delete_suffix("=").delete_suffix("?").delete_suffix("!").to_sym)
48
+ end
49
+
50
+ private
51
+
52
+ def refusal(key)
53
+ near = GraphWeaver::Internal::Util.did_you_mean(KEYS.map(&:to_s), key.to_s)
54
+ fix =
55
+ if GraphWeaver.respond_to?(:"#{key}=") then " — GraphWeaver.#{key} = ... is the setting you want"
56
+ elsif near then " (did you mean #{near}?)"
57
+ end
58
+ "config.graph_weaver takes #{KEYS.join(", ")}, not #{key}#{fix}"
59
+ end
60
+ end
61
+
62
+ config.graph_weaver = Options.new
24
63
 
25
64
  class << self
26
65
  # The file watcher, so the to_prepare block below can ask it whether a
27
66
  # query changed. nil when not watching.
28
67
  attr_accessor :watcher
68
+
69
+ # What ignore_generated actually hid, resolved. Zeitwerk only reads its
70
+ # ignore list at setup, so anything that arrives later isn't hidden by
71
+ # calling ignore again — check_generated_ignored! refuses instead.
72
+ attr_accessor :ignored_dirs
29
73
  end
30
74
 
31
75
  rake_tasks do
32
76
  require "graph_weaver/tasks"
33
77
  end
34
78
 
79
+ # The two auto-wires an app gets for free, and the only two it can turn off
80
+ # by assigning nil. They are declared FIRST, and `before:
81
+ # :load_config_initializers`, and both of those are load-bearing:
82
+ #
83
+ # Rails gives an initializer an implicit `after:` of the previous one in the
84
+ # same railtie (Initializable#initializer: `opts[:after] ||=
85
+ # initializers.last&.name`). Declared after ignore_generated, which is
86
+ # `after: :load_config_initializers`, these two inherited that position —
87
+ # they ran AFTER config/initializers, so the `if nil?` fallback overwrote an
88
+ # app that had just said nil and the documented PII opt-out did nothing,
89
+ # silently, while queries and variables kept reaching a debug Rails.logger.
90
+ # Adding the `before:` without moving them is a TSort::Cyclic at boot, since
91
+ # the implicit `after:` would then point through ignore_generated.
92
+ #
93
+ # Rails.logger, unless the app already chose one (set
94
+ # GraphWeaver.logger = nil in an initializer to silence)
95
+ initializer "graph_weaver.logger", before: :load_config_initializers do
96
+ GraphWeaver.logger = Rails.logger if GraphWeaver.logger.nil?
97
+ end
98
+
99
+ # An APM sees every GraphQL call without the app configuring anything:
100
+ # the ActiveSupport::Notifications adapter from docs/logging.md, plus the
101
+ # LogSubscriber that turns its event into one line. Measured at ~4.5µs per
102
+ # execution all told (0.13µs of that ActiveSupport::Notifications itself
103
+ # with nothing subscribed; the rest is its Event machinery) — 0.05% of a
104
+ # 10ms round trip, so there is nothing to weigh.
105
+ #
106
+ # An instrumenter the app set is never replaced: assigned before this (in
107
+ # config/application.rb) the nil check leaves it, and config/initializers now
108
+ # genuinely runs later, so one assigned there wins on its own — including
109
+ # `GraphWeaver.instrumenter = nil` to opt out.
110
+ initializer "graph_weaver.instrumentation", before: :load_config_initializers do
111
+ next unless defined?(ActiveSupport::Notifications)
112
+
113
+ if GraphWeaver.instrumenter.nil?
114
+ GraphWeaver.instrumenter = lambda do |event, payload, &block|
115
+ ActiveSupport::Notifications.instrument(event, payload, &block)
116
+ end
117
+ end
118
+
119
+ # ActiveSupport::LogSubscriber is one of ActiveSupport's own eager
120
+ # autoloads, so naming it is enough — no require of theirs needed
121
+ require "graph_weaver/log_subscriber"
122
+ # idempotent — Subscriber.add_event_subscriber skips a pattern it already has
123
+ GraphWeaver::LogSubscriber.attach_to :graph_weaver
124
+ end
125
+
35
126
  # generated/person_query.rb defines ::PersonQuery, but Zeitwerk infers
36
127
  # Generated::PersonQuery from the path — and app/graphql/generated is
37
128
  # inside an autoload root by default, so eager loading raised
38
129
  # "uninitialized constant Generated::PersonQuery" in production while
39
130
  # development (lazy) was fine. load_generated! below requires them.
40
- initializer "graph_weaver.ignore_generated", before: :setup_main_autoloader do
41
- Rails.autoloaders.each do |loader|
42
- # patterns, not paths generated_paths may be globs, and Zeitwerk
43
- # expands its own at setup (which is what this runs before)
44
- GraphWeaver.generated_paths.each { |path| loader.ignore(GraphWeaver::Internal::Util.resolve(path)) }
131
+ #
132
+ # after: :load_config_initializers as well as before Zeitwerk's setup — a
133
+ # graph's output: is only known once the app has declared its graphs, and
134
+ # with only the `before:` constraint this ran ~20 initializers too early, so
135
+ # an output outside the conventional glob was eager loaded on top of
136
+ # load_generated! and died on a redefined enum.
137
+ initializer "graph_weaver.ignore_generated",
138
+ after: :load_config_initializers, before: :setup_main_autoloader do
139
+ # patterns, not paths — generated_paths may be globs, and Zeitwerk
140
+ # expands its own at setup (which is what this runs before)
141
+ dirs = GraphWeaver::Internal::Util.generated_dirs.map { GraphWeaver::Railtie.autoload_path(_1) }
142
+ GraphWeaver::Railtie.check_autoload_once!(dirs)
143
+ GraphWeaver::Railtie.ignored_dirs = dirs
144
+ Rails.autoloaders.each { |loader| dirs.each { |path| loader.ignore(path) } }
145
+ end
146
+
147
+ # The `once` autoloader is set up in bootstrap, and Zeitwerk reads its ignore
148
+ # list only at setup — so the `loader.ignore` above, which runs after
149
+ # config/initializers, hides nothing from it however the output is spelled.
150
+ # The generic advice ("name it in GraphWeaver.generated_paths from
151
+ # config/initializers") produced byte-identical output for this one, so it
152
+ # gets its own refusal, naming the place that is still early enough.
153
+ def self.check_autoload_once!(dirs)
154
+ return unless Rails.respond_to?(:autoloaders) && Rails.autoloaders.respond_to?(:once)
155
+
156
+ once = Rails.autoloaders.once
157
+ dirs.each do |dir|
158
+ next unless autoloaded?(once, dir)
159
+
160
+ subject, short = describe_output(dir)
161
+ raise GraphWeaver::Error,
162
+ "#{subject} is under config.autoload_once_paths, which Rails sets the `once` autoloader up on " \
163
+ "before config/initializers run — so nothing GraphWeaver can do from there hides it, and its " \
164
+ "modules can't load. Hide it in config/application.rb, which is still early enough: " \
165
+ "Rails.autoloaders.once.ignore(Rails.root.join(#{short.inspect})) — or generate somewhere that " \
166
+ "is not an autoload-once path."
45
167
  end
46
168
  end
47
169
 
48
- # Rails.logger, unless the app already chose one (set
49
- # GraphWeaver.logger = nil in an initializer to silence)
50
- initializer "graph_weaver.logger" do
51
- GraphWeaver.logger = Rails.logger if GraphWeaver.logger.nil?
170
+ # A generated path as ZEITWERK sees it: resolved, and with symlinks followed,
171
+ # because Zeitwerk walks real directories. Ignoring a symlinked output hid it
172
+ # under a name Zeitwerk never visits, and the refusal below compared that same
173
+ # name against real autoload roots and so never fired — including for an
174
+ # absolute output through a symlinked ancestor, the Capistrano current/ shape.
175
+ # Only this seam needs it: everywhere else a path stays the setting expanded,
176
+ # so what the gem reports is what you wrote.
177
+ def self.autoload_path(path)
178
+ resolved = GraphWeaver::Internal::Util.resolve(path)
179
+ File.exist?(resolved) ? File.realpath(resolved) : resolved
180
+ end
181
+
182
+ # Whether this loader would actually try to load `dir`: one of its roots
183
+ # contains it and its own ignore list doesn't cover it. Asking only about the
184
+ # roots refused an app that had called Rails.autoloaders.main.ignore(dir)
185
+ # itself, and told it the directory couldn't be hidden — when it already was.
186
+ #
187
+ # Zeitwerk answers that question under two names: `ignores?` was public until
188
+ # 2.6.1 made it internal, which publishes it as `__ignores?`. Neither present
189
+ # (some other loader in the slot) falls back to refusing, which is what this
190
+ # did for everyone before.
191
+ IGNORES = %i[__ignores? ignores?].freeze
192
+
193
+ def self.autoloaded?(loader, dir)
194
+ return false unless loader.dirs.any? { |root| dir.start_with?("#{root}/") }
195
+
196
+ asked = IGNORES.find { |name| loader.respond_to?(name) }
197
+ asked.nil? || !loader.public_send(asked, dir)
198
+ end
199
+
200
+ # How a refusal names a generated directory: what writes it, and the path the
201
+ # way the graph itself spells it — not the symlink target autoload_path
202
+ # resolved to, since the advice has to name something the reader can find in
203
+ # their own config.
204
+ def self.describe_output(dir)
205
+ graph = GraphWeaver.graphs.find { autoload_path(_1.output) == dir }
206
+ short = GraphWeaver::Internal::Util.relative(GraphWeaver::Internal::Util.resolve(graph&.output || dir))
207
+ ["#{graph ? "graph :#{graph.name}'s output" : "generated path"} #{short}", short]
208
+ end
209
+
210
+ # A graph declared from to_prepare — what the docs say to do when its block
211
+ # names an autoloaded constant — is declared after Zeitwerk is set up, and
212
+ # Zeitwerk reads its ignore list only then. So an output that arrives that
213
+ # late can't be hidden: its files load as ordinary autoloads and raise on the
214
+ # constant they don't define, in a Zeitwerk error that blames a dropped
215
+ # extend_type. Refuse, and name what actually happened.
216
+ def self.check_generated_ignored!
217
+ # no autoloaders, no Zeitwerk, nothing to refuse
218
+ return unless Rails.respond_to?(:autoloaders)
219
+
220
+ late = GraphWeaver::Internal::Util.generated_dirs.map { autoload_path(_1) } - Array(ignored_dirs)
221
+ return if late.empty?
222
+
223
+ late.each do |dir|
224
+ next unless Rails.autoloaders.any? { |loader| autoloaded?(loader, dir) }
225
+
226
+ subject, short = describe_output(dir)
227
+ raise GraphWeaver::Error,
228
+ "#{subject} was declared after Rails " \
229
+ "set Zeitwerk up on it, so it can't be hidden from autoloading and its modules can't load. Declare " \
230
+ "the graph in config/initializers (schema -> { MyApp::Schema } resolves an autoloaded class when " \
231
+ "generation asks), or name #{short.inspect} in GraphWeaver.generated_paths there."
232
+ end
52
233
  end
53
234
 
54
235
  # The app already declared what is sensitive, so variables logged at debug
@@ -71,8 +252,25 @@ class GraphWeaver::Railtie < Rails::Railtie
71
252
  #
72
253
  # after: :load_config_initializers — that's where an app moves
73
254
  # queries_paths, and the finisher that reads app.reloaders runs later still.
255
+ #
256
+ # to_prepare, not the initializer itself: a graph declared from one of those
257
+ # (what the docs say to do when its block names an autoloaded constant) isn't
258
+ # declared until every initializer has run, and a watcher built before it
259
+ # watched the default queries_paths — an edit to that graph's .graphql
260
+ # silently never regenerated. to_prepare blocks run in registration order and
261
+ # an app registers its own during :load_config_initializers, which this is
262
+ # `after:`, so every graph is in by the time this runs; app.reloaders is read
263
+ # per request, so joining it this late still counts. Once, though: a dev
264
+ # reload re-runs to_prepare, and a second watcher is a second reloader over
265
+ # the same files.
74
266
  initializer "graph_weaver.watch", after: :load_config_initializers do |app|
75
- GraphWeaver::Railtie.watch!(app)
267
+ watched = false
268
+ app.config.to_prepare do
269
+ next if watched
270
+
271
+ watched = true
272
+ GraphWeaver::Railtie.watch!(app)
273
+ end
76
274
  end
77
275
 
78
276
  # Registers the watcher, and says so: this is the one thing GraphWeaver does
@@ -87,7 +285,7 @@ class GraphWeaver::Railtie < Rails::Railtie
87
285
 
88
286
  # a directory that doesn't exist yet is still watched — FileUpdateChecker
89
287
  # re-globs on every check, and its keys may themselves be globs
90
- watched = GraphWeaver.queries_paths + GraphWeaver.fragments_paths
288
+ watched = GraphWeaver.graphs.flat_map(&:queries) | GraphWeaver.fragments_paths
91
289
  # the dump codegen would read, or where it goes once someone takes one
92
290
  dump = GraphWeaver::SchemaLoader.locate_path || GraphWeaver.schema_path
93
291
 
@@ -96,7 +294,7 @@ class GraphWeaver::Railtie < Rails::Railtie
96
294
  app.reloaders << watcher
97
295
  GraphWeaver::Internal::Log.log(:info) do
98
296
  "watching #{(watched << GraphWeaver::Internal::Util.relative(dump)).join(", ")} — an edit regenerates " \
99
- "#{GraphWeaver.generated_paths.first} before the next request " \
297
+ "#{GraphWeaver.graphs.map(&:output).uniq.join(", ")} before the next request " \
100
298
  "(config.graph_weaver.watch = false to stop)"
101
299
  end
102
300
  watcher
@@ -109,13 +307,15 @@ class GraphWeaver::Railtie < Rails::Railtie
109
307
  # one error per save, and the next save that compiles takes.
110
308
  def self.regenerate!
111
309
  GraphWeaver.generate!
112
- GraphWeaver.reload_generated!
113
310
  changed = GraphWeaver.changed_files
311
+ # before reloading, not after: reloading logs a line of its own, and
312
+ # "loaded 4 generated module(s)" ahead of "regenerated ..." reads backwards
114
313
  GraphWeaver::Internal::Log.log(:info) do
115
314
  next "generated modules already up to date" if changed.empty?
116
315
 
117
316
  "regenerated #{changed.join(", ")}"
118
317
  end
318
+ GraphWeaver.reload_generated!
119
319
  rescue GraphWeaver::Error => e
120
320
  GraphWeaver::Internal::Log.log(:error) { "keeping the modules already loaded — #{e.message}" }
121
321
  end
@@ -136,6 +336,10 @@ class GraphWeaver::Railtie < Rails::Railtie
136
336
  # :environment, so boot failed before the task that would regenerate it.
137
337
  next if GraphWeaver.skip_generated_load
138
338
 
339
+ # the app's own to_prepare blocks have run by now, so this is the first
340
+ # point that sees every graph — and the last before one of them loads
341
+ GraphWeaver::Railtie.check_generated_ignored!
342
+
139
343
  # Regenerate first, then load — and here rather than in the watcher's own
140
344
  # to_run, so an extend_type or register_enum the app registers in its own
141
345
  # to_prepare is already in place (that block was registered at
@@ -144,8 +348,24 @@ class GraphWeaver::Railtie < Rails::Railtie
144
348
  next if GraphWeaver::Railtie.watcher&.execute_if_updated
145
349
 
146
350
  # entries may be globs, so Dir[] rather than Dir.exist?
147
- generated = GraphWeaver.generated_paths.any? { |dir| Dir[GraphWeaver::Internal::Util.resolve(dir)].any? }
148
- GraphWeaver.load_generated! if generated
351
+ generated = GraphWeaver::Internal::Util.generated_dirs.any? do |dir|
352
+ Dir[GraphWeaver::Internal::Util.resolve(dir)].any?
353
+ end
354
+ next unless generated
355
+
356
+ # `require` no-ops on a file it has already read — which is what we want,
357
+ # except when the constant that file defined is gone. A graph's
358
+ # `namespace:` is normally a module Zeitwerk owns (app/graphql/accounts/
359
+ # implies Accounts), and unloading it on a dev reload takes the generated
360
+ # module nested inside it with it; require then restores nothing and every
361
+ # request 500s on "uninitialized constant Accounts::PersonQuery" until a
362
+ # .graphql edit happens to trigger the watcher. An un-namespaced module
363
+ # defines a top-level constant Zeitwerk never manages, so it survives.
364
+ if GraphWeaver.graphs.any?(&:namespace)
365
+ GraphWeaver.reload_generated!
366
+ else
367
+ GraphWeaver.load_generated!
368
+ end
149
369
  end
150
370
  end
151
371
  end
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "errors"
5
+ require_relative "internal/refusal"
5
6
 
6
7
  module GraphWeaver
7
8
  # Called by generated code — not semver'd for direct use.
@@ -20,6 +21,12 @@ module GraphWeaver
20
21
  # representation is a Hash a module function builds, so there is no generated
21
22
  # struct to name.
22
23
  module Representation
24
+ # Marks a key path hop the schema declares as a list: `@key(fields: "id
25
+ # lineItems { sku }")` over a `[LineItem!]!` flattens to
26
+ # "lineItems[].sku". A representation is built from these paths and
27
+ # nothing else, so this is the only place list-ness is written down.
28
+ LIST_HOP = "[]"
29
+
23
30
  # `key_sets` is the entity's @key field sets as dotted paths, in
24
31
  # declaration order — [["upc", "sku"], ["id"]] for a type keyed either
25
32
  # way. The first fully-supplied one wins; the wire hash carries exactly
@@ -29,7 +36,7 @@ module GraphWeaver
29
36
  raise incomplete(type_name, values, key_sets) unless satisfied
30
37
 
31
38
  satisfied.each_with_object({ "__typename" => type_name }) do |path, wire|
32
- assign(wire, path.split("."), dig(values, path))
39
+ graft(wire, values, path.split("."))
33
40
  end
34
41
  end
35
42
 
@@ -44,11 +51,13 @@ module GraphWeaver
44
51
 
45
52
  yield value
46
53
  rescue StandardError => e
47
- shown = value.inspect
54
+ shown = Internal::Redact.shown(value)
48
55
  got = " (got #{shown})" unless e.message.include?(shown)
49
56
  raise InputError.new(
50
57
  "#{type_name} representation #{name}: #{Internal::Redact.detail(name, "#{e.message}#{got}")}",
51
- field: name, struct: type_name,
58
+ kind: Internal::Refusal.kind_of(e), path: [name], coordinate: "#{type_name}.#{name}",
59
+ value: Internal::Redact.value(name, value), details: Internal::Refusal.details_of(e),
60
+ struct: type_name,
52
61
  )
53
62
  end
54
63
 
@@ -57,32 +66,61 @@ module GraphWeaver
57
66
 
58
67
  # Nested key values arrive as a caller-built hash, so accept either key
59
68
  # flavour at every hop — a literal `{ id: "1" }` reads the same as a hash
60
- # round-tripped through JSON.
61
- def self.dig(values, path)
62
- path.split(".").reduce(values) do |scope, name|
63
- return unless scope.is_a?(Hash)
69
+ # round-tripped through JSON. A LIST_HOP has to BE a list, and the rest of
70
+ # the path reads through every element: supplied only if all of them are,
71
+ # so one object where the schema says list reads as absent rather than
72
+ # going onto the wire misshapen.
73
+ def self.dig(scope, hops)
74
+ hops = hops.split(".") if hops.is_a?(String)
75
+ return scope if hops.empty?
76
+ return unless scope.is_a?(Hash)
64
77
 
65
- scope.key?(name) ? scope[name] : scope[name.to_sym]
66
- end
78
+ hop, *rest = hops
79
+ value = fetch(scope, hop.delete_suffix(LIST_HOP))
80
+ return dig(value, rest) unless hop.end_with?(LIST_HOP)
81
+ return unless value.is_a?(Array)
82
+ return value if rest.empty?
83
+
84
+ each = value.map { |item| dig(item, rest) }
85
+ each unless each.any?(&:nil?)
67
86
  end
68
87
  private_class_method :dig
69
88
 
70
- def self.assign(wire, path, value)
71
- *parents, leaf = path
72
- target = parents.reduce(wire) { |scope, name| scope[name] ||= {} }
73
- target[leaf] = value
89
+ def self.fetch(hash, name) = hash.key?(name) ? hash[name] : hash[name.to_sym]
90
+ private_class_method :fetch
91
+
92
+ # One key path, copied onto the wire in the shape the path declares — a
93
+ # LIST_HOP stays a list of objects, one per element, rather than
94
+ # collapsing into the single object a subgraph would read as one entity.
95
+ def self.graft(wire, source, hops)
96
+ hop, *rest = hops
97
+ name = hop.delete_suffix(LIST_HOP)
98
+ value = fetch(source, name)
99
+ return wire[name] = value if rest.empty?
100
+
101
+ if hop.end_with?(LIST_HOP)
102
+ elements = (wire[name] ||= Array.new(value.size) { {} })
103
+ value.each_with_index { |item, index| graft(elements[index], item, rest) }
104
+ else
105
+ graft(wire[name] ||= {}, value, rest)
106
+ end
74
107
  end
75
- private_class_method :assign
108
+ private_class_method :graft
76
109
 
77
110
  # Name the type and what it's short of, per @key — with a single key
78
- # there's one answer, so it also fills InputError#field.
111
+ # there's one answer, so it also fills InputError#path.
79
112
  def self.incomplete(type_name, values, key_sets)
80
113
  gaps = key_sets.map { |paths| missing(values, paths) }
81
114
 
82
115
  if key_sets.one?
116
+ # a nested @key reads "organization.id"; #path is that route, without
117
+ # the list markers the message keeps
118
+ path = gaps.first.one? ? gaps.first.first.split(".").map { |hop| hop.delete_suffix(LIST_HOP) } : []
83
119
  InputError.new(
84
120
  "#{type_name} representation is missing @key #{gaps.first.map(&:inspect).join(", ")}",
85
- field: gaps.first.one? ? gaps.first.first : nil, struct: type_name,
121
+ kind: :missing, path:,
122
+ coordinate: ("#{type_name}.#{path.first}" if path.one?),
123
+ struct: type_name,
86
124
  )
87
125
  else
88
126
  alternatives = key_sets.zip(gaps).map do |paths, gap|
@@ -93,7 +131,7 @@ module GraphWeaver
93
131
  end
94
132
  InputError.new(
95
133
  "#{type_name} representation satisfies none of its @keys — supply #{alternatives.join(", or ")}",
96
- struct: type_name,
134
+ kind: :missing, struct: type_name,
97
135
  )
98
136
  end
99
137
  end
@@ -0,0 +1,90 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module GraphWeaver
7
+ # Included in every generated result struct — the twin of InputStruct on
8
+ # the way out. T::Struct compares by identity, has no #to_h and can't be
9
+ # destructured, so two results parsed from the same response come back
10
+ # unequal, `result.to_h` raises, and `case result in {person:}` raises
11
+ # NoMatchingPatternError. A struct the library hands you should behave
12
+ # like an ordinary Ruby object; this is that behaviour, in one place.
13
+ module ResultStruct
14
+ extend T::Sig
15
+ include Kernel # for sorbet: hosts are T::Structs
16
+
17
+ # Value equality over the struct's props. Nested structs compare
18
+ # through their own copy of this, so a whole result tree compares.
19
+ # eql? on the props hash rather than ==, so that this and #hash agree
20
+ # on what "same" means (1 and 1.0 hash differently).
21
+ sig { params(other: T.untyped).returns(T::Boolean) }
22
+ def ==(other)
23
+ other.instance_of?(self.class) && deconstruct_keys(nil).eql?(other.deconstruct_keys(nil))
24
+ end
25
+ alias_method :eql?, :==
26
+
27
+ # eql? and hash move together, or a struct is a broken Hash key.
28
+ sig { returns(Integer) }
29
+ def hash = [self.class, *deconstruct_keys(nil).values].hash
30
+
31
+ # Pattern matching: `case result in {person: {name:}}`. Every prop,
32
+ # always — the pattern binds the ones it names, and a nested struct
33
+ # destructures through its own copy. Values stay as they are, so
34
+ # `in {person: Person => p}` binds the struct rather than a hash.
35
+ sig { params(_keys: T.nilable(T::Array[Symbol])).returns(T::Hash[Symbol, T.untyped]) }
36
+ def deconstruct_keys(_keys)
37
+ self.class.props.keys.to_h { |prop| [prop, public_send(prop)] }
38
+ end
39
+
40
+ # The Ruby-side view of the result: snake_case prop names as Symbols,
41
+ # nils kept, nested structs and arrays followed, enums left as their
42
+ # T::Enum members.
43
+ #
44
+ # Deliberately NOT the wire shape, and not an inverse of .from_h — a
45
+ # registered scalar keeps whatever Ruby object its codec built. That
46
+ # is the same objection Response#to_h raises against re-serializing
47
+ # its data, and it doesn't apply here: this hash is Symbol-keyed and
48
+ # Ruby-cased, so it can't be mistaken for the server's response.
49
+ sig { returns(T::Hash[Symbol, T.untyped]) }
50
+ def to_h
51
+ deconstruct_keys(nil).transform_values { |value| unwrap_value(value) }
52
+ end
53
+
54
+ # JSON is the wire's shape, not Ruby's: `to_json` is the generated
55
+ # `as_json` encoded, so it carries the response keys and each leaf back
56
+ # through its scalar registration's `serialize:`, and
57
+ # `.from_h(JSON.parse(result.to_json))` gives an equal struct. That is
58
+ # the opposite of #to_h, deliberately — a Symbol-keyed Ruby hash can't be
59
+ # mistaken for a response, and a JSON string can, so the string is the
60
+ # one that has to be true. (A registration with no `serialize:` has no
61
+ # wire form; its value passes through, exactly as it does on the way in.)
62
+ #
63
+ # Defined here rather than left to Ruby: Object#to_json writes the
64
+ # #inspect string, quoted, and ActiveSupport's Object#as_json writes the
65
+ # ivars — a result's snake_cased props, `class_` and all.
66
+ sig { params(options: T.untyped).returns(String) }
67
+ def to_json(options = nil) = as_json.to_json(options)
68
+
69
+ # Only reached by a struct generated before as_json existed — the
70
+ # generated override otherwise wins, being defined on the struct itself.
71
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
72
+ def as_json(*_options)
73
+ raise GraphWeaver::Error,
74
+ "#{self.class} was generated before #as_json — regenerate (rake graph_weaver:generate)"
75
+ end
76
+
77
+ private
78
+
79
+ # Follows a value into nested result structs and lists (which nest, for
80
+ # a `[[Pet!]!]!`); everything else is already Ruby-side.
81
+ sig { params(value: T.untyped).returns(T.untyped) }
82
+ def unwrap_value(value)
83
+ case value
84
+ when ResultStruct then value.to_h
85
+ when Array then value.map { |item| unwrap_value(item) }
86
+ else value
87
+ end
88
+ end
89
+ end
90
+ end
@@ -52,22 +52,31 @@ class GraphWeaver::Retry
52
52
  # server asking you to come back later rather than to fix anything
53
53
  RETRIABLE_CLIENT_STATUSES = [408, 429].freeze
54
54
 
55
+ # The status half of the policy, asked of a raised ServerError's status
56
+ # and of the status a response arrived on — one answer, so the two can't
57
+ # drift apart.
58
+ RETRIABLE_STATUS = ->(status) { status >= 500 || RETRIABLE_CLIENT_STATUSES.include?(status) }
59
+
55
60
  # retry 5xx (and 408/429), not the rest of 4xx; everything else listed
56
61
  # in retry_on: retries
57
62
  DEFAULT_RETRY_IF = lambda do |error|
58
- !error.is_a?(GraphWeaver::ServerError) ||
59
- error.status >= 500 || RETRIABLE_CLIENT_STATUSES.include?(error.status)
63
+ !error.is_a?(GraphWeaver::ServerError) || RETRIABLE_STATUS.call(error.status)
60
64
  end
61
65
 
62
66
  # said once, where the decision is made and where it is explained
63
67
  MUTATION_HINT = "not retrying a mutation — a request that failed without an answer " \
64
68
  "may still have been applied; pass retry_mutations: true if yours are idempotent"
65
- private_constant :DEFAULT_RETRY_IF, :MUTATION_HINT
69
+ private_constant :RETRIABLE_STATUS, :DEFAULT_RETRY_IF, :MUTATION_HINT
66
70
 
67
71
  def initialize(client, retries: 2, retry_on: [GraphWeaver::TransportError, GraphWeaver::ServerError],
68
72
  backoff: :exponential, base_delay: 0.5, max_delay: 30, jitter: true, retry_if: DEFAULT_RETRY_IF,
69
73
  retry_codes: [], retry_mutations: false, sleeper: nil)
70
74
  raise ArgumentError, "retries: must be >= 0" unless retries.is_a?(Integer) && retries >= 0
75
+ # a negative would reach Kernel#sleep, which raises — and the failure being
76
+ # retried would be lost behind an ArgumentError from somewhere else
77
+ { base_delay:, max_delay: }.each do |name, value|
78
+ raise ArgumentError, "#{name}: must be >= 0, got #{value}" unless value >= 0
79
+ end
71
80
 
72
81
  @client = client
73
82
  @retries = retries
@@ -101,7 +110,11 @@ class GraphWeaver::Retry
101
110
  loop do
102
111
  attempt += 1
103
112
  begin
104
- response = @client.execute(query, variables:, operation_name:)
113
+ # each attempt is its own EXECUTE_EVENT; :retries says which one,
114
+ # so "slow" and "slow after two 502s" don't read the same in an APM
115
+ response = GraphWeaver::Internal::Log.with_retries(attempt - 1) do
116
+ @client.execute(query, variables:, operation_name:)
117
+ end
105
118
  return response unless attempt < attempts && retryable_response?(response)
106
119
  rescue *@retry_on => e
107
120
  if attempt >= attempts || !@retry_if.call(e)
@@ -130,6 +143,19 @@ class GraphWeaver::Retry
130
143
  end
131
144
 
132
145
  def retryable_response?(response)
146
+ retryable_status?(response) || retryable_code?(response)
147
+ end
148
+
149
+ # The status a response arrived on, where it came back with one — the
150
+ # bundled transports say; a schema class, a fake and a hand-rolled client
151
+ # answer a plain Hash, and a response with no status is never retried on
152
+ # one.
153
+ def retryable_status?(response)
154
+ status = response.http_status if response.respond_to?(:http_status)
155
+ !status.nil? && RETRIABLE_STATUS.call(status)
156
+ end
157
+
158
+ def retryable_code?(response)
133
159
  return false if @retry_codes.empty?
134
160
 
135
161
  codes = (response.to_h["errors"] || []).filter_map { |error| error.dig("extensions", "code") }
@@ -144,7 +170,9 @@ class GraphWeaver::Retry
144
170
  after = failure.retry_after if failure.is_a?(GraphWeaver::ServerError)
145
171
  return [after, @max_delay].min.to_f if after
146
172
 
147
- seconds = [@backoff.call(@base_delay, attempt), @max_delay].min.to_f
173
+ # floored at 0: a custom backoff: is the caller's arithmetic, and a
174
+ # negative from it would raise out of Kernel#sleep rather than retry
175
+ seconds = [[@backoff.call(@base_delay, attempt), @max_delay].min.to_f, 0.0].max
148
176
  @jitter ? seconds * (0.5 + rand * 0.5) : seconds
149
177
  end
150
178
  end