graph_weaver 0.5.1 → 0.6.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +409 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +6 -1
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +73 -16
  8. data/docs/federation.md +201 -151
  9. data/docs/generated_modules.md +222 -165
  10. data/docs/getting_started.md +105 -81
  11. data/docs/logging.md +34 -4
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +191 -151
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +210 -11
  16. data/lib/generators/graph_weaver/install_generator.rb +16 -1
  17. data/lib/graph_weaver/client.rb +46 -13
  18. data/lib/graph_weaver/codegen/aliases.rb +5 -4
  19. data/lib/graph_weaver/codegen/emit.rb +96 -39
  20. data/lib/graph_weaver/codegen/enum_type.rb +3 -0
  21. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  22. data/lib/graph_weaver/codegen/scalar_type.rb +82 -79
  23. data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
  24. data/lib/graph_weaver/codegen.rb +284 -84
  25. data/lib/graph_weaver/coerce.rb +113 -0
  26. data/lib/graph_weaver/errors.rb +30 -7
  27. data/lib/graph_weaver/federation.rb +6 -5
  28. data/lib/graph_weaver/hints.rb +76 -2
  29. data/lib/graph_weaver/in_process.rb +11 -8
  30. data/lib/graph_weaver/inflect.rb +2 -0
  31. data/lib/graph_weaver/input_struct.rb +115 -12
  32. data/lib/graph_weaver/internal/overrides.rb +101 -0
  33. data/lib/graph_weaver/internal/planner.rb +868 -0
  34. data/lib/graph_weaver/internal/schemas.rb +50 -0
  35. data/lib/graph_weaver/internal/selection.rb +127 -0
  36. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
  37. data/lib/graph_weaver/internal/values.rb +181 -0
  38. data/lib/graph_weaver/internal.rb +206 -0
  39. data/lib/graph_weaver/logging.rb +108 -20
  40. data/lib/graph_weaver/parsing.rb +5 -4
  41. data/lib/graph_weaver/query_module.rb +2 -0
  42. data/lib/graph_weaver/railtie.rb +113 -14
  43. data/lib/graph_weaver/representation.rb +30 -2
  44. data/lib/graph_weaver/response.rb +15 -0
  45. data/lib/graph_weaver/retry.rb +54 -22
  46. data/lib/graph_weaver/rspec.rb +50 -11
  47. data/lib/graph_weaver/schema_diff.rb +293 -0
  48. data/lib/graph_weaver/schema_loader.rb +96 -29
  49. data/lib/graph_weaver/tasks.rb +78 -29
  50. data/lib/graph_weaver/testing/cassette.rb +49 -65
  51. data/lib/graph_weaver/testing/coverage.rb +5 -4
  52. data/lib/graph_weaver/testing/failure.rb +10 -6
  53. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  54. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  55. data/lib/graph_weaver/testing/router.rb +94 -808
  56. data/lib/graph_weaver/testing.rb +35 -84
  57. data/lib/graph_weaver/transport/faraday.rb +1 -1
  58. data/lib/graph_weaver/transport/http.rb +29 -12
  59. data/lib/graph_weaver/transport.rb +11 -34
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +188 -110
  62. metadata +10 -5
  63. data/lib/graph_weaver/schemas.rb +0 -48
  64. data/lib/graph_weaver/selection.rb +0 -120
  65. data/lib/graph_weaver/testing/values.rb +0 -98
@@ -14,13 +14,14 @@ module GraphWeaver
14
14
  class MissingRecording < GraphWeaver::Error
15
15
  # how many recorded variable sets to print before summarizing
16
16
  SHOWN = 5
17
+ private_constant :SHOWN
17
18
 
18
19
  def initialize(path:, query:, variables:, recorded:, size:)
19
20
  super([
20
- "no recording for this request in #{path}",
21
- " variables: #{Cassette.normalize_variables(variables).inspect}",
21
+ "no recording for this request in #{GraphWeaver::Internal::Util.relative(path)}",
22
+ " variables: #{GraphWeaver::Internal::Log.filter_variables(Internal::RequestKey.normalize_variables(variables)).inspect}",
22
23
  " #{self.class.recorded_summary(recorded, size)}",
23
- " query: #{Cassette.summarize(query)}",
24
+ " query: #{Internal::RequestKey.summarize(query)}",
24
25
  "re-record it (GRAPHWEAVER_RECORD=1 with a client:), or delete the cassette to start over.",
25
26
  ].join("\n"))
26
27
  end
@@ -29,8 +30,9 @@ module GraphWeaver
29
30
  return "no entry recorded for this query (#{size} in the cassette)" if recorded.empty?
30
31
 
31
32
  more = recorded.size > SHOWN ? " (+#{recorded.size - SHOWN} more)" : ""
33
+ shown = recorded.first(SHOWN).map { |set| GraphWeaver::Internal::Log.filter_variables(set).inspect }
32
34
  "#{recorded.size} #{(recorded.size == 1) ? "entry" : "entries"} recorded for this query, " \
33
- "with variables #{recorded.first(SHOWN).map(&:inspect).join(", ")}#{more}"
35
+ "with variables #{shown.join(", ")}#{more}"
34
36
  end
35
37
  end
36
38
 
@@ -51,15 +53,17 @@ module GraphWeaver
51
53
  elsif config.record
52
54
  # record mode without a client would quietly serve the stale
53
55
  # recording — the one thing "re-record everything" didn't ask for
54
- raise GraphWeaver::Error, "record mode is on but no `client:` was given for #{file.path} " \
56
+ raise GraphWeaver::Error, "record mode is on but no `client:` was given for " \
57
+ "#{GraphWeaver::Internal::Util.relative(file.path)} " \
55
58
  "— pass a live `client:` to re-record it, or turn record mode off " \
56
59
  "(GRAPHWEAVER_RECORD / Testing.config.record)."
57
60
  elsif file.exist?
58
61
  Replayer.new(file)
59
62
  else
60
63
  # a first run, not a missing recording: there is no request yet
61
- raise GraphWeaver::Error, "#{file.path} doesn't exist and no `client:` was given to " \
62
- "record with — pass `client:` on the first run, or commit the cassette."
64
+ raise GraphWeaver::Error, "#{GraphWeaver::Internal::Util.relative(file.path)} doesn't exist " \
65
+ "and no `client:` was given to record with — pass `client:` on the first run, " \
66
+ "or commit the cassette."
63
67
  end
64
68
  end
65
69
  end
@@ -72,14 +76,14 @@ module GraphWeaver
72
76
  # found. `checked` is how many recordings a module claimed: a run that
73
77
  # claimed none proved nothing, which is a different answer from "all
74
78
  # good" — the same distinction `federation:diff` draws.
75
- Check = Struct.new(:path, :checked, :skipped, :stale, keyword_init: true) do
79
+ Check = Data.define(:path, :checked, :skipped, :stale) do
76
80
  def ok? = stale.empty?
77
81
 
78
82
  def report
79
83
  counted = ["#{checked} checked"]
80
84
  counted << "#{skipped} not sent by any query module" if skipped.positive?
81
85
 
82
- ["#{path}: #{stale.size} stale (#{counted.join(", ")})"] +
86
+ ["#{GraphWeaver::Internal::Util.relative(path)}: #{stale.size} stale (#{counted.join(", ")})"] +
83
87
  stale.flat_map do |entry|
84
88
  [" #{entry.module_name} #{entry.variables.inspect}", " #{entry.message}"]
85
89
  end
@@ -87,7 +91,7 @@ module GraphWeaver
87
91
  end
88
92
 
89
93
  # One recording the generated structs can no longer read.
90
- Stale = Struct.new(:module_name, :variables, :message, keyword_init: true)
94
+ Stale = Data.define(:module_name, :variables, :message)
91
95
 
92
96
  # Shapes that are a credential whatever the field around them is
93
97
  # called. Anonymization can't cover everything a cassette holds — the
@@ -112,35 +116,41 @@ module GraphWeaver
112
116
  @path = Testing.cassette_path(path)
113
117
  @entries = File.exist?(@path) ? YAML.safe_load_file(@path, aliases: true) : []
114
118
  @flagged = []
119
+ # record is read-modify-write; two threads recording through one
120
+ # cassette (a parallel spec run) would each save a snapshot missing
121
+ # the other's entry — atomic_write keeps the file whole, not complete
122
+ @lock = Mutex.new
115
123
  end
116
124
 
117
125
  def exist? = File.exist?(@path)
118
126
  def size = @entries.size
119
127
 
120
128
  def lookup(query, variables, operation_name = nil)
121
- wanted = self.class.key(query, variables, operation_name)
122
- @entries.find { |entry| self.class.entry_key(entry) == wanted }
129
+ wanted = Internal::RequestKey.for(query, variables, operation_name)
130
+ @entries.find { |entry| Internal::RequestKey.for_entry(entry) == wanted }
123
131
  end
124
132
 
125
133
  # every variables hash recorded for this query — what a miss needs
126
134
  # to show, since the variables are what usually differ
127
135
  def variants(query, operation_name = nil)
128
- normalized = self.class.normalize_query(query)
136
+ normalized = Internal::RequestKey.normalize_query(query)
129
137
  @entries.select do |entry|
130
- self.class.normalize_query(entry["query"]) == normalized && entry["operationName"] == operation_name
138
+ Internal::RequestKey.normalize_query(entry["query"]) == normalized && entry["operationName"] == operation_name
131
139
  end.map { |entry| entry["variables"] || {} }
132
140
  end
133
141
 
134
142
  def record(query, variables, response, operation_name = nil)
135
143
  entry = { "query" => query }
136
144
  entry["operationName"] = operation_name if operation_name
137
- entry["variables"] = self.class.normalize_variables(variables)
145
+ entry["variables"] = Internal::RequestKey.normalize_variables(variables)
138
146
  entry["response"] = response
139
147
 
140
- wanted = self.class.key(query, variables, operation_name)
141
- @entries.reject! { |existing| self.class.entry_key(existing) == wanted }
142
- @entries << entry
143
- save
148
+ wanted = Internal::RequestKey.for(query, variables, operation_name)
149
+ @lock.synchronize do
150
+ @entries.reject! { |existing| Internal::RequestKey.for_entry(existing) == wanted }
151
+ @entries << entry
152
+ save
153
+ end
144
154
  end
145
155
 
146
156
  # Replay every recording through `modules` — the generated query
@@ -156,10 +166,10 @@ module GraphWeaver
156
166
  # Matching is on the query text, which is the module that sent it — a
157
167
  # recording no module sends is skipped rather than guessed at.
158
168
  def check(modules)
159
- index = modules.to_h { |mod| [self.class.normalize_query(mod.const_get(:QUERY)), mod] }
169
+ index = modules.to_h { |mod| [Internal::RequestKey.normalize_query(mod.const_get(:QUERY)), mod] }
160
170
  checked = 0
161
171
  stale = @entries.filter_map do |entry|
162
- mod = index[self.class.normalize_query(entry["query"])] or next
172
+ mod = index[Internal::RequestKey.normalize_query(entry["query"])] or next
163
173
  checked += 1
164
174
 
165
175
  begin
@@ -176,8 +186,8 @@ module GraphWeaver
176
186
  # Replace recorded response values with fakes, preserving structure.
177
187
  # Walks each entry's query against the schema (like FakeClient,
178
188
  # but transforming what's there instead of generating from scratch).
179
- def anonymize!(schema:, seed: nil, mode: nil)
180
- anonymizer = Anonymizer.new(schema:, seed:, mode:)
189
+ def anonymize!(schema:, seed: nil, values: nil)
190
+ anonymizer = Anonymizer.new(schema:, seed:, values:)
181
191
  @entries.each do |entry|
182
192
  entry["response"] = anonymizer.anonymize(entry["query"], entry["response"]) if entry["response"]
183
193
  end
@@ -185,42 +195,12 @@ module GraphWeaver
185
195
  self
186
196
  end
187
197
 
188
- # The request's identity, exactly as the server sees it. operationName
189
- # is part of that: it picks the operation the document runs, so two
190
- # requests with identical text but different names are different
191
- # requests. Derived, never stored — the file holds the request once,
192
- # so a hand-edited entry can't disagree with what replay matches on.
193
- def self.key(query, variables, operation_name = nil)
194
- key = { "query" => normalize_query(query), "variables" => normalize_variables(variables) }
195
- key["operationName"] = operation_name if operation_name
196
- key
197
- end
198
-
199
- def self.entry_key(entry)
200
- key(entry["query"], entry["variables"], entry["operationName"])
201
- end
202
-
203
- def self.normalize_query(query) = query.gsub(/\s+/, " ").strip
204
-
205
- # one readable line: an error naming a 60-line query is a wall, not a hint
206
- def self.summarize(query, limit: 160)
207
- normalized = normalize_query(query)
208
- (normalized.length > limit) ? "#{normalized[0, limit]}…" : normalized
209
- end
210
-
211
- # JSON round-trip so symbol keys become strings — otherwise YAML.dump
212
- # writes Ruby symbols the safe loader rejects on the next run, and lookup
213
- # keys stay stable across processes
214
- def self.normalize_variables(variables)
215
- JSON.parse(JSON.generate(variables || {}))
216
- end
217
-
218
198
  private
219
199
 
220
200
  def save
221
201
  yaml = YAML.dump(@entries)
222
202
  FileUtils.mkdir_p(File.dirname(@path))
223
- File.write(@path, yaml)
203
+ GraphWeaver::Internal::Util.atomic_write(@path, yaml)
224
204
  flag_credentials(yaml)
225
205
  end
226
206
 
@@ -234,7 +214,7 @@ module GraphWeaver
234
214
  return if found.empty?
235
215
 
236
216
  @flagged.concat(found)
237
- warn "graph_weaver: #{@path} contains #{found.join(", ")} — a cassette is committed as " \
217
+ warn "graph_weaver: #{GraphWeaver::Internal::Util.relative(@path)} contains #{found.join(", ")} — a cassette is committed as " \
238
218
  "written, so review this one first. Testing.config.anonymize scrubs the response; the " \
239
219
  "query and variables are the replay key and are recorded verbatim."
240
220
  end
@@ -290,7 +270,7 @@ module GraphWeaver
290
270
  # fake values. Enums, booleans, __typename, and null positions are
291
271
  # preserved; ids map consistently so relationships survive.
292
272
  class Anonymizer
293
- include GraphWeaver::Selection
273
+ include GraphWeaver::Internal::Selection
294
274
 
295
275
  # Keys under `errors`/`extensions` whose value describes the request
296
276
  # rather than carrying data: `path` and `locations` point into the
@@ -299,9 +279,9 @@ module GraphWeaver
299
279
  # for the same reason.
300
280
  VERBATIM_KEYS = %w[path locations code].freeze
301
281
 
302
- def initialize(schema:, seed: nil, mode: nil)
282
+ def initialize(schema:, seed: nil, values: nil)
303
283
  @schema = schema
304
- @values = Values.new(seed:, mode:)
284
+ @values = Internal::Values.new(seed:, values:)
305
285
  end
306
286
 
307
287
  # The whole response, not just `data`: an error message routinely
@@ -383,19 +363,19 @@ module GraphWeaver
383
363
  # to whichever possible type declares it
384
364
  field = @schema.get_field(parent_type.graphql_name, name) ||
385
365
  @schema.possible_types(parent_type).filter_map { |t| @schema.get_field(t.graphql_name, name) }.first
386
- type_value(field.type, name, selections, value)
366
+ type_value(field.type, name, selections, value, "#{parent_type.graphql_name}.#{name}")
387
367
  end
388
368
 
389
- def type_value(type, name, selections, value)
369
+ def type_value(type, name, selections, value, coordinate = nil)
390
370
  return if value.nil? # preserve null positions
391
371
 
392
372
  case type.kind.name
393
373
  when "NON_NULL"
394
- type_value(type.of_type, name, selections, value)
374
+ type_value(type.of_type, name, selections, value, coordinate)
395
375
  when "LIST"
396
- value.map { |element| type_value(type.of_type, name, selections, element) }
376
+ value.map { |element| type_value(type.of_type, name, selections, element, coordinate) }
397
377
  when "SCALAR"
398
- scalar_value(type.graphql_name, name, value)
378
+ scalar_value(type.graphql_name, name, value, coordinate)
399
379
  when "ENUM"
400
380
  value # enums aren't PII; preserving them keeps semantics
401
381
  when "OBJECT", "UNION", "INTERFACE"
@@ -405,13 +385,17 @@ module GraphWeaver
405
385
  end
406
386
  end
407
387
 
408
- def scalar_value(type_name, field_name, value)
388
+ def scalar_value(type_name, field_name, value, coordinate = nil)
409
389
  case type_name
410
390
  when "ID" then @values.mapped_id(value)
411
391
  when "Boolean" then value # not PII; preserves branching behavior
412
- else @values.scalar(type_name, field_name)
392
+ else @values.scalar(type_name, field_name, coordinate)
413
393
  end
414
394
  end
415
395
  end
396
+
397
+ # the scrubbing walk anonymize! runs — reached through a cassette,
398
+ # never named
399
+ private_constant :Anonymizer
416
400
  end
417
401
  end
@@ -26,7 +26,7 @@ module GraphWeaver
26
26
  # plan stitches across a boundary. `absent` names the subgraphs that
27
27
  # plan reaches which nothing in this process serves — plannable and
28
28
  # runnable-here are different questions.
29
- Result = Struct.new(:path, :subgraph, :absent, :category, :detail) do
29
+ Result = Data.define(:path, :subgraph, :absent, :category, :detail) do
30
30
  def servable? = category.nil? && absent.empty?
31
31
  end
32
32
 
@@ -35,6 +35,7 @@ module GraphWeaver
35
35
  .transform_values(&:first)
36
36
  .merge(invalid: "doesn't validate against the supergraph")
37
37
  .freeze
38
+ private_constant :LABELS
38
39
 
39
40
  attr_reader :results
40
41
 
@@ -49,11 +50,11 @@ module GraphWeaver
49
50
  # separately, from evidence (a loaded schema defining what the table
50
51
  # says one resolves), and never refuses: with nothing loaded the
51
52
  # answer is simply "none", which is the SDL-alone CI run.
52
- @planner = Router::Planner.new(table:, schema: GraphWeaver::SchemaLoader.load(source))
53
- @absent = table.subgraphs.reject { |name| Subgraphs.served?(table, name) }
53
+ @planner = Internal::Planner.new(table:, schema: GraphWeaver::SchemaLoader.load(source))
54
+ @absent = table.subgraphs.reject { |name| Internal::Subgraphs.served?(table, name) }
54
55
  @local = @absent.size < table.subgraphs.size
55
56
  @shared = GraphWeaver::Codegen.load_fragments(fragments)
56
- @results = GraphWeaver.query_files(queries).map { |path| measure(path) }
57
+ @results = GraphWeaver::Internal::Util.query_files(queries).map { |path| measure(path) }
57
58
  end
58
59
 
59
60
  def plannable = @results.count { |result| result.category.nil? }
@@ -22,19 +22,23 @@ module GraphWeaver
22
22
  include Kernel # for sorbet
23
23
  module_function
24
24
 
25
- # the request never reaches the server — cause preserved, like the
26
- # bundled transports do
25
+ # the request never reaches the server — cause preserved, and the
26
+ # message shaped as the bundled transports shape it
27
27
  def transport(message = "simulated network failure", cause: SocketError)
28
28
  FailureClient.new do
29
29
  raise cause, message
30
30
  rescue cause => e
31
- raise GraphWeaver::TransportError, e.message
31
+ raise GraphWeaver::TransportError, "#{e.class}: #{e.message}"
32
32
  end
33
33
  end
34
34
 
35
- # the server answered non-2xx
36
- def server(status: 500, body: "simulated server error")
37
- FailureClient.new { raise GraphWeaver::ServerError.new(status:, body:) }
35
+ # The server answered non-2xx. headers: is where the answer to "wait,
36
+ # then" lives ServerError#retry_after and #throttled? read it, so a
37
+ # backoff is only exercised by a failure that carries one:
38
+ #
39
+ # Failure.server(status: 429, headers: { "retry-after" => "2" })
40
+ def server(status: 500, body: "simulated server error", headers: {})
41
+ FailureClient.new { raise GraphWeaver::ServerError.new(status:, body:, headers:) }
38
42
  end
39
43
 
40
44
  # top-level GraphQL errors: strings, or hashes with message/path/