graph_weaver 0.4.6 → 0.5.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1314 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -154
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -258
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -124
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +593 -334
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +9 -1
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +14 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +7 -0
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +794 -59
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +43 -8
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +160 -61
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +181 -21
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +28 -10
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +67 -14
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +389 -170
  62. metadata +20 -3
@@ -7,33 +7,88 @@ require "yaml"
7
7
 
8
8
  module GraphWeaver
9
9
  module Testing
10
- # Raised by Replayer when a request has no recording.
10
+ # Raised by Replayer when a request has no recording. The query
11
+ # usually matches and the variables don't, so the variables lead and
12
+ # the recorded ones for the same query come next — the diff you'd
13
+ # otherwise do by eye against the YAML.
11
14
  class MissingRecording < GraphWeaver::Error
12
- def initialize(path:, query:)
13
- super(<<~MSG.strip)
14
- no recording for this request in #{path} — re-record it
15
- (Recorder / Cassette.use with a live client, or delete
16
- the cassette to start over). Query:
17
- #{query.strip[0, 200]}
18
- MSG
15
+ # how many recorded variable sets to print before summarizing
16
+ SHOWN = 5
17
+
18
+ def initialize(path:, query:, variables:, recorded:, size:)
19
+ super([
20
+ "no recording for this request in #{path}",
21
+ " variables: #{Cassette.normalize_variables(variables).inspect}",
22
+ " #{self.class.recorded_summary(recorded, size)}",
23
+ " query: #{Cassette.summarize(query)}",
24
+ "re-record it (GRAPHWEAVER_RECORD=1 with a client:), or delete the cassette to start over.",
25
+ ].join("\n"))
26
+ end
27
+
28
+ def self.recorded_summary(recorded, size)
29
+ return "no entry recorded for this query (#{size} in the cassette)" if recorded.empty?
30
+
31
+ more = recorded.size > SHOWN ? " (+#{recorded.size - SHOWN} more)" : ""
32
+ "#{recorded.size} #{(recorded.size == 1) ? "entry" : "entries"} recorded for this query, " \
33
+ "with variables #{recorded.first(SHOWN).map(&:inspect).join(", ")}#{more}"
19
34
  end
20
35
  end
21
36
 
22
- # Capture/replay above the transport (no HTTP interception): a
23
- # cassette is a YAML file of {query, variables, response} entries,
24
- # keyed on the normalized query + variables.
25
- #
26
- # # record against a real client, replay when the file exists:
27
- # client = GraphWeaver::Testing::Cassette.use("github", client: real)
28
- #
29
- # Cassettes hold real responses — anonymize before committing:
30
- #
31
- # Cassette.new("spec/cassettes/github.yml").anonymize!(schema:)
32
- #
33
- # keeps every shape (list lengths, null positions, enums, __typename,
34
- # id relationships via a consistent mapping) while replacing values
35
- # with fakes, semantically matched where field names allow.
37
+ class << self
38
+ # The cassette-backed client: replays spec/cassettes/<name>.yml when
39
+ # it exists, records it through client: when it doesn't (VCR's once
40
+ # mode). Record mode (GRAPHWEAVER_RECORD=1 / config.record) always
41
+ # records, so it needs a client: too.
42
+ #
43
+ # client = GraphWeaver::Testing.cassette("github", client: live)
44
+ # result = RepoQuery.execute!(client:, owner: "dpep")
45
+ #
46
+ def cassette(name, client: nil)
47
+ file = Cassette.new(name)
48
+
49
+ if client && (config.record || !file.exist?)
50
+ Recorder.new(client, file)
51
+ elsif config.record
52
+ # record mode without a client would quietly serve the stale
53
+ # 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} " \
55
+ "— pass a live `client:` to re-record it, or turn record mode off " \
56
+ "(GRAPHWEAVER_RECORD / Testing.config.record)."
57
+ elsif file.exist?
58
+ Replayer.new(file)
59
+ else
60
+ # 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."
63
+ end
64
+ end
65
+ end
66
+
67
+ # The cassette file itself: a YAML list of {query, variables, response}
68
+ # entries. Testing.cassette wraps one in a record/replay client; this is
69
+ # the file object behind it — and what the anonymize rake task rewrites.
36
70
  class Cassette
71
+ # What replaying one cassette through the current generated modules
72
+ # found. `checked` is how many recordings a module claimed: a run that
73
+ # claimed none proved nothing, which is a different answer from "all
74
+ # good" — the same distinction `federation:diff` draws.
75
+ Check = Struct.new(:path, :checked, :skipped, :stale, keyword_init: true) do
76
+ def ok? = stale.empty?
77
+
78
+ def report
79
+ counted = ["#{checked} checked"]
80
+ counted << "#{skipped} not sent by any query module" if skipped.positive?
81
+
82
+ ["#{path}: #{stale.size} stale (#{counted.join(", ")})"] +
83
+ stale.flat_map do |entry|
84
+ [" #{entry.module_name} #{entry.variables.inspect}", " #{entry.message}"]
85
+ end
86
+ end
87
+ end
88
+
89
+ # One recording the generated structs can no longer read.
90
+ Stale = Struct.new(:module_name, :variables, :message, keyword_init: true)
91
+
37
92
  attr_reader :path
38
93
 
39
94
  def initialize(path)
@@ -44,40 +99,62 @@ module GraphWeaver
44
99
  def exist? = File.exist?(@path)
45
100
  def size = @entries.size
46
101
 
47
- # Replay when recorded, record when not (VCR's once mode).
48
- # client: is required to record; omit it to replay-or-raise.
49
- # With Testing.config.record on (or GRAPHWEAVER_RECORD=1), always
50
- # records — the "just re-record everything" switch.
51
- def self.use(path, client: nil)
52
- cassette = new(path)
53
- if Testing.config.record && client
54
- Recorder.new(client, cassette)
55
- elsif cassette.exist?
56
- Replayer.new(cassette)
57
- elsif client
58
- Recorder.new(client, cassette)
59
- else
60
- raise MissingRecording.new(path: cassette.path, query: "(no client to record with)")
61
- end
102
+ def lookup(query, variables, operation_name = nil)
103
+ wanted = self.class.key(query, variables, operation_name)
104
+ @entries.find { |entry| self.class.entry_key(entry) == wanted }
62
105
  end
63
106
 
64
- def lookup(query, variables)
65
- wanted = self.class.key(query, variables)
66
- @entries.find { |entry| entry["key"] == wanted }
107
+ # every variables hash recorded for this query — what a miss needs
108
+ # to show, since the variables are what usually differ
109
+ def variants(query, operation_name = nil)
110
+ normalized = self.class.normalize_query(query)
111
+ @entries.select do |entry|
112
+ self.class.normalize_query(entry["query"]) == normalized && entry["operationName"] == operation_name
113
+ end.map { |entry| entry["variables"] || {} }
67
114
  end
68
115
 
69
- def record(query, variables, response)
70
- entry = {
71
- "key" => self.class.key(query, variables),
72
- "query" => query,
73
- "variables" => self.class.normalize_variables(variables),
74
- "response" => response,
75
- }
76
- @entries.reject! { |existing| existing["key"] == entry["key"] }
116
+ def record(query, variables, response, operation_name = nil)
117
+ entry = { "query" => query }
118
+ entry["operationName"] = operation_name if operation_name
119
+ entry["variables"] = self.class.normalize_variables(variables)
120
+ entry["response"] = response
121
+
122
+ wanted = self.class.key(query, variables, operation_name)
123
+ @entries.reject! { |existing| self.class.entry_key(existing) == wanted }
77
124
  @entries << entry
78
125
  save
79
126
  end
80
127
 
128
+ # Replay every recording through `modules` — the generated query
129
+ # modules — and report the ones that no longer cast.
130
+ #
131
+ # A cassette is the one artifact here recorded from a *foreign* server,
132
+ # and nothing else notices when that server's answers drift out of the
133
+ # shape the structs were generated for: `verify`, `queries:check` and
134
+ # `schema:diff` all ask about the local side. Without this the drift
135
+ # surfaces mid-spec as a `TypeError` naming a struct and a sorbet
136
+ # frame, with nothing pointing at the stale file.
137
+ #
138
+ # Matching is on the query text, which is the module that sent it — a
139
+ # recording no module sends is skipped rather than guessed at.
140
+ def check(modules)
141
+ index = modules.to_h { |mod| [self.class.normalize_query(mod.const_get(:QUERY)), mod] }
142
+ checked = 0
143
+ stale = @entries.filter_map do |entry|
144
+ mod = index[self.class.normalize_query(entry["query"])] or next
145
+ checked += 1
146
+
147
+ begin
148
+ mod.from_response(entry["response"])
149
+ nil
150
+ rescue GraphWeaver::Error => e
151
+ Stale.new(module_name: mod.name, variables: entry["variables"] || {}, message: e.message)
152
+ end
153
+ end
154
+
155
+ Check.new(path: @path, checked:, skipped: @entries.size - checked, stale:)
156
+ end
157
+
81
158
  # Replace recorded response values with fakes, preserving structure.
82
159
  # Walks each entry's query against the schema (like FakeClient,
83
160
  # but transforming what's there instead of generating from scratch).
@@ -91,8 +168,27 @@ module GraphWeaver
91
168
  self
92
169
  end
93
170
 
94
- def self.key(query, variables)
95
- { "query" => query.gsub(/\s+/, " ").strip, "variables" => normalize_variables(variables) }
171
+ # The request's identity, exactly as the server sees it. operationName
172
+ # is part of that: it picks the operation the document runs, so two
173
+ # requests with identical text but different names are different
174
+ # requests. Derived, never stored — the file holds the request once,
175
+ # so a hand-edited entry can't disagree with what replay matches on.
176
+ def self.key(query, variables, operation_name = nil)
177
+ key = { "query" => normalize_query(query), "variables" => normalize_variables(variables) }
178
+ key["operationName"] = operation_name if operation_name
179
+ key
180
+ end
181
+
182
+ def self.entry_key(entry)
183
+ key(entry["query"], entry["variables"], entry["operationName"])
184
+ end
185
+
186
+ def self.normalize_query(query) = query.gsub(/\s+/, " ").strip
187
+
188
+ # one readable line: an error naming a 60-line query is a wall, not a hint
189
+ def self.summarize(query, limit: 160)
190
+ normalized = normalize_query(query)
191
+ (normalized.length > limit) ? "#{normalized[0, limit]}…" : normalized
96
192
  end
97
193
 
98
194
  # JSON round-trip so symbol keys become strings — otherwise YAML.dump
@@ -111,16 +207,16 @@ module GraphWeaver
111
207
  end
112
208
 
113
209
  # Tees requests through a live client and records every response.
114
- # With Testing.config.anonymize (or anonymize: true), responses are
115
- # anonymized as they're recorded — and the anonymized version is what
116
- # the caller sees too, so assertions written now hold on replay.
210
+ # With Testing.config.anonymize, responses are anonymized as they're
211
+ # recorded — and the anonymized version is what the caller sees too,
212
+ # so assertions written now hold on replay.
117
213
  class Recorder
118
- def initialize(client, cassette, anonymize: nil)
214
+ def initialize(client, cassette)
119
215
  @client = client
120
216
  @cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
121
217
 
122
218
  config = Testing.config
123
- if anonymize.nil? ? config.anonymize : anonymize
219
+ if config.anonymize
124
220
  unless config.schema
125
221
  raise ArgumentError, "anonymizing recordings needs GraphWeaver::Testing.config.schema"
126
222
  end
@@ -129,13 +225,13 @@ module GraphWeaver
129
225
  end
130
226
  end
131
227
 
132
- def execute(query, variables: {})
133
- response = @client.execute(query, variables:).to_h
228
+ def execute(query, variables: {}, operation_name: nil)
229
+ response = @client.execute(query, variables:, operation_name:).to_h
134
230
  if @anonymizer && (data = response["data"])
135
231
  response = response.merge("data" => @anonymizer.anonymize(query, data))
136
232
  end
137
233
 
138
- @cassette.record(query, variables, response)
234
+ @cassette.record(query, variables, response, operation_name)
139
235
  response
140
236
  end
141
237
  end
@@ -147,9 +243,12 @@ module GraphWeaver
147
243
  @cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
148
244
  end
149
245
 
150
- def execute(query, variables: {})
151
- entry = @cassette.lookup(query, variables)
152
- raise MissingRecording.new(path: @cassette.path, query:) unless entry
246
+ def execute(query, variables: {}, operation_name: nil)
247
+ entry = @cassette.lookup(query, variables, operation_name)
248
+ unless entry
249
+ raise MissingRecording.new(path: @cassette.path, query:, variables:,
250
+ recorded: @cassette.variants(query, operation_name), size: @cassette.size)
251
+ end
153
252
 
154
253
  entry["response"]
155
254
  end
@@ -0,0 +1,165 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "graphql"
5
+
6
+ require_relative "router"
7
+
8
+ module GraphWeaver
9
+ module Testing
10
+ # How much of a query set the local {Router} can plan, and — for the rest
11
+ # — exactly what stopped it:
12
+ #
13
+ # rake graph_weaver:federation:coverage SUPERGRAPH=supergraph.graphql
14
+ #
15
+ # The router refuses every query that crosses a subgraph boundary, so its
16
+ # worth to a suite is one number: the fraction of *your* queries it can
17
+ # answer. Nobody can guess that from outside — it depends on the shape of
18
+ # your graph and the shape of your queries — so measure it before
19
+ # deciding the router is (or isn't) enough.
20
+ #
21
+ # Planning needs the supergraph and nothing else, so this runs without
22
+ # any subgraph being loadable, in CI or on a laptop with the SDL alone.
23
+ class Coverage
24
+ # One query file's verdict: `category` nil means the router can plan
25
+ # it, and `subgraph` is where it runs — "accounts+reviews" when the
26
+ # plan stitches across a boundary. `absent` names the subgraphs that
27
+ # plan reaches which nothing in this process serves — plannable and
28
+ # runnable-here are different questions.
29
+ Result = Struct.new(:path, :subgraph, :absent, :category, :detail) do
30
+ def servable? = category.nil? && absent.empty?
31
+ end
32
+
33
+ # the label each verdict groups under
34
+ LABELS = Unplannable::CATEGORIES
35
+ .transform_values(&:first)
36
+ .merge(invalid: "doesn't validate against the supergraph")
37
+ .freeze
38
+
39
+ attr_reader :results
40
+
41
+ def initialize(supergraph:, queries: GraphWeaver.queries_paths, fragments: GraphWeaver.fragments_paths)
42
+ source = supergraph.to_s
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
53
+
54
+ # Planning still runs with `absent` empty — a query is plannable or
55
+ # not whoever is serving. Which subgraphs are *here* is asked
56
+ # separately, from evidence (a loaded schema defining what the table
57
+ # says one resolves), and never refuses: with nothing loaded the
58
+ # answer is simply "none", which is the SDL-alone CI run.
59
+ @planner = Router::Planner.new(table:, schema: GraphWeaver::SchemaLoader.load(source))
60
+ @absent = table.subgraphs.reject { |name| Subgraphs.candidates(table, name).any? }
61
+ @local = @absent.size < table.subgraphs.size
62
+ @shared = GraphWeaver::Codegen.load_fragments(fragments)
63
+ @results = GraphWeaver.query_files(queries).map { |path| measure(path) }
64
+ end
65
+
66
+ def plannable = @results.count { |result| result.category.nil? }
67
+
68
+ # plannable *and* every subgraph the plan reaches is served here
69
+ def servable = @results.count(&:servable?)
70
+
71
+ # plannable, but reaching a subgraph another process serves
72
+ def elsewhere = @results.select { |result| result.category.nil? && result.absent.any? }
73
+
74
+ def refused = @results.reject { |result| result.category.nil? }
75
+
76
+ # whole percent: this is a count of a handful of files, and a decimal
77
+ # place would claim precision the sample doesn't have
78
+ def percent = @results.empty? ? 0 : (plannable * 100.0 / @results.size).round
79
+
80
+ def report
81
+ return "no queries found" if @results.empty?
82
+
83
+ [headline, *breakdown, *served_here, *refusals].join("\n")
84
+ end
85
+ alias to_s report
86
+
87
+ def inspect = "#<#{self.class.name} #{plannable}/#{@results.size} plannable>"
88
+
89
+ private
90
+
91
+ def headline
92
+ counted = "#{plannable}/#{@results.size} #{(@results.size == 1) ? "query" : "queries"} " \
93
+ "plannable locally (#{percent}%)"
94
+ @local ? "#{counted}, #{servable} servable here" : counted
95
+ end
96
+
97
+ # Plan-only is by design — a query is plannable whoever serves it — but
98
+ # the question this report exists to answer is whether wiring the router
99
+ # up is worth it, and a suite can only *run* what this process serves.
100
+ # A partly-local supergraph is the usual migration shape, so the second
101
+ # number has to be here rather than inferred from a refusal later.
102
+ def served_here
103
+ unless @local
104
+ return ["", "nothing here serves any of this supergraph's subgraphs " \
105
+ "(#{@absent.join(", ")}), so this counts planning only"]
106
+ end
107
+ return [] if elsewhere.empty?
108
+
109
+ example = elsewhere.first.absent.first
110
+ ["", "plannable, but nothing here serves what they reach (#{elsewhere.size}) — name a " \
111
+ "schema for those subgraphs, fake them (subgraphs: { #{example.inspect} => :fake }), " \
112
+ "or run these against a real router:"] +
113
+ elsewhere.map { |result| " #{name(result)} #{result.absent.join(", ")}" }
114
+ end
115
+
116
+ # where the plannable ones land — a graph whose queries all sit in one
117
+ # subgraph is a different situation from one that's evenly spread
118
+ def breakdown
119
+ by_subgraph = @results.filter_map(&:subgraph).tally.sort_by { |name, count| [-count, name] }
120
+ return [] if by_subgraph.empty?
121
+
122
+ [" " + by_subgraph.map { |name, count| "#{name} #{count}" }.join(", ")]
123
+ end
124
+
125
+ def refusals
126
+ return [] if refused.empty?
127
+
128
+ groups = refused.group_by(&:category).sort_by { |category, group| [-group.size, category.to_s] }
129
+
130
+ ["", "refused (#{refused.size})"] + groups.flat_map do |category, group|
131
+ ["", " #{LABELS.fetch(category, category)} (#{group.size})"] +
132
+ group.map { |result| " #{name(result)} #{result.detail}" }
133
+ end
134
+ end
135
+
136
+ # the file column: filenames when every query came from one directory,
137
+ # padded so what sits beside them lines up
138
+ def name(result) = basename(result).ljust(width)
139
+
140
+ def basename(result) = result.path.delete_prefix(shared_dir)
141
+
142
+ def width = @width ||= @results.map { |result| basename(result).length }.max
143
+
144
+ # the directory every query came from, so the report's file column is
145
+ # filenames rather than the same path repeated
146
+ def shared_dir
147
+ dirs = @results.map { |result| File.dirname(result.path) }.uniq
148
+ dirs.one? ? "#{dirs.first}/" : ""
149
+ end
150
+
151
+ def measure(path)
152
+ document = GraphQL.parse(GraphWeaver::Codegen.inline_fragments(File.read(path), @shared, path))
153
+ errors = @planner.validate(document)
154
+ return Result.new(path, nil, [], :invalid, errors.first["message"]) if errors.any?
155
+
156
+ plan = @planner.plan(document)
157
+ Result.new(path, plan.where, plan.subgraphs & @absent, nil, nil)
158
+ rescue Unplannable => e
159
+ Result.new(path, nil, [], e.category, e.detail)
160
+ rescue GraphQL::ParseError, GraphWeaver::Error => e
161
+ Result.new(path, nil, [], :invalid, e.message)
162
+ end
163
+ end
164
+ end
165
+ end
@@ -9,10 +9,10 @@ module GraphWeaver
9
9
  # transports produce, so error-handling paths are testable without a
10
10
  # server that misbehaves on cue:
11
11
  #
12
- # PersonQuery.execute(id: "1", Failure.transport) # TransportError
13
- # PersonQuery.execute(id: "1", Failure.server(status: 502))
14
- # PersonQuery.execute(id: "1", Failure.throttled) # QueryError, code THROTTLED
15
- # PersonQuery.execute(id: "1", Failure.stale_schema) # schema_stale? => true
12
+ # PersonQuery.execute(client: Failure.transport, id: "1") # TransportError
13
+ # PersonQuery.execute(client: Failure.server(status: 502), id: "1")
14
+ # PersonQuery.execute(client: Failure.throttled, id: "1") # QueryError, code THROTTLED
15
+ # PersonQuery.execute(client: Failure.stale_schema, id: "1") # schema_stale? => true
16
16
  #
17
17
  # For type mismatches, corrupt the wire with a FakeClient override:
18
18
  # FakeClient.new(schema:, overrides: { "Person.birthday" => 123 })
@@ -56,24 +56,11 @@ module GraphWeaver
56
56
  end
57
57
 
58
58
  # A validation-shaped rejection — trips schema_stale? and its
59
- # regenerate hint, as if the schema changed under the module. Name
60
- # the casualty explicitly, or pass schema: to sample a real
61
- # type/field (as if the server just dropped it):
59
+ # regenerate hint, as if the schema changed under the module. Name the
60
+ # casualty when the message matters:
62
61
  #
63
62
  # Failure.stale_schema(type: "Person", field: "name")
64
- # Failure.stale_schema(schema: MySchema) # random real field
65
- # Failure.stale_schema(schema: MySchema, seed: 42) # reproducibly random
66
- def stale_schema(field: nil, type: nil, schema: nil, seed: nil)
67
- if schema && (field.nil? || type.nil?)
68
- rng = Random.new(seed || GraphWeaver::Testing.config.seed || Random.new_seed)
69
- candidates = schema.types.values.select do |candidate|
70
- candidate.kind.name == "OBJECT" && !candidate.graphql_name.start_with?("__")
71
- end
72
- chosen = candidates.sort_by(&:graphql_name).sample(random: rng)
73
- type ||= chosen.graphql_name
74
- field ||= chosen.fields.keys.sort.sample(random: rng)
75
- end
76
-
63
+ def stale_schema(field: nil, type: nil)
77
64
  graphql("Field '#{field || "someField"}' doesn't exist on type '#{type || "SomeType"}'")
78
65
  end
79
66
  end
@@ -84,7 +71,7 @@ module GraphWeaver
84
71
  @response = response
85
72
  end
86
73
 
87
- def execute(_query, variables: {})
74
+ def execute(_query, variables: {}, operation_name: nil)
88
75
  @response.call
89
76
  end
90
77
  end
@@ -99,10 +86,10 @@ module GraphWeaver
99
86
  @calls = 0
100
87
  end
101
88
 
102
- def execute(query, variables: {})
89
+ def execute(query, variables: {}, operation_name: nil)
103
90
  client = @clients[[@calls, @clients.size - 1].min]
104
91
  @calls += 1
105
- client.execute(query, variables:)
92
+ client.execute(query, variables:, operation_name:)
106
93
  end
107
94
  end
108
95
  end