graph_weaver 0.1.0 → 0.2.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +256 -0
  4. data/Gemfile.lock +32 -2
  5. data/Makefile +7 -2
  6. data/NOTES.md +1 -1
  7. data/PLAN.md +41 -1
  8. data/README.md +56 -41
  9. data/docs/cassettes.md +76 -0
  10. data/docs/errors.md +37 -9
  11. data/docs/generated_modules.md +178 -29
  12. data/docs/getting_started.md +136 -0
  13. data/docs/logging.md +33 -0
  14. data/docs/real_world.md +27 -20
  15. data/docs/scalars.md +122 -8
  16. data/docs/testing.md +55 -38
  17. data/docs/transports.md +124 -0
  18. data/graph_weaver.gemspec +7 -1
  19. data/lib/graph_weaver/client.rb +201 -0
  20. data/lib/graph_weaver/codegen/emit.rb +315 -76
  21. data/lib/graph_weaver/codegen/enum_type.rb +149 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +123 -68
  23. data/lib/graph_weaver/codegen/scalar_type.rb +53 -35
  24. data/lib/graph_weaver/codegen.rb +272 -101
  25. data/lib/graph_weaver/errors.rb +37 -25
  26. data/lib/graph_weaver/hints.rb +63 -0
  27. data/lib/graph_weaver/inflect.rb +2 -1
  28. data/lib/graph_weaver/input_struct.rb +59 -0
  29. data/lib/graph_weaver/logging.rb +41 -0
  30. data/lib/graph_weaver/railtie.rb +30 -0
  31. data/lib/graph_weaver/retry.rb +97 -0
  32. data/lib/graph_weaver/rspec.rb +17 -14
  33. data/lib/graph_weaver/schema_loader.rb +156 -20
  34. data/lib/graph_weaver/selection.rb +3 -3
  35. data/lib/graph_weaver/tasks.rb +71 -0
  36. data/lib/graph_weaver/testing/cassette.rb +42 -21
  37. data/lib/graph_weaver/testing/failure.rb +22 -22
  38. data/lib/graph_weaver/testing/{fake_executor.rb → fake_client.rb} +13 -13
  39. data/lib/graph_weaver/testing/values.rb +2 -2
  40. data/lib/graph_weaver/testing.rb +31 -15
  41. data/lib/graph_weaver/transport/faraday.rb +56 -0
  42. data/lib/graph_weaver/transport/http.rb +87 -0
  43. data/lib/graph_weaver/transport.rb +120 -0
  44. data/lib/graph_weaver/version.rb +1 -1
  45. data/lib/graph_weaver.rb +289 -38
  46. metadata +74 -5
  47. data/lib/graph_weaver/faraday_executor.rb +0 -61
  48. data/lib/graph_weaver/http_executor.rb +0 -44
  49. data/lib/graph_weaver/testing/rspec.rb +0 -5
@@ -15,7 +15,7 @@ module GraphWeaver::SchemaLoader
15
15
  # - a file path — .json (introspection) or .graphql/.gql (SDL)
16
16
  # - raw content — introspection JSON (starts with "{") or SDL
17
17
  # so a cache round-trip is symmetrical with introspect:
18
- # SchemaLoader.load(cached_json) # from Rails.cache/redis/...
18
+ # SchemaLoader.load(cached_json) # from Rails.cache/redis/...
19
19
  def self.load(source)
20
20
  return GraphQL::Schema.from_introspection(source) if source.is_a?(Hash)
21
21
 
@@ -39,43 +39,179 @@ module GraphWeaver::SchemaLoader
39
39
  end
40
40
  end
41
41
 
42
- # Run the standard introspection query through an executor and build a
42
+ # Run the standard introspection query through a transport and build a
43
43
  # schema from the result:
44
44
  #
45
- # executor = GraphWeaver::HttpExecutor.new(url, headers: { ... })
46
- # schema = GraphWeaver::SchemaLoader.introspect(executor)
45
+ # transport = GraphWeaver::Transport::HTTP.new(url, headers: { ... })
46
+ # schema = GraphWeaver::SchemaLoader.introspect(transport)
47
47
  #
48
- # Introspecting a large API takes seconds, so cache: (a file path)
49
- # stores the raw introspection JSON and reuses it until ttl: seconds
50
- # elapse (no ttl = until the file is deleted). GraphQL has no standard
51
- # schema-version signal to invalidate on a stale cache surfaces as
52
- # server-side validation errors (see QueryError#schema_stale?), so pick
53
- # a ttl that matches how fast the API moves, or delete the file.
48
+ # Introspecting a large API takes seconds, so cache: dumps the schema
49
+ # to a file and reuses it until ttl: seconds elapse (no ttl = until the
50
+ # file is deleted). cache: takes
51
+ # - true GraphWeaver.schema_path, the file the generation workflow
52
+ # reads (its extension picks the format)
53
+ # - a path the extension picks the format: .json is the verbatim
54
+ # introspection result, .graphql/.gql is SDL (human-readable,
55
+ # PR-reviewable diffs); both load back identically
56
+ # - :json / :graphql / :gql — GraphWeaver.schema_path's location, in
57
+ # that format
58
+ # Reading is format-agnostic: any fresh sibling dump counts, whatever
59
+ # its format — an existing schema.graphql is reused rather than
60
+ # re-introspecting to write schema.json.
61
+ # GraphQL has no standard schema-version signal to invalidate on — a
62
+ # stale cache surfaces as server-side validation errors (see
63
+ # QueryError#schema_stale?), so pick a ttl that matches how fast the
64
+ # API moves, or delete the file.
54
65
  #
55
66
  # To cache anywhere else (Rails.cache, redis, ...), serialize the schema
56
67
  # itself — schemas round-trip through their introspection JSON:
57
68
  #
58
- # json = Rails.cache.fetch("gh_schema", expires_in: 12.hours) do
59
- # GraphWeaver::SchemaLoader.introspect(executor).to_json
60
- # end
61
- # schema = GraphWeaver::SchemaLoader.load(json)
62
- def self.introspect(executor, cache: nil, ttl: nil)
63
- if cache && fresh?(cache, ttl)
64
- return GraphQL::Schema.from_introspection(JSON.parse(File.read(cache)))
69
+ # json = Rails.cache.fetch("gh_schema", expires_in: 12.hours) do
70
+ # GraphWeaver::SchemaLoader.introspect(transport).to_json
71
+ # end
72
+ # schema = GraphWeaver::SchemaLoader.load(json)
73
+ def self.introspect(transport, cache: nil, ttl: nil)
74
+ cache = cache_path(cache)
75
+
76
+ if cache
77
+ # reuse whatever fresh dump is present, regardless of format —
78
+ # don't re-introspect to write schema.json when a usable
79
+ # schema.graphql already sits there
80
+ existing = cache_candidates(cache).find { |candidate| fresh?(candidate, ttl) }
81
+ if existing
82
+ GraphWeaver.log(:info) { "schema cache hit: #{existing}#{" (ttl #{ttl}s)" if ttl}" }
83
+ return load(existing)
84
+ end
85
+
86
+ GraphWeaver.log(:info) { "schema cache miss: #{cache}" }
65
87
  end
66
88
 
67
- result = executor.execute(GraphQL::Introspection.query, variables: {}).to_h
89
+ result = GraphWeaver.log_timed(:info, "introspected #{transport.respond_to?(:url) ? transport.url : transport.class}") do
90
+ transport.execute(GraphQL::Introspection.query, variables: {}).to_h
91
+ end
68
92
  if (errors = result["errors"])
69
93
  raise GraphWeaver::Error, "introspection failed: #{errors.inspect}"
70
94
  end
71
95
 
96
+ schema = GraphQL::Schema.from_introspection(result)
97
+
72
98
  if cache
73
99
  FileUtils.mkdir_p(File.dirname(cache))
74
- File.write(cache, JSON.generate(result))
100
+ # the extension picks the format: .json is the verbatim wire
101
+ # artifact; .graphql/.gql is SDL — human-readable, PR-reviewable
102
+ # diffs (both generate byte-identical code)
103
+ meta = stamp(transport)
104
+ content = if cache.end_with?(".json")
105
+ JSON.generate(meta ? result.merge("graph_weaver" => meta) : result)
106
+ else
107
+ header = meta && "# graph_weaver: #{JSON.generate(meta)}\n\n"
108
+ "#{header}#{schema.to_definition}"
109
+ end
110
+ File.write(cache, content)
111
+ GraphWeaver.log(:info) { "wrote schema cache: #{cache} (#{content.bytesize} bytes)" }
75
112
  end
76
113
 
77
- GraphQL::Schema.from_introspection(result)
114
+ schema
115
+ end
116
+
117
+ # The conventional schema dump, whatever its format: schema_path or the
118
+ # first sibling extension that exists. nil when none is on disk.
119
+ def self.locate_path(path = GraphWeaver.schema_path)
120
+ cache_candidates(path).find { |candidate| File.exist?(candidate) }
121
+ end
122
+
123
+ # locate_path, loaded.
124
+ def self.locate(path = GraphWeaver.schema_path)
125
+ found = locate_path(path)
126
+ found && load(found)
127
+ end
128
+
129
+ # The provenance recorded in a dump ({"url" => ..., "introspected_at"
130
+ # => ...}), whichever format holds it; nil for local/unannotated dumps.
131
+ def self.provenance(path)
132
+ content = File.read(path)
133
+ if path.end_with?(".json")
134
+ JSON.parse(content)["graph_weaver"]
135
+ elsif (meta = content[/\A# graph_weaver: (\{.*\})$/, 1])
136
+ JSON.parse(meta)
137
+ end
138
+ end
139
+
140
+ # Re-introspect a dump's source and compare — true when the server has
141
+ # drifted from what's on disk. transport: overrides the transport (auth
142
+ # etc); by default one is built from the dump's recorded url. Wired up
143
+ # as `rake graph_weaver:schema:verify` / `:refresh`.
144
+ def self.stale?(path, transport: nil)
145
+ transport ||= source_transport(path)
146
+ fresh = introspect(transport)
147
+
148
+ fresh.to_definition != load(path).to_definition
149
+ end
150
+
151
+ # a transport to the dump's recorded url (GRAPHWEAVER_AUTH supplies a
152
+ # token when set)
153
+ def self.source_transport(path)
154
+ meta = provenance(path)
155
+ unless meta&.key?("url")
156
+ raise GraphWeaver::Error, "#{path} records no source url — pass transport:"
157
+ end
158
+
159
+ GraphWeaver.new(meta["url"], auth: ENV["GRAPHWEAVER_AUTH"]).transport
160
+ end
161
+ private_class_method :source_transport
162
+
163
+ # Where a dump came from, recorded into the file so it can be
164
+ # re-verified later — a parsable header comment in SDL, a
165
+ # "graph_weaver" sibling key in introspection JSON (from_introspection
166
+ # reads only "data"). nil when the transport has no url (schema
167
+ # classes, fakes).
168
+ def self.stamp(transport)
169
+ return unless transport.respond_to?(:url) && transport.url
170
+
171
+ require "time"
172
+ { "url" => transport.url, "introspected_at" => Time.now.utc.iso8601 }
173
+ end
174
+ private_class_method :stamp
175
+
176
+ CACHE_EXTENSIONS = %w[.json .graphql .gql].freeze
177
+
178
+ # cache: true / :json / :graphql / :gql / a path => the file to write
179
+ # (nil for no caching). Symbols and true anchor at GraphWeaver.schema_path —
180
+ # the schema dump the generation workflow reads, so one file serves both
181
+ # (introspect caches it, rake generate loads it).
182
+ def self.cache_path(cache)
183
+ case cache
184
+ when nil, false
185
+ nil
186
+ when true
187
+ GraphWeaver.schema_path
188
+ when Symbol
189
+ unless CACHE_EXTENSIONS.include?(".#{cache}")
190
+ raise ArgumentError, "cache: format must be :json, :graphql, or :gql, got #{cache.inspect}"
191
+ end
192
+
193
+ "#{strip_extension(GraphWeaver.schema_path)}.#{cache}"
194
+ else
195
+ unless cache.end_with?(*CACHE_EXTENSIONS)
196
+ raise ArgumentError, "cache: must be a .json or .graphql/.gql path, got #{cache}"
197
+ end
198
+
199
+ cache
200
+ end
201
+ end
202
+ private_class_method :cache_path
203
+
204
+ # the requested path first, then its siblings in the other formats
205
+ def self.cache_candidates(path)
206
+ base = strip_extension(path)
207
+ [path, *CACHE_EXTENSIONS.map { |ext| base + ext }].uniq
208
+ end
209
+ private_class_method :cache_candidates
210
+
211
+ def self.strip_extension(path)
212
+ path.delete_suffix(File.extname(path))
78
213
  end
214
+ private_class_method :strip_extension
79
215
 
80
216
  def self.fresh?(path, ttl)
81
217
  File.exist?(path) && (ttl.nil? || Time.now - File.mtime(path) < ttl)
@@ -4,7 +4,7 @@
4
4
  require "graphql"
5
5
 
6
6
  module GraphWeaver
7
- # Shared query-selection walking — the rules Codegen, FakeExecutor, and
7
+ # Shared query-selection walking — the rules Codegen, FakeClient, and
8
8
  # the cassette Anonymizer all follow, in one place so they can't drift:
9
9
  # how fragments flatten into selections, and when a type condition
10
10
  # applies. Hosts set @schema and call load_operation before walking.
@@ -27,7 +27,7 @@ module GraphWeaver
27
27
  case operation&.operation_type
28
28
  when "query", nil then @schema.query
29
29
  when "mutation" then @schema.mutation
30
- else raise NotImplementedError, "unsupported operation: #{operation.operation_type}"
30
+ else raise GraphWeaver::Error, "unsupported operation: #{operation.operation_type}"
31
31
  end
32
32
  end
33
33
 
@@ -47,7 +47,7 @@ module GraphWeaver
47
47
  end
48
48
  each_field(type, fragment.selections, &block) if applies?(fragment.type.name, type)
49
49
  else
50
- raise NotImplementedError, "unsupported selection: #{selection.class}"
50
+ raise GraphWeaver::Error, "unsupported selection: #{selection.class}"
51
51
  end
52
52
  end
53
53
  end
@@ -0,0 +1,71 @@
1
+ # typed: ignore — Rake DSL at top level, loaded from Rakefiles
2
+ # frozen_string_literal: true
3
+
4
+ # Rake tasks — add to your Rakefile:
5
+ #
6
+ # require "graph_weaver/tasks"
7
+ #
8
+ # The tasks use the conventional paths (GraphWeaver.queries_path /
9
+ # generated_path / schema_path — override in your Rakefile or an
10
+ # initializer). Register custom scalars before the tasks run — they're
11
+ # baked into generated source.
12
+ #
13
+ # rake graph_weaver:generate # queries_path -> generated_path
14
+ # rake graph_weaver:verify # fail if generated files are stale (CI)
15
+ require_relative "../graph_weaver"
16
+
17
+ # in Rails, run app config first (initializers register scalars/enums/
18
+ # helpers, and they're baked into generated source)
19
+ GRAPH_WEAVER_DEPS = Rake::Task.task_defined?("environment") ? ["environment"] : []
20
+
21
+ namespace :graph_weaver do
22
+ desc "Generate typed query modules (#{GraphWeaver.queries_path} -> #{GraphWeaver.generated_path})"
23
+ task generate: GRAPH_WEAVER_DEPS do
24
+ # schema auto-located at GraphWeaver.schema_path, any supported extension
25
+ GraphWeaver.generate!.each { |path| puts "wrote #{path}" }
26
+ end
27
+
28
+ desc "Verify generated query modules are up to date"
29
+ task verify: GRAPH_WEAVER_DEPS do
30
+ GraphWeaver.verify_generated!
31
+ puts "generated queries up to date"
32
+ end
33
+
34
+ namespace :schema do
35
+ # both tasks re-introspect from the url recorded in the dump
36
+ # (GRAPHWEAVER_AUTH supplies a token for private APIs)
37
+
38
+ desc "Fail when the server's schema has drifted from the local dump"
39
+ task :verify do
40
+ path = GraphWeaver::SchemaLoader.locate_path or abort "no schema dump at #{GraphWeaver.schema_path}"
41
+ if GraphWeaver::SchemaLoader.stale?(path)
42
+ abort "#{path} is stale — the server's schema has drifted (rake graph_weaver:schema:refresh)"
43
+ end
44
+
45
+ puts "#{path} matches the server"
46
+ end
47
+
48
+ desc "Re-introspect the recorded url and rewrite the local dump"
49
+ task :refresh do
50
+ path = GraphWeaver::SchemaLoader.locate_path or abort "no schema dump at #{GraphWeaver.schema_path}"
51
+ meta = GraphWeaver::SchemaLoader.provenance(path) or abort "#{path} records no source url"
52
+
53
+ transport = GraphWeaver.new(meta["url"], auth: ENV["GRAPHWEAVER_AUTH"]).transport
54
+ GraphWeaver::SchemaLoader.introspect(transport, cache: path, ttl: 0)
55
+ puts "refreshed #{path} from #{meta["url"]}"
56
+ end
57
+ end
58
+
59
+ namespace :cassettes do
60
+ desc "Anonymize every cassette in Testing.config.cassette_dir (PII-safe to commit)"
61
+ task :anonymize do
62
+ require "graph_weaver/testing"
63
+
64
+ schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
65
+ Dir[File.join(GraphWeaver::Testing.config.cassette_dir, "*.yml")].sort.each do |path|
66
+ GraphWeaver::Testing::Cassette.new(path).anonymize!(schema:)
67
+ puts "anonymized #{path}"
68
+ end
69
+ end
70
+ end
71
+ end
@@ -7,12 +7,12 @@ require "yaml"
7
7
 
8
8
  module GraphWeaver
9
9
  module Testing
10
- # Raised by ReplayExecutor when a request has no recording.
10
+ # Raised by Replayer when a request has no recording.
11
11
  class MissingRecording < GraphWeaver::Error
12
12
  def initialize(path:, query:)
13
13
  super(<<~MSG.strip)
14
14
  no recording for this request in #{path} — re-record it
15
- (RecordingExecutor / Cassette.use with a live executor, or delete
15
+ (Recorder / Cassette.use with a live client, or delete
16
16
  the cassette to start over). Query:
17
17
  #{query.strip[0, 200]}
18
18
  MSG
@@ -23,12 +23,12 @@ module GraphWeaver
23
23
  # cassette is a YAML file of {query, variables, response} entries,
24
24
  # keyed on the normalized query + variables.
25
25
  #
26
- # # record against a real executor, replay when the file exists:
27
- # executor = GraphWeaver::Testing::Cassette.use("github", executor: real)
26
+ # # record against a real client, replay when the file exists:
27
+ # client = GraphWeaver::Testing::Cassette.use("github", client: real)
28
28
  #
29
29
  # Cassettes hold real responses — anonymize before committing:
30
30
  #
31
- # Cassette.new("spec/cassettes/github.yml").anonymize!(schema:)
31
+ # Cassette.new("spec/cassettes/github.yml").anonymize!(schema:)
32
32
  #
33
33
  # keeps every shape (list lengths, null positions, enums, __typename,
34
34
  # id relationships via a consistent mapping) while replacing values
@@ -44,21 +44,26 @@ module GraphWeaver
44
44
  def exist? = File.exist?(@path)
45
45
  def size = @entries.size
46
46
 
47
- # replay when recorded, record when not (VCR's once mode).
48
- # executor: is required to record; omit it to replay-or-raise.
49
- def self.use(path, executor: nil)
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)
50
52
  cassette = new(path)
51
- if cassette.exist?
52
- ReplayExecutor.new(cassette)
53
- elsif executor
54
- RecordingExecutor.new(executor, cassette)
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)
55
59
  else
56
- raise MissingRecording.new(path: cassette.path, query: "(no executor to record with)")
60
+ raise MissingRecording.new(path: cassette.path, query: "(no client to record with)")
57
61
  end
58
62
  end
59
63
 
60
64
  def lookup(query, variables)
61
- @entries.find { |entry| entry["key"] == self.class.key(query, variables) }
65
+ wanted = self.class.key(query, variables)
66
+ @entries.find { |entry| entry["key"] == wanted }
62
67
  end
63
68
 
64
69
  def record(query, variables, response)
@@ -74,7 +79,7 @@ module GraphWeaver
74
79
  end
75
80
 
76
81
  # Replace recorded response values with fakes, preserving structure.
77
- # Walks each entry's query against the schema (like FakeExecutor,
82
+ # Walks each entry's query against the schema (like FakeClient,
78
83
  # but transforming what's there instead of generating from scratch).
79
84
  def anonymize!(schema:, seed: nil, mode: nil)
80
85
  anonymizer = Anonymizer.new(schema:, seed:, mode:)
@@ -98,15 +103,31 @@ module GraphWeaver
98
103
  end
99
104
  end
100
105
 
101
- # Tees requests through a live executor and records every response.
102
- class RecordingExecutor
103
- def initialize(executor, cassette)
104
- @executor = executor
106
+ # Tees requests through a live client and records every response.
107
+ # With Testing.config.anonymize (or anonymize: true), responses are
108
+ # anonymized as they're recorded — and the anonymized version is what
109
+ # the caller sees too, so assertions written now hold on replay.
110
+ class Recorder
111
+ def initialize(client, cassette, anonymize: nil)
112
+ @client = client
105
113
  @cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
114
+
115
+ config = Testing.config
116
+ if anonymize.nil? ? config.anonymize : anonymize
117
+ unless config.schema
118
+ raise ArgumentError, "anonymizing recordings needs GraphWeaver::Testing.config.schema"
119
+ end
120
+
121
+ @anonymizer = Anonymizer.new(schema: config.schema, seed: config.seed)
122
+ end
106
123
  end
107
124
 
108
125
  def execute(query, variables: {})
109
- response = @executor.execute(query, variables:).to_h
126
+ response = @client.execute(query, variables:).to_h
127
+ if @anonymizer && (data = response["data"])
128
+ response = response.merge("data" => @anonymizer.anonymize(query, data))
129
+ end
130
+
110
131
  @cassette.record(query, variables, response)
111
132
  response
112
133
  end
@@ -114,7 +135,7 @@ module GraphWeaver
114
135
 
115
136
  # Serves recorded responses; raises MissingRecording on unknown
116
137
  # requests rather than silently faking.
117
- class ReplayExecutor
138
+ class Replayer
118
139
  def initialize(cassette)
119
140
  @cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
120
141
  end
@@ -5,19 +5,19 @@ require "json"
5
5
 
6
6
  module GraphWeaver
7
7
  module Testing
8
- # Canned failure executors — each produces exactly what the real
8
+ # Canned failure clients — each produces exactly what the real
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", executor: Failure.transport) # TransportError
13
- # PersonQuery.execute(id: "1", executor: Failure.server(status: 502))
14
- # PersonQuery.execute(id: "1", executor: Failure.throttled) # QueryError, code THROTTLED
15
- # PersonQuery.execute(id: "1", executor: Failure.stale_schema) # schema_stale? => true
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
16
16
  #
17
- # For type mismatches, corrupt the wire with a FakeExecutor override:
18
- # FakeExecutor.new(schema:, overrides: { "Person.birthday" => 123 })
17
+ # For type mismatches, corrupt the wire with a FakeClient override:
18
+ # FakeClient.new(schema:, overrides: { "Person.birthday" => 123 })
19
19
  # casting then raises GraphWeaver::TypeError, exactly as a bad server
20
- # payload would. For partial failures, see FakeExecutor's fail_at:.
20
+ # payload would. For partial failures, see FakeClient's fail_at:.
21
21
  module Failure
22
22
  include Kernel # for sorbet
23
23
  module_function
@@ -25,7 +25,7 @@ module GraphWeaver
25
25
  # the request never reaches the server — cause preserved, like the
26
26
  # bundled transports do
27
27
  def transport(message = "simulated network failure", cause: SocketError)
28
- FailureExecutor.new do
28
+ FailureClient.new do
29
29
  raise cause, message
30
30
  rescue cause => e
31
31
  raise GraphWeaver::TransportError, e.message
@@ -34,7 +34,7 @@ module GraphWeaver
34
34
 
35
35
  # the server answered non-2xx
36
36
  def server(status: 500, body: "simulated server error")
37
- FailureExecutor.new { raise GraphWeaver::ServerError.new(status:, body:) }
37
+ FailureClient.new { raise GraphWeaver::ServerError.new(status:, body:) }
38
38
  end
39
39
 
40
40
  # top-level GraphQL errors: strings, or hashes with message/path/
@@ -47,7 +47,7 @@ module GraphWeaver
47
47
  response = { "errors" => normalized }
48
48
  response["data"] = data if data
49
49
  response["extensions"] = JSON.parse(JSON.generate(extensions)) unless extensions.empty?
50
- FailureExecutor.new { response }
50
+ FailureClient.new { response }
51
51
  end
52
52
 
53
53
  def throttled
@@ -60,9 +60,9 @@ module GraphWeaver
60
60
  # the casualty explicitly, or pass schema: to sample a real
61
61
  # type/field (as if the server just dropped it):
62
62
  #
63
- # 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
63
+ # 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
66
  def stale_schema(field: nil, type: nil, schema: nil, seed: nil)
67
67
  if schema && (field.nil? || type.nil?)
68
68
  rng = Random.new(seed || GraphWeaver::Testing.config.seed || Random.new_seed)
@@ -79,7 +79,7 @@ module GraphWeaver
79
79
  end
80
80
 
81
81
  # runs the block per request — raise or return an envelope
82
- class FailureExecutor
82
+ class FailureClient
83
83
  def initialize(&response)
84
84
  @response = response
85
85
  end
@@ -89,20 +89,20 @@ module GraphWeaver
89
89
  end
90
90
  end
91
91
 
92
- # Delegates each call to the next executor in line (the last one
92
+ # Delegates each call to the next client in line (the last one
93
93
  # repeats) — fail N times, then succeed, for retry/backoff testing:
94
94
  #
95
- # SequenceExecutor.new(Failure.transport, Failure.transport, fake)
96
- class SequenceExecutor
97
- def initialize(*executors)
98
- @executors = executors.flatten
95
+ # Sequence.new(Failure.transport, Failure.transport, fake)
96
+ class Sequence
97
+ def initialize(*clients)
98
+ @clients = clients.flatten
99
99
  @calls = 0
100
100
  end
101
101
 
102
102
  def execute(query, variables: {})
103
- executor = @executors[[@calls, @executors.size - 1].min]
103
+ client = @clients[[@calls, @clients.size - 1].min]
104
104
  @calls += 1
105
- executor.execute(query, variables:)
105
+ client.execute(query, variables:)
106
106
  end
107
107
  end
108
108
  end
@@ -4,12 +4,12 @@
4
4
  require "graphql"
5
5
  require "json"
6
6
 
7
- # An executor that fabricates schema-correct responses for whatever query
7
+ # A fake client that fabricates schema-correct responses for whatever query
8
8
  # arrives — the zero-setup way to test code built on generated modules:
9
9
  #
10
- # fake = GraphWeaver::Testing::FakeExecutor.new(schema:)
11
- # result = PersonQuery.execute!(id: "1", executor: fake)
12
- # result.person.name # => a plausible String, typed and castable
10
+ # fake = GraphWeaver::Testing::FakeClient.new(schema:)
11
+ # result = PersonQuery.execute!(id: "1", fake — positionally)
12
+ # result.person.name # => a plausible String, typed and castable
13
13
  #
14
14
  # Values are type-correct by construction (real enum values, valid
15
15
  # __typename members for unions/interfaces, iso8601 for date scalars), so
@@ -22,10 +22,10 @@ require "json"
22
22
  # also the way to simulate a corrupt payload — casting raises
23
23
  # GraphWeaver::TypeError.)
24
24
  #
25
- # FakeExecutor.new(schema:, overrides: {
26
- # "Person.name" => "Daniel",
27
- # "email" => -> { "test@example.com" },
28
- # })
25
+ # FakeClient.new(schema:, overrides: {
26
+ # "Person.name" => "Daniel",
27
+ # "email" => -> { "test@example.com" },
28
+ # })
29
29
  #
30
30
  # Partial failures: fail_at simulates a field-level error with
31
31
  # spec-correct null propagation — the field's error lands in the errors
@@ -33,8 +33,8 @@ require "json"
33
33
  # bubble past non-null positions to the nearest nullable ancestor, just
34
34
  # like a real server:
35
35
  #
36
- # FakeExecutor.new(schema:, fail_at: "person.pets.name")
37
- # FakeExecutor.new(schema:, fail_at: { path: "person.email", message: "hidden", code: "PRIVATE" })
36
+ # FakeClient.new(schema:, fail_at: "person.pets.name")
37
+ # FakeClient.new(schema:, fail_at: { path: "person.email", message: "hidden", code: "PRIVATE" })
38
38
  #
39
39
  # errors: appends verbatim top-level errors alongside the fake data.
40
40
  #
@@ -43,11 +43,11 @@ require "json"
43
43
  # so casting raises GraphWeaver::TypeError. One spec checks the failure
44
44
  # path; every other spec gets working data:
45
45
  #
46
- # FakeExecutor.new(schema:, corrupt: "Person.birthday")
46
+ # FakeClient.new(schema:, corrupt: "Person.birthday")
47
47
  #
48
- # seed: makes a run reproducible (also seeds faker). Per-executor options
48
+ # seed: makes a run reproducible (also seeds faker). Per-instance options
49
49
  # fall back to GraphWeaver::Testing.config.
50
- class GraphWeaver::Testing::FakeExecutor
50
+ class GraphWeaver::Testing::FakeClient
51
51
  include GraphWeaver::Selection
52
52
 
53
53
  # sentinel: a simulated failure bubbling up to the nearest nullable spot
@@ -3,7 +3,7 @@
3
3
 
4
4
  require "date"
5
5
 
6
- # The value engine behind FakeExecutor and Cassette#anonymize!: seeded,
6
+ # The value engine behind FakeClient and Cassette#anonymize!: seeded,
7
7
  # type-correct scalar generation with optional faker-backed semantics
8
8
  # matched on field names — strings (name/email/url/...) and numbers
9
9
  # (age/price/count/latitude/...) alike. Keeps a consistent id mapping so
@@ -47,7 +47,7 @@ class GraphWeaver::Testing::Values
47
47
 
48
48
  if @mode == :faker
49
49
  # rebind per call: several Values instances may interleave (e.g. two
50
- # seeded executors), and faker's rng is global
50
+ # seeded fakes), and faker's rng is global
51
51
  ::Faker::Config.random = @rng
52
52
  case type_name
53
53
  when "String"