graph_weaver 0.5.0 → 0.5.1

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -1
  5. data/docs/cassettes.md +23 -3
  6. data/docs/errors.md +2 -0
  7. data/docs/federation.md +8 -7
  8. data/docs/generated_modules.md +2 -2
  9. data/docs/getting_started.md +1 -1
  10. data/docs/logging.md +1 -1
  11. data/docs/testing.md +8 -7
  12. data/docs/upgrading.md +34 -12
  13. data/graph_weaver.gemspec +16 -2
  14. data/lib/generators/graph_weaver/install_generator.rb +15 -15
  15. data/lib/graph_weaver/client.rb +6 -2
  16. data/lib/graph_weaver/codegen/aliases.rb +10 -4
  17. data/lib/graph_weaver/codegen/emit.rb +11 -3
  18. data/lib/graph_weaver/codegen/enum_type.rb +1 -3
  19. data/lib/graph_weaver/codegen/scalar_type.rb +5 -4
  20. data/lib/graph_weaver/codegen/type_helpers.rb +1 -3
  21. data/lib/graph_weaver/codegen.rb +99 -22
  22. data/lib/graph_weaver/errors.rb +27 -6
  23. data/lib/graph_weaver/federation.rb +4 -17
  24. data/lib/graph_weaver/parsing.rb +1 -9
  25. data/lib/graph_weaver/rspec.rb +13 -7
  26. data/lib/graph_weaver/schema_loader.rb +30 -6
  27. data/lib/graph_weaver/schemas.rb +4 -2
  28. data/lib/graph_weaver/tasks.rb +24 -21
  29. data/lib/graph_weaver/testing/cassette.rb +89 -20
  30. data/lib/graph_weaver/testing/coverage.rb +7 -12
  31. data/lib/graph_weaver/testing/failure.rb +4 -2
  32. data/lib/graph_weaver/testing/fake_client.rb +1 -1
  33. data/lib/graph_weaver/testing/router.rb +68 -47
  34. data/lib/graph_weaver/testing/subgraphs.rb +11 -7
  35. data/lib/graph_weaver/testing.rb +8 -2
  36. data/lib/graph_weaver/version.rb +1 -1
  37. data/lib/graph_weaver.rb +39 -14
  38. metadata +8 -9
  39. data/CLAUDE.md +0 -161
  40. data/DECISIONS.md +0 -309
  41. data/Makefile +0 -23
  42. data/NOTES.md +0 -182
  43. data/PLAN.md +0 -115
  44. data/REVIEW.md +0 -946
@@ -89,11 +89,29 @@ module GraphWeaver
89
89
  # One recording the generated structs can no longer read.
90
90
  Stale = Struct.new(:module_name, :variables, :message, keyword_init: true)
91
91
 
92
+ # Shapes that are a credential whatever the field around them is
93
+ # called. Anonymization can't cover everything a cassette holds — the
94
+ # variables ARE the replay key, so they're written verbatim — so the
95
+ # bytes that reach disk get one look before anyone commits them.
96
+ # Deliberately narrow: a false alarm costs a glance, while a password
97
+ # like "hunter2" has no shape at all, so a quiet run is not a clean
98
+ # bill of health.
99
+ CREDENTIAL_SHAPES = {
100
+ "a JWT" => /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\./,
101
+ "an AWS access key" => /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,
102
+ "a GitHub token" => /\b(?:gh[opusr]|github_pat)_[A-Za-z0-9_]{20,}/,
103
+ "a Slack token" => /\bxox[baprs]-[A-Za-z0-9-]{10,}/,
104
+ "a Stripe key" => /\bsk_(?:live|test)_[A-Za-z0-9]{10,}/,
105
+ "a private key" => /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
106
+ "an Authorization header" => /\bBearer\s+\S{16,}/,
107
+ }.freeze
108
+
92
109
  attr_reader :path
93
110
 
94
111
  def initialize(path)
95
112
  @path = Testing.cassette_path(path)
96
113
  @entries = File.exist?(@path) ? YAML.safe_load_file(@path, aliases: true) : []
114
+ @flagged = []
97
115
  end
98
116
 
99
117
  def exist? = File.exist?(@path)
@@ -161,8 +179,7 @@ module GraphWeaver
161
179
  def anonymize!(schema:, seed: nil, mode: nil)
162
180
  anonymizer = Anonymizer.new(schema:, seed:, mode:)
163
181
  @entries.each do |entry|
164
- data = entry.dig("response", "data")
165
- entry["response"]["data"] = anonymizer.anonymize(entry["query"], data) if data
182
+ entry["response"] = anonymizer.anonymize(entry["query"], entry["response"]) if entry["response"]
166
183
  end
167
184
  save
168
185
  self
@@ -201,8 +218,25 @@ module GraphWeaver
201
218
  private
202
219
 
203
220
  def save
221
+ yaml = YAML.dump(@entries)
204
222
  FileUtils.mkdir_p(File.dirname(@path))
205
- File.write(@path, YAML.dump(@entries))
223
+ File.write(@path, yaml)
224
+ flag_credentials(yaml)
225
+ end
226
+
227
+ # Once per shape per cassette: a recording run saves after every
228
+ # request, and one line is a warning where forty is noise. On stderr
229
+ # rather than GraphWeaver.logger — the logger is silent by default,
230
+ # and this has to reach whoever is about to commit the file.
231
+ def flag_credentials(yaml)
232
+ found = CREDENTIAL_SHAPES.reject { |name, _| @flagged.include?(name) }
233
+ .select { |_, pattern| pattern.match?(yaml) }.keys
234
+ return if found.empty?
235
+
236
+ @flagged.concat(found)
237
+ warn "graph_weaver: #{@path} contains #{found.join(", ")} — a cassette is committed as " \
238
+ "written, so review this one first. Testing.config.anonymize scrubs the response; the " \
239
+ "query and variables are the replay key and are recorded verbatim."
206
240
  end
207
241
  end
208
242
 
@@ -227,9 +261,7 @@ module GraphWeaver
227
261
 
228
262
  def execute(query, variables: {}, operation_name: nil)
229
263
  response = @client.execute(query, variables:, operation_name:).to_h
230
- if @anonymizer && (data = response["data"])
231
- response = response.merge("data" => @anonymizer.anonymize(query, data))
232
- end
264
+ response = @anonymizer.anonymize(query, response) if @anonymizer
233
265
 
234
266
  @cassette.record(query, variables, response, operation_name)
235
267
  response
@@ -260,18 +292,52 @@ module GraphWeaver
260
292
  class Anonymizer
261
293
  include GraphWeaver::Selection
262
294
 
295
+ # Keys under `errors`/`extensions` whose value describes the request
296
+ # rather than carrying data: `path` and `locations` point into the
297
+ # document, and `code` is the errors-world enum — call sites branch on
298
+ # it exactly as they branch on an enum in `data`, which is preserved
299
+ # for the same reason.
300
+ VERBATIM_KEYS = %w[path locations code].freeze
301
+
263
302
  def initialize(schema:, seed: nil, mode: nil)
264
303
  @schema = schema
265
304
  @values = Values.new(seed:, mode:)
266
305
  end
267
306
 
268
- def anonymize(query, data)
269
- operation = load_operation(query)
307
+ # The whole response, not just `data`: an error message routinely
308
+ # quotes the input that caused it, and `extensions` is whatever the
309
+ # server felt like attaching. One rule — `data` is walked against the
310
+ # schema, everything else by shape.
311
+ def anonymize(query, response)
312
+ response.to_h do |key, value|
313
+ [key, (key == "data") ? data_value(query, value) : untyped_value(key, value)]
314
+ end
315
+ end
270
316
 
317
+ private
318
+
319
+ def data_value(query, data)
320
+ return if data.nil?
321
+
322
+ operation = load_operation(query)
271
323
  object_value(operation_root_type(operation), operation.selections, data)
272
324
  end
273
325
 
274
- private
326
+ # No schema stands behind errors or extensions, so shape is all there
327
+ # is to preserve: keys, nesting, list lengths, nulls and booleans
328
+ # survive; every string and number is replaced.
329
+ def untyped_value(key, value)
330
+ return value if VERBATIM_KEYS.include?(key)
331
+
332
+ case value
333
+ when Hash then value.to_h { |name, nested| [name, untyped_value(name, nested)] }
334
+ when Array then value.map { |element| untyped_value(key, element) }
335
+ when String then @values.scalar("String", key)
336
+ when Integer then @values.scalar("Int", key)
337
+ when Float then @values.scalar("Float", key)
338
+ else value
339
+ end
340
+ end
275
341
 
276
342
  # Anonymization walks recorded data, not a live dispatch. When the query
277
343
  # narrows an abstract type without selecting __typename (`named { name
@@ -295,42 +361,45 @@ module GraphWeaver
295
361
  end
296
362
 
297
363
  result = {}
298
- each_field(type, selections) do |key, node|
364
+ # gather (not each_field) so a key selected twice — `a { x } a { y }` —
365
+ # keeps the MERGED shape codegen's struct expects, not last-writer-wins
366
+ gather(type, selections).each do |key, nodes|
299
367
  next unless data.key?(key)
300
368
 
369
+ node = nodes.first
301
370
  result[key] = if node.name == "__typename"
302
371
  data[key]
303
372
  else
304
- field_value(type, node, data[key])
373
+ field_value(type, node.name, nodes.flat_map(&:selections), data[key])
305
374
  end
306
375
  end
307
376
 
308
377
  result
309
378
  end
310
379
 
311
- def field_value(parent_type, node, value)
380
+ def field_value(parent_type, name, selections, value)
312
381
  # a field from a `... on Member` fragment lives on the member, not the
313
382
  # abstract type we're walking (no __typename to narrow by), so fall back
314
383
  # to whichever possible type declares it
315
- field = @schema.get_field(parent_type.graphql_name, node.name) ||
316
- @schema.possible_types(parent_type).filter_map { |t| @schema.get_field(t.graphql_name, node.name) }.first
317
- type_value(field.type, node, value)
384
+ field = @schema.get_field(parent_type.graphql_name, name) ||
385
+ @schema.possible_types(parent_type).filter_map { |t| @schema.get_field(t.graphql_name, name) }.first
386
+ type_value(field.type, name, selections, value)
318
387
  end
319
388
 
320
- def type_value(type, node, value)
389
+ def type_value(type, name, selections, value)
321
390
  return if value.nil? # preserve null positions
322
391
 
323
392
  case type.kind.name
324
393
  when "NON_NULL"
325
- type_value(type.of_type, node, value)
394
+ type_value(type.of_type, name, selections, value)
326
395
  when "LIST"
327
- value.map { |element| type_value(type.of_type, node, element) }
396
+ value.map { |element| type_value(type.of_type, name, selections, element) }
328
397
  when "SCALAR"
329
- scalar_value(type.graphql_name, node.name, value)
398
+ scalar_value(type.graphql_name, name, value)
330
399
  when "ENUM"
331
400
  value # enums aren't PII; preserving them keeps semantics
332
401
  when "OBJECT", "UNION", "INTERFACE"
333
- object_value(type, node.selections, value)
402
+ object_value(type, selections, value)
334
403
  else
335
404
  value
336
405
  end
@@ -41,15 +41,8 @@ module GraphWeaver
41
41
  def initialize(supergraph:, queries: GraphWeaver.queries_paths, fragments: GraphWeaver.fragments_paths)
42
42
  source = supergraph.to_s
43
43
  table = GraphWeaver::SchemaLoader.routing_table(source)
44
- unless table.unsupported.empty?
45
- # the same refusal the Router makes at construction: with the table
46
- # incomplete, every number this would report is a guess
47
- raise Unplannable.new(
48
- "this supergraph uses federation constructs the local router doesn't read: " +
49
- table.unsupported.join("; "),
50
- category: :unsupported_federation,
51
- )
52
- end
44
+ # with the table incomplete, every number this would report is a guess
45
+ Unplannable.unsupported!(table)
53
46
 
54
47
  # Planning still runs with `absent` empty — a query is plannable or
55
48
  # not whoever is serving. Which subgraphs are *here* is asked
@@ -57,7 +50,7 @@ module GraphWeaver
57
50
  # says one resolves), and never refuses: with nothing loaded the
58
51
  # answer is simply "none", which is the SDL-alone CI run.
59
52
  @planner = Router::Planner.new(table:, schema: GraphWeaver::SchemaLoader.load(source))
60
- @absent = table.subgraphs.reject { |name| Subgraphs.candidates(table, name).any? }
53
+ @absent = table.subgraphs.reject { |name| Subgraphs.served?(table, name) }
61
54
  @local = @absent.size < table.subgraphs.size
62
55
  @shared = GraphWeaver::Codegen.load_fragments(fragments)
63
56
  @results = GraphWeaver.query_files(queries).map { |path| measure(path) }
@@ -144,8 +137,10 @@ module GraphWeaver
144
137
  # the directory every query came from, so the report's file column is
145
138
  # filenames rather than the same path repeated
146
139
  def shared_dir
147
- dirs = @results.map { |result| File.dirname(result.path) }.uniq
148
- dirs.one? ? "#{dirs.first}/" : ""
140
+ @shared_dir ||= begin
141
+ dirs = @results.map { |result| File.dirname(result.path) }.uniq
142
+ dirs.one? ? "#{dirs.first}/" : ""
143
+ end
149
144
  end
150
145
 
151
146
  def measure(path)
@@ -51,8 +51,10 @@ module GraphWeaver
51
51
  end
52
52
 
53
53
  def throttled
54
- # array-wrapped so the hash can't parse as kwargs
55
- graphql([{ message: "rate limited", extensions: { code: "THROTTLED" } }])
54
+ # a code from the list #throttled? recognizes, not one spelled here —
55
+ # a fake that doesn't trip the predicate it exists to exercise is worse
56
+ # than no fake. (array-wrapped so the hash can't parse as kwargs)
57
+ graphql([{ message: "rate limited", extensions: { code: GraphWeaver::GraphQLError::THROTTLE_CODES.first } }])
56
58
  end
57
59
 
58
60
  # A validation-shaped rejection — trips schema_stale? and its
@@ -96,7 +96,7 @@ class GraphWeaver::Testing::FakeClient
96
96
  @schema = schema || config.schema || raise(GraphWeaver::Error,
97
97
  "no schema to fake against — set GraphWeaver::Testing.config.schema, pass schema:, " \
98
98
  "or commit a schema dump at #{GraphWeaver.schema_path}")
99
- @overrides = config.overrides.merge(overrides)
99
+ @overrides = config.overrides.merge(overrides).transform_keys(&:to_s)
100
100
  GraphWeaver::Testing.validate_overrides!(@schema, @overrides)
101
101
  @values = GraphWeaver::Testing::Values.new(seed:, mode:)
102
102
  @list_size = list_size || config.list_size
@@ -6,6 +6,7 @@ require "json"
6
6
 
7
7
  require_relative "../parsing"
8
8
  require_relative "../schema_loader"
9
+ require_relative "../selection"
9
10
  require_relative "../transport"
10
11
  require_relative "subgraphs"
11
12
 
@@ -116,6 +117,21 @@ module GraphWeaver
116
117
  # the short label a report groups this refusal under
117
118
  def label = CATEGORIES.fetch(category).first
118
119
 
120
+ # Refuse a supergraph the routing table couldn't read whole. An unread
121
+ # @join__ construct leaves the table incomplete, so every answer drawn
122
+ # from it is a guess — including which schema serves which subgraph, so
123
+ # this comes before resolving those. Router and Coverage both refuse at
124
+ # construction, before any query, and say it the same way.
125
+ def self.unsupported!(table)
126
+ return if table.unsupported.empty?
127
+
128
+ raise new(
129
+ "this supergraph uses federation constructs the local router doesn't read: " +
130
+ table.unsupported.join("; "),
131
+ category: :unsupported_federation,
132
+ )
133
+ end
134
+
119
135
  def to_h = super.merge("category" => category.to_s, "detail" => detail)
120
136
  end
121
137
 
@@ -146,12 +162,12 @@ module GraphWeaver
146
162
  # the object the SDL spells rather than as a flattened path; and a union
147
163
  # or interface at a boundary, planned per concrete type and bucketed on
148
164
  # the `__typename` the data comes back with.
149
- # Everything it can't plan
150
- # *faithfully* raises {Unplannable}, before any subgraph runs, so a
151
- # refusal can never be a half-executed query. Apollo's planner is twenty
152
- # thousand lines; a double that approximated the rest of it would let a
153
- # test pass on an answer production disagrees with, which is the most
154
- # expensive thing this library can produce.
165
+ #
166
+ # Everything it can't plan *faithfully* raises {Unplannable}, before any
167
+ # subgraph runs, so a refusal can never be a half-executed query. Apollo's
168
+ # planner is twenty thousand lines; a double that approximated the rest of
169
+ # it would let a test pass on an answer production disagrees with, which is
170
+ # the most expensive thing this library can produce.
155
171
  #
156
172
  # Introspection is answered from the composed API schema — never from a
157
173
  # subgraph, which would reply with its own slice. That is the one split a
@@ -224,6 +240,12 @@ module GraphWeaver
224
240
  end
225
241
  end
226
242
 
243
+ # one error in the shape a GraphQL response carries them — a class
244
+ # method because the Planner refuses documents before a Router exists
245
+ def self.graphql_error(message, code)
246
+ { "message" => message, "extensions" => { "code" => code } }
247
+ end
248
+
227
249
  # subgraphs: names the Ruby schema serving each subgraph. Omit it (or
228
250
  # any of its entries) and the rest are derived from what each loaded
229
251
  # schema defines — see {Subgraphs}, which also checks the ones you name.
@@ -236,17 +258,7 @@ module GraphWeaver
236
258
  @context = context
237
259
  @trace = []
238
260
 
239
- # refuse at construction, not per query: an unread @join__ construct
240
- # means the routing table is incomplete, and every answer it gives
241
- # about this supergraph is a guess — including which schema serves
242
- # which subgraph, so this comes before resolving those
243
- unless @table.unsupported.empty?
244
- raise Unplannable.new(
245
- "this supergraph uses federation constructs the local router doesn't read: " +
246
- @table.unsupported.join("; "),
247
- category: :unsupported_federation,
248
- )
249
- end
261
+ Unplannable.unsupported!(@table)
250
262
 
251
263
  served = Subgraphs.resolve(@table, subgraphs)
252
264
  @faked = served.select { |_name, schema| schema == Subgraphs::FAKE }.keys.freeze
@@ -257,19 +269,30 @@ module GraphWeaver
257
269
  @planner = Planner.new(table: @table, schema: @schema, absent: @absent)
258
270
  end
259
271
 
260
- # Drop the fetches recorded so far. A router is built once and reused
261
- # (the rspec tag builds one per suite), so without this #trace answers
262
- # about whatever ran before as well.
272
+ # Drop the fetches recorded so far, so #trace answers about what runs
273
+ # next. For counting fetches across part of a run the whole example
274
+ # boundary is #reset!.
263
275
  def reset_trace
264
276
  @trace = []
265
277
  self
266
278
  end
267
279
 
280
+ # An example boundary. A router is built once and reused (the rspec tag
281
+ # builds one per suite), so a faked subgraph would otherwise keep
282
+ # fabricating from wherever the previous example left its sequence —
283
+ # making the same example give different data alone than in a full run,
284
+ # which is exactly what `rspec --seed` promises it won't.
285
+ def reset!
286
+ reset_trace
287
+ @faked.each { |name| @subgraphs[name] = FakeSubgraph.new(name, @schema) }
288
+ self
289
+ end
290
+
268
291
  def execute(query, variables: {}, operation_name: nil)
269
292
  document = begin
270
293
  GraphQL.parse(query)
271
294
  rescue GraphQL::ParseError => e
272
- return { "data" => nil, "errors" => [graphql_error(e.message, "GRAPHQL_PARSE_FAILED")] }
295
+ return { "data" => nil, "errors" => [Router.graphql_error(e.message, "GRAPHQL_PARSE_FAILED")] }
273
296
  end
274
297
 
275
298
  # validate the way a router does, so a stale query fails as it fails
@@ -341,13 +364,16 @@ module GraphWeaver
341
364
  response
342
365
  end
343
366
 
344
- # Everything the plan applies at this level: one _entities fetch per
345
- # subgraph the level defers to (all nodes at once _entities answers
346
- # in representation order), then the same again one level down.
367
+ # Only for evaluating @skip/@include, which read Booleans so only the
368
+ # scalar defaults the parser hands back as Ruby values are wanted. An
369
+ # enum or input-object default is an AST node; sending one to a subgraph
370
+ # puts a parser back-pointer on the wire, or raises inside JSON. The
371
+ # subgraph applies those itself: used_variables copies each declaration
372
+ # verbatim, defaults and all.
347
373
  def variable_defaults(operation)
348
374
  operation.variables.each_with_object({}) do |definition, defaults|
349
375
  value = definition.default_value
350
- next if value.nil? || value.is_a?(GraphQL::Language::Nodes::NullValue)
376
+ next unless value == true || value == false
351
377
 
352
378
  defaults[definition.name] = value
353
379
  end
@@ -358,7 +384,7 @@ module GraphWeaver
358
384
  # and includes under @skip — the same way graphql-ruby resolves it.
359
385
  def included?(node, variables)
360
386
  node.directives.all? do |directive|
361
- next true unless %w[skip include].include?(directive.name)
387
+ next true unless GraphWeaver::Selection::CONDITIONAL_DIRECTIVES.include?(directive.name)
362
388
 
363
389
  argument = directive.arguments.find { |arg| arg.name == "if" } or next true
364
390
  value = argument.value
@@ -368,6 +394,9 @@ module GraphWeaver
368
394
  end
369
395
  end
370
396
 
397
+ # Everything the plan applies at this level: one _entities fetch per
398
+ # subgraph the level defers to (all nodes at once — _entities answers
399
+ # in representation order), then the same again one level down.
371
400
  def stitch(step, nodes, operation, variables, errors)
372
401
  return if nodes.empty?
373
402
 
@@ -386,14 +415,14 @@ module GraphWeaver
386
415
 
387
416
  blocked = prefetch(step, nodes, operation, variables, errors)
388
417
 
389
- # a @requires fetch and a plain one need different node sets, so they
390
- # can't share a call even into the same subgraph — which is the split
391
- # a real router makes too
392
418
  # A fetch for a selection the operation excluded is a fetch a real
393
419
  # router never makes, and `trace` is something specs assert on. The
394
420
  # plan is built once and reused, so only here are the variables known.
395
421
  wanted = step.deferrals.select { |d| included?(d.node, variables) }
396
422
 
423
+ # a @requires fetch and a plain one need different node sets, so they
424
+ # can't share a call even into the same subgraph — which is the split
425
+ # a real router makes too
397
426
  wanted.group_by { |d| [d.subgraph, d.requires.any?] }.each do |(target, chained), deferrals|
398
427
  fetched = chained ? nodes.reject { |(node, _)| blocked.include?(node.object_id) } : nodes
399
428
  tree = Router.field_tree(deferrals.flat_map(&:representation).uniq)
@@ -609,10 +638,6 @@ module GraphWeaver
609
638
  end
610
639
  end
611
640
 
612
- def graphql_error(message, code)
613
- { "message" => message, "extensions" => { "code" => code } }
614
- end
615
-
616
641
  # ---- null propagation ---------------------------------------------
617
642
 
618
643
  # a position whose type forbids null but whose value is null: the
@@ -634,7 +659,10 @@ module GraphWeaver
634
659
  # __typename the fetch bucketed by says what this object is. Without
635
660
  # one the subtree ran whole in one subgraph, which already applied
636
661
  # its own propagation — there is nothing to redo.
637
- concrete = value.is_a?(Hash) ? @schema.types[value[TYPENAME] || value["__typename"]] : nil
662
+ # get_type, not types[]: the latter merges every type through a
663
+ # visibility filter into a fresh hash, and this runs once per
664
+ # response row — so it would cost rows x schema size
665
+ concrete = value.is_a?(Hash) ? @schema.get_type(value[TYPENAME] || value["__typename"]) : nil
638
666
  return value unless concrete&.kind&.fields?
639
667
 
640
668
  propagate_object(value, concrete, @planner.narrow(concrete.graphql_name, selections, fragments), fragments)
@@ -782,9 +810,8 @@ module GraphWeaver
782
810
 
783
811
  # the operation's validation errors, GraphQL-wire shaped
784
812
  def validate(document)
785
- @schema.validate(document).map do |error|
786
- { "message" => error.message, "extensions" => { "code" => "GRAPHQL_VALIDATION_FAILED" } }
787
- end
813
+ @schema.validate(document)
814
+ .map { |error| Router.graphql_error(error.message, "GRAPHQL_VALIDATION_FAILED") }
788
815
  end
789
816
 
790
817
  def plan(document, operation_name: nil)
@@ -897,7 +924,7 @@ module GraphWeaver
897
924
  def applies?(condition, concrete)
898
925
  return true if condition.nil? || condition == concrete
899
926
 
900
- type = @schema.types[condition]
927
+ type = @schema.get_type(condition)
901
928
  !!type&.kind&.abstract? && @schema.possible_types(type).any? { |t| t.graphql_name == concrete }
902
929
  end
903
930
 
@@ -1028,7 +1055,7 @@ module GraphWeaver
1028
1055
  def plan_child(type_name, node, subgraph, field, fragments, depth)
1029
1056
  child_type = child_type_name(type_name, node.name)
1030
1057
  return plan_branches(type_name, node, child_type, subgraph, field, fragments, depth) if
1031
- @schema.types[child_type]&.kind&.abstract?
1058
+ @schema.get_type(child_type)&.kind&.abstract?
1032
1059
 
1033
1060
  plan_step(child_type, narrow(child_type, node.selections, fragments), subgraph, fragments,
1034
1061
  provides(field), depth + 1)
@@ -1206,13 +1233,7 @@ module GraphWeaver
1206
1233
  # Dotted paths back to the selection set they were parsed from — the
1207
1234
  # inverse of RoutingTable.parse_field_set, so a refusal spells the
1208
1235
  # field set the way the schema does and is greppable against it.
1209
- def field_set(paths)
1210
- tree = {}
1211
- paths.each do |path|
1212
- path.split(".").reduce(tree) { |node, segment| node[segment] ||= {} }
1213
- end
1214
- render_field_set(tree)
1215
- end
1236
+ def field_set(paths) = render_field_set(Router.field_tree(paths))
1216
1237
 
1217
1238
  def render_field_set(tree)
1218
1239
  tree.map { |name, children|
@@ -1415,7 +1436,7 @@ module GraphWeaver
1415
1436
  end
1416
1437
 
1417
1438
  def raw_child_type(type_name, field_name)
1418
- type = @schema.types[type_name]
1439
+ type = @schema.get_type(type_name)
1419
1440
  return unless type.respond_to?(:fields)
1420
1441
 
1421
1442
  field = type.fields[field_name] or return
@@ -45,13 +45,7 @@ module GraphWeaver
45
45
  # Names in `given` skip detection (:fake included); the rest are
46
46
  # derived, and both go through the same check.
47
47
  def resolve(table, given = nil, schemas: nil)
48
- named = (given || {}).to_h { |name, schema| [name.to_s, schema] }
49
- unknown = named.keys - table.subgraphs
50
- if unknown.any?
51
- raise GraphWeaver::ConfigurationError, "subgraphs: names #{unknown.join(", ")}, which " \
52
- "this supergraph doesn't have (its subgraphs are #{table.subgraphs.join(", ")})"
53
- end
54
-
48
+ named = table.named_subgraphs(given)
55
49
  searched = schemas || GraphWeaver::Schemas.loaded
56
50
  table.subgraphs.filter_map do |name|
57
51
  served = named.key?(name) ? check!(table, name, named[name]) : detect(table, name, searched)
@@ -81,6 +75,16 @@ module GraphWeaver
81
75
  expected(table, name).reject { |coordinate| GraphWeaver::Schemas.defines?(schema, coordinate) }
82
76
  end
83
77
 
78
+ # Is this subgraph served in this process? Exactly one candidate is
79
+ # what that means: none is somebody else's service, and several is a
80
+ # question only the caller can answer, so neither is something a suite
81
+ # can run against. `detect` turns the several into a refusal at
82
+ # construction; a report counts it as not served here, which is the
83
+ # same verdict phrased for something that never raises.
84
+ def served?(table, name, schemas = GraphWeaver::Schemas.loaded)
85
+ candidates(table, name, schemas).one?
86
+ end
87
+
84
88
  private
85
89
 
86
90
  # The schema serving `name`, or nil when nothing here does — the
@@ -50,8 +50,11 @@ module GraphWeaver
50
50
  CLIENT_MODES = %i[fake in_process router].freeze
51
51
 
52
52
  class Config
53
- attr_accessor :schema, :overrides, :seed, :list_size, :null_chance, :cassette_dir, :context,
53
+ attr_accessor :overrides, :seed, :list_size, :null_chance, :cassette_dir, :context,
54
54
  :record, :anonymize
55
+ # #schema is written plainly and read with a fallback (below), the way
56
+ # #mode, #router and #default_mode are read plainly and written with a check
57
+ attr_writer :schema
55
58
  attr_reader :mode, :router, :default_mode
56
59
 
57
60
  def initialize
@@ -169,7 +172,10 @@ module GraphWeaver
169
172
  # client already runs in-process. Only a live class has resolvers, so
170
173
  # there is nothing else to fall back to: a dump is type information.
171
174
  def schema_class!
172
- runnable(schema) || GraphWeaver.live_schema ||
175
+ # explicit_schema, not schema: the latter falls back to the committed
176
+ # dump, which loads as an anonymous GraphQL::Schema subclass — runnable
177
+ # by every test that matters, and holding not one resolver.
178
+ runnable(explicit_schema) || GraphWeaver.live_schema ||
173
179
  raise(GraphWeaver::Error, ":in_process runs your resolvers, so it needs the live " \
174
180
  "GraphQL::Schema class — and GraphWeaver.client isn't running one in-process to " \
175
181
  "borrow. Name it in the example — graphql_in_process(MySchema) — or set " \
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.5.0"
2
+ VERSION = "0.5.1"
3
3
  end
data/lib/graph_weaver.rb CHANGED
@@ -68,6 +68,27 @@ module GraphWeaver
68
68
  # body has to brand rather than escape as a raw Sorbet TypeError from a sig
69
69
  # (which fires before the struct's own rescue can see it). Lives here rather
70
70
  # than unrolled into every generated module.
71
+ # Cast a response's data, keeping the server's own errors on a failure.
72
+ # The common cause of a cast failure is a field that came back null *with
73
+ # a reason attached* — a permission rule, a partial outage — and raising
74
+ # only Sorbet's nil complaint throws that reason away, leaving whoever is
75
+ # on call with a type error and no explanation.
76
+ sig do
77
+ params(struct: T.untyped, data: T.untyped, errors: T::Array[GraphWeaver::GraphQLError])
78
+ .returns(T.untyped)
79
+ end
80
+ def cast_data(struct, data, errors)
81
+ struct.from_h(data)
82
+ rescue GraphWeaver::TypeError => e
83
+ raise if errors.empty?
84
+
85
+ detail = e.message.delete_prefix("failed to cast response into #{struct}: ")
86
+ raise GraphWeaver::TypeError.new(
87
+ struct:,
88
+ message: "#{detail} — the server also reported: #{errors.map(&:message).join("; ")}",
89
+ )
90
+ end
91
+
71
92
  def check_envelope!(raw, struct)
72
93
  unless raw.is_a?(Hash)
73
94
  raise GraphWeaver::TypeError.new(struct:, message: "response must be an object, got #{raw.class}")
@@ -131,11 +152,6 @@ module GraphWeaver
131
152
  # read. A second schema is a second generate! (schema: names it).
132
153
  attr_writer :schema_path
133
154
 
134
- # Set by every graph_weaver rake task: those tasks WRITE the generated
135
- # files, so loading them first lets a stale one block its own repair.
136
- # None of them needs the modules loaded.
137
- attr_accessor :skip_generated_load
138
-
139
155
  def queries_paths = @queries_paths ||= ["app/graphql/queries"]
140
156
  def generated_paths = @generated_paths ||= ["app/graphql/generated", "app/graphql/*/generated"]
141
157
 
@@ -159,6 +175,11 @@ module GraphWeaver
159
175
 
160
176
  def schema_path = @schema_path || "app/graphql/schema.json"
161
177
 
178
+ # Set by every graph_weaver rake task: those tasks WRITE the generated
179
+ # files, so loading them first lets a stale one block its own repair.
180
+ # None of them needs the modules loaded.
181
+ attr_accessor :skip_generated_load
182
+
162
183
  # Every query document under these directories, sorted — the files
163
184
  # generate!, verify_generated!, check_queries and load_queries! all read.
164
185
  def query_files(paths = queries_paths)
@@ -324,10 +345,11 @@ module GraphWeaver
324
345
 
325
346
  # locate_schema! raises the conventional "no schema dump" message
326
347
  path = SchemaLoader.locate_path or locate_schema!
327
- meta = SchemaLoader.provenance(path)
328
- return SchemaLoader.load(path) unless meta&.key?("url")
348
+ return SchemaLoader.load(path) unless SchemaLoader.provenance(path)&.key?("url")
329
349
 
330
- SchemaLoader.introspect(new(meta["url"], auth: ENV["GRAPHWEAVER_AUTH"]).transport)
350
+ # source_transport rather than one built here: it reads the auth ENV var
351
+ # the dump named, so `--auth MY_TOKEN` reaches this path too
352
+ SchemaLoader.introspect(SchemaLoader.source_transport(path))
331
353
  end
332
354
  private :refreshed_schema
333
355
 
@@ -607,15 +629,18 @@ module GraphWeaver
607
629
  #
608
630
  # schema: is a graphql-ruby schema or a Client (its schema, and its
609
631
  # transport as the module's default). query is a .graphql/.gql path (module
610
- # name derived from the file name
611
- # and the operation see #module_name) or a raw query string (name
612
- # derived from the operation name,
613
- # falling back to "Query" for anonymous operations collisions are
614
- # impossible since each parse gets its own container). Pass name: to
615
- # override, client: to bake the module's default client/transport.
632
+ # name derived from the file name and the operation — see #module_name) or
633
+ # a raw query string (name derived from the operation name, falling back to
634
+ # "Query" for anonymous operations — collisions are impossible since each
635
+ # parse gets its own container). Pass name: to override, client: to bake
636
+ # the module's default client/transport.
616
637
  def parse(schema:, query:, name: nil, client: nil, fragments: fragments_paths)
617
638
  client ||= schema if schema.is_a?(Client)
618
639
  schema = schema_for(schema)
640
+ # Rails.root.join(...) hands you a Pathname, and to_path is the
641
+ # ecosystem's "I am a path" — the same conversion schema: gets through
642
+ # SchemaLoader.load. Without it end_with? below is a NoMethodError.
643
+ query = query.to_path if query.respond_to?(:to_path)
619
644
  path = query if query.end_with?(".graphql", ".gql")
620
645
  if path
621
646
  query = File.read(path)