graph_weaver 0.0.1 → 0.2.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +279 -0
  4. data/Gemfile.lock +75 -17
  5. data/Makefile +11 -1
  6. data/NOTES.md +1 -1
  7. data/PLAN.md +85 -22
  8. data/README.md +86 -26
  9. data/docs/cassettes.md +76 -0
  10. data/docs/errors.md +134 -0
  11. data/docs/generated_modules.md +229 -0
  12. data/docs/getting_started.md +134 -0
  13. data/docs/logging.md +33 -0
  14. data/docs/real_world.md +53 -0
  15. data/docs/scalars.md +183 -0
  16. data/docs/testing.md +103 -0
  17. data/docs/transports.md +124 -0
  18. data/graph_weaver.gemspec +10 -1
  19. data/lib/graph_weaver/client.rb +200 -0
  20. data/lib/graph_weaver/codegen/emit.rb +286 -0
  21. data/lib/graph_weaver/codegen/enum_type.rb +149 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +356 -0
  23. data/lib/graph_weaver/codegen/scalar_type.rb +256 -0
  24. data/lib/graph_weaver/codegen.rb +396 -401
  25. data/lib/graph_weaver/errors.rb +322 -0
  26. data/lib/graph_weaver/hints.rb +63 -0
  27. data/lib/graph_weaver/inflect.rb +19 -0
  28. data/lib/graph_weaver/logging.rb +41 -0
  29. data/lib/graph_weaver/railtie.rb +29 -0
  30. data/lib/graph_weaver/response.rb +55 -0
  31. data/lib/graph_weaver/retry.rb +97 -0
  32. data/lib/graph_weaver/rspec.rb +59 -0
  33. data/lib/graph_weaver/schema_loader.rb +208 -8
  34. data/lib/graph_weaver/selection.rb +68 -0
  35. data/lib/graph_weaver/tasks.rb +71 -0
  36. data/lib/graph_weaver/testing/cassette.rb +224 -0
  37. data/lib/graph_weaver/testing/failure.rb +109 -0
  38. data/lib/graph_weaver/testing/fake_client.rb +228 -0
  39. data/lib/graph_weaver/testing/values.rb +98 -0
  40. data/lib/graph_weaver/testing.rb +106 -0
  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 +268 -4
  46. metadata +132 -2
  47. data/lib/graph_weaver/http_executor.rb +0 -31
@@ -1,20 +1,220 @@
1
1
  # typed: true
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "fileutils"
4
5
  require "graphql"
5
6
  require "json"
7
+ require_relative "errors"
6
8
 
7
9
  # Load a schema for codegen from either format a remote service can hand
8
- # you: an introspection dump (.json) or SDL (.graphql/.gql).
10
+ # you introspection JSON or SDL, as a file path or the content itself —
11
+ # or fetch one straight from a live endpoint via introspect.
9
12
  module GraphWeaver::SchemaLoader
10
- def self.load(path)
11
- case File.extname(path)
12
- when ".json"
13
- GraphQL::Schema.from_introspection(JSON.parse(File.read(path)))
14
- when ".graphql", ".gql"
15
- GraphQL::Schema.from_definition(File.read(path))
13
+ # Accepts, and detects:
14
+ # - a Hash (a parsed introspection result)
15
+ # - a file path — .json (introspection) or .graphql/.gql (SDL)
16
+ # - raw content — introspection JSON (starts with "{") or SDL
17
+ # so a cache round-trip is symmetrical with introspect:
18
+ # SchemaLoader.load(cached_json) # from Rails.cache/redis/...
19
+ def self.load(source)
20
+ return GraphQL::Schema.from_introspection(source) if source.is_a?(Hash)
21
+
22
+ if source.lstrip.start_with?("{") # introspection JSON content
23
+ GraphQL::Schema.from_introspection(JSON.parse(source))
24
+ elsif source.include?("\n") # multi-line: SDL content
25
+ unless source.match?(/^\s*(schema|type|interface|union|enum|scalar|directive|input|")/)
26
+ raise ArgumentError, "unsupported schema content: #{source.lstrip[0, 80].inspect}"
27
+ end
28
+
29
+ GraphQL::Schema.from_definition(source)
30
+ else # a file path
31
+ case File.extname(source)
32
+ when ".json"
33
+ GraphQL::Schema.from_introspection(JSON.parse(File.read(source)))
34
+ when ".graphql", ".gql"
35
+ GraphQL::Schema.from_definition(File.read(source))
36
+ else
37
+ raise ArgumentError, "unsupported schema format: #{source}"
38
+ end
39
+ end
40
+ end
41
+
42
+ # Run the standard introspection query through a transport and build a
43
+ # schema from the result:
44
+ #
45
+ # transport = GraphWeaver::Transport::HTTP.new(url, headers: { ... })
46
+ # schema = GraphWeaver::SchemaLoader.introspect(transport)
47
+ #
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.
65
+ #
66
+ # To cache anywhere else (Rails.cache, redis, ...), serialize the schema
67
+ # itself — schemas round-trip through their introspection JSON:
68
+ #
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}" }
87
+ end
88
+
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
92
+ if (errors = result["errors"])
93
+ raise GraphWeaver::Error, "introspection failed: #{errors.inspect}"
94
+ end
95
+
96
+ schema = GraphQL::Schema.from_introspection(result)
97
+
98
+ if cache
99
+ FileUtils.mkdir_p(File.dirname(cache))
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)" }
112
+ end
113
+
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}"
16
194
  else
17
- raise ArgumentError, "unsupported schema format: #{path}"
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
18
200
  end
19
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))
213
+ end
214
+ private_class_method :strip_extension
215
+
216
+ def self.fresh?(path, ttl)
217
+ File.exist?(path) && (ttl.nil? || Time.now - File.mtime(path) < ttl)
218
+ end
219
+ private_class_method :fresh?
20
220
  end
@@ -0,0 +1,68 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "graphql"
5
+
6
+ module GraphWeaver
7
+ # Shared query-selection walking — the rules Codegen, FakeClient, and
8
+ # the cassette Anonymizer all follow, in one place so they can't drift:
9
+ # how fragments flatten into selections, and when a type condition
10
+ # applies. Hosts set @schema and call load_operation before walking.
11
+ module Selection
12
+ include Kernel # for sorbet: hosts are Objects
13
+
14
+ # Parse a query, stash its fragment definitions for the walk, and
15
+ # return the operation.
16
+ def load_operation(query)
17
+ doc = GraphQL.parse(query)
18
+ @fragments = doc.definitions
19
+ .grep(GraphQL::Language::Nodes::FragmentDefinition)
20
+ .to_h { |fragment| [fragment.name, fragment] }
21
+
22
+ doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).first
23
+ end
24
+
25
+ # The schema type an operation's selections start from.
26
+ def operation_root_type(operation)
27
+ case operation&.operation_type
28
+ when "query", nil then @schema.query
29
+ when "mutation" then @schema.mutation
30
+ else raise GraphWeaver::Error, "unsupported operation: #{operation.operation_type}"
31
+ end
32
+ end
33
+
34
+ # Flatten a selection set as seen by `type`, yielding (result_key,
35
+ # field_node) per field: plain fields yield directly; inline fragments
36
+ # and named spreads recurse when their type condition applies.
37
+ def each_field(type, selections, &block)
38
+ selections.each do |selection|
39
+ case selection
40
+ when GraphQL::Language::Nodes::Field
41
+ yield(selection.alias || selection.name, selection)
42
+ when GraphQL::Language::Nodes::InlineFragment
43
+ each_field(type, selection.selections, &block) if applies?(selection.type&.name, type)
44
+ when GraphQL::Language::Nodes::FragmentSpread
45
+ fragment = @fragments.fetch(selection.name) do
46
+ raise ArgumentError, "unknown fragment: #{selection.name}"
47
+ end
48
+ each_field(type, fragment.selections, &block) if applies?(fragment.type.name, type)
49
+ else
50
+ raise GraphWeaver::Error, "unsupported selection: #{selection.class}"
51
+ end
52
+ end
53
+ end
54
+
55
+ # A fragment's type condition applies when it names this type exactly,
56
+ # or an interface/union this type belongs to (`... on Named { ... }`).
57
+ def applies?(condition, type)
58
+ return true if condition.nil? || condition == type.graphql_name
59
+
60
+ condition_type = @schema.get_type(condition)
61
+ return false unless condition_type
62
+
63
+ kind = condition_type.kind.name
64
+ (kind == "INTERFACE" || kind == "UNION") &&
65
+ @schema.possible_types(condition_type).include?(type)
66
+ end
67
+ end
68
+ 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
@@ -0,0 +1,224 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "graphql"
6
+ require "yaml"
7
+
8
+ module GraphWeaver
9
+ module Testing
10
+ # Raised by Replayer when a request has no recording.
11
+ 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
19
+ end
20
+ end
21
+
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.
36
+ class Cassette
37
+ attr_reader :path
38
+
39
+ def initialize(path)
40
+ @path = Testing.cassette_path(path)
41
+ @entries = File.exist?(@path) ? YAML.safe_load_file(@path, aliases: true) : []
42
+ end
43
+
44
+ def exist? = File.exist?(@path)
45
+ def size = @entries.size
46
+
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
62
+ end
63
+
64
+ def lookup(query, variables)
65
+ wanted = self.class.key(query, variables)
66
+ @entries.find { |entry| entry["key"] == wanted }
67
+ end
68
+
69
+ def record(query, variables, response)
70
+ entry = {
71
+ "key" => self.class.key(query, variables),
72
+ "query" => query,
73
+ "variables" => variables,
74
+ "response" => response,
75
+ }
76
+ @entries.reject! { |existing| existing["key"] == entry["key"] }
77
+ @entries << entry
78
+ save
79
+ end
80
+
81
+ # Replace recorded response values with fakes, preserving structure.
82
+ # Walks each entry's query against the schema (like FakeClient,
83
+ # but transforming what's there instead of generating from scratch).
84
+ def anonymize!(schema:, seed: nil, mode: nil)
85
+ anonymizer = Anonymizer.new(schema:, seed:, mode:)
86
+ @entries.each do |entry|
87
+ data = entry.dig("response", "data")
88
+ entry["response"]["data"] = anonymizer.anonymize(entry["query"], data) if data
89
+ end
90
+ save
91
+ self
92
+ end
93
+
94
+ def self.key(query, variables)
95
+ { "query" => query.gsub(/\s+/, " ").strip, "variables" => variables || {} }
96
+ end
97
+
98
+ private
99
+
100
+ def save
101
+ FileUtils.mkdir_p(File.dirname(@path))
102
+ File.write(@path, YAML.dump(@entries))
103
+ end
104
+ end
105
+
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
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
123
+ end
124
+
125
+ def execute(query, variables: {})
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
+
131
+ @cassette.record(query, variables, response)
132
+ response
133
+ end
134
+ end
135
+
136
+ # Serves recorded responses; raises MissingRecording on unknown
137
+ # requests rather than silently faking.
138
+ class Replayer
139
+ def initialize(cassette)
140
+ @cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
141
+ end
142
+
143
+ def execute(query, variables: {})
144
+ entry = @cassette.lookup(query, variables)
145
+ raise MissingRecording.new(path: @cassette.path, query:) unless entry
146
+
147
+ entry["response"]
148
+ end
149
+ end
150
+
151
+ # Rewrites a recorded response through the Values engine: same shape,
152
+ # fake values. Enums, booleans, __typename, and null positions are
153
+ # preserved; ids map consistently so relationships survive.
154
+ class Anonymizer
155
+ include GraphWeaver::Selection
156
+
157
+ def initialize(schema:, seed: nil, mode: nil)
158
+ @schema = schema
159
+ @values = Values.new(seed:, mode:)
160
+ end
161
+
162
+ def anonymize(query, data)
163
+ operation = load_operation(query)
164
+
165
+ object_value(operation_root_type(operation), operation.selections, data)
166
+ end
167
+
168
+ private
169
+
170
+ def object_value(type, selections, data)
171
+ return data if data.nil?
172
+
173
+ # abstract types anonymize as the member the response says it was
174
+ if (typename = data["__typename"]) && type.graphql_name != typename
175
+ type = @schema.get_type(typename) || type
176
+ end
177
+
178
+ result = {}
179
+ each_field(type, selections) do |key, node|
180
+ next unless data.key?(key)
181
+
182
+ result[key] = if node.name == "__typename"
183
+ data[key]
184
+ else
185
+ field_value(type, node, data[key])
186
+ end
187
+ end
188
+
189
+ result
190
+ end
191
+
192
+ def field_value(parent_type, node, value)
193
+ type_value(@schema.get_field(parent_type.graphql_name, node.name).type, node, value)
194
+ end
195
+
196
+ def type_value(type, node, value)
197
+ return if value.nil? # preserve null positions
198
+
199
+ case type.kind.name
200
+ when "NON_NULL"
201
+ type_value(type.of_type, node, value)
202
+ when "LIST"
203
+ value.map { |element| type_value(type.of_type, node, element) }
204
+ when "SCALAR"
205
+ scalar_value(type.graphql_name, node.name, value)
206
+ when "ENUM"
207
+ value # enums aren't PII; preserving them keeps semantics
208
+ when "OBJECT", "UNION", "INTERFACE"
209
+ object_value(type, node.selections, value)
210
+ else
211
+ value
212
+ end
213
+ end
214
+
215
+ def scalar_value(type_name, field_name, value)
216
+ case type_name
217
+ when "ID" then @values.mapped_id(value)
218
+ when "Boolean" then value # not PII; preserves branching behavior
219
+ else @values.scalar(type_name, field_name)
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end