graph_weaver 0.0.1 → 0.1.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.
@@ -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, FakeExecutor, 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 NotImplementedError, "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 NotImplementedError, "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,203 @@
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 ReplayExecutor 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
+ (RecordingExecutor / Cassette.use with a live executor, 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 executor, replay when the file exists:
27
+ # executor = GraphWeaver::Testing::Cassette.use("github", executor: 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
+ # executor: is required to record; omit it to replay-or-raise.
49
+ def self.use(path, executor: nil)
50
+ cassette = new(path)
51
+ if cassette.exist?
52
+ ReplayExecutor.new(cassette)
53
+ elsif executor
54
+ RecordingExecutor.new(executor, cassette)
55
+ else
56
+ raise MissingRecording.new(path: cassette.path, query: "(no executor to record with)")
57
+ end
58
+ end
59
+
60
+ def lookup(query, variables)
61
+ @entries.find { |entry| entry["key"] == self.class.key(query, variables) }
62
+ end
63
+
64
+ def record(query, variables, response)
65
+ entry = {
66
+ "key" => self.class.key(query, variables),
67
+ "query" => query,
68
+ "variables" => variables,
69
+ "response" => response,
70
+ }
71
+ @entries.reject! { |existing| existing["key"] == entry["key"] }
72
+ @entries << entry
73
+ save
74
+ end
75
+
76
+ # Replace recorded response values with fakes, preserving structure.
77
+ # Walks each entry's query against the schema (like FakeExecutor,
78
+ # but transforming what's there instead of generating from scratch).
79
+ def anonymize!(schema:, seed: nil, mode: nil)
80
+ anonymizer = Anonymizer.new(schema:, seed:, mode:)
81
+ @entries.each do |entry|
82
+ data = entry.dig("response", "data")
83
+ entry["response"]["data"] = anonymizer.anonymize(entry["query"], data) if data
84
+ end
85
+ save
86
+ self
87
+ end
88
+
89
+ def self.key(query, variables)
90
+ { "query" => query.gsub(/\s+/, " ").strip, "variables" => variables || {} }
91
+ end
92
+
93
+ private
94
+
95
+ def save
96
+ FileUtils.mkdir_p(File.dirname(@path))
97
+ File.write(@path, YAML.dump(@entries))
98
+ end
99
+ end
100
+
101
+ # Tees requests through a live executor and records every response.
102
+ class RecordingExecutor
103
+ def initialize(executor, cassette)
104
+ @executor = executor
105
+ @cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
106
+ end
107
+
108
+ def execute(query, variables: {})
109
+ response = @executor.execute(query, variables:).to_h
110
+ @cassette.record(query, variables, response)
111
+ response
112
+ end
113
+ end
114
+
115
+ # Serves recorded responses; raises MissingRecording on unknown
116
+ # requests rather than silently faking.
117
+ class ReplayExecutor
118
+ def initialize(cassette)
119
+ @cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
120
+ end
121
+
122
+ def execute(query, variables: {})
123
+ entry = @cassette.lookup(query, variables)
124
+ raise MissingRecording.new(path: @cassette.path, query:) unless entry
125
+
126
+ entry["response"]
127
+ end
128
+ end
129
+
130
+ # Rewrites a recorded response through the Values engine: same shape,
131
+ # fake values. Enums, booleans, __typename, and null positions are
132
+ # preserved; ids map consistently so relationships survive.
133
+ class Anonymizer
134
+ include GraphWeaver::Selection
135
+
136
+ def initialize(schema:, seed: nil, mode: nil)
137
+ @schema = schema
138
+ @values = Values.new(seed:, mode:)
139
+ end
140
+
141
+ def anonymize(query, data)
142
+ operation = load_operation(query)
143
+
144
+ object_value(operation_root_type(operation), operation.selections, data)
145
+ end
146
+
147
+ private
148
+
149
+ def object_value(type, selections, data)
150
+ return data if data.nil?
151
+
152
+ # abstract types anonymize as the member the response says it was
153
+ if (typename = data["__typename"]) && type.graphql_name != typename
154
+ type = @schema.get_type(typename) || type
155
+ end
156
+
157
+ result = {}
158
+ each_field(type, selections) do |key, node|
159
+ next unless data.key?(key)
160
+
161
+ result[key] = if node.name == "__typename"
162
+ data[key]
163
+ else
164
+ field_value(type, node, data[key])
165
+ end
166
+ end
167
+
168
+ result
169
+ end
170
+
171
+ def field_value(parent_type, node, value)
172
+ type_value(@schema.get_field(parent_type.graphql_name, node.name).type, node, value)
173
+ end
174
+
175
+ def type_value(type, node, value)
176
+ return if value.nil? # preserve null positions
177
+
178
+ case type.kind.name
179
+ when "NON_NULL"
180
+ type_value(type.of_type, node, value)
181
+ when "LIST"
182
+ value.map { |element| type_value(type.of_type, node, element) }
183
+ when "SCALAR"
184
+ scalar_value(type.graphql_name, node.name, value)
185
+ when "ENUM"
186
+ value # enums aren't PII; preserving them keeps semantics
187
+ when "OBJECT", "UNION", "INTERFACE"
188
+ object_value(type, node.selections, value)
189
+ else
190
+ value
191
+ end
192
+ end
193
+
194
+ def scalar_value(type_name, field_name, value)
195
+ case type_name
196
+ when "ID" then @values.mapped_id(value)
197
+ when "Boolean" then value # not PII; preserves branching behavior
198
+ else @values.scalar(type_name, field_name)
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,109 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ module GraphWeaver
7
+ module Testing
8
+ # Canned failure executors — each produces exactly what the real
9
+ # transports produce, so error-handling paths are testable without a
10
+ # server that misbehaves on cue:
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
16
+ #
17
+ # For type mismatches, corrupt the wire with a FakeExecutor override:
18
+ # FakeExecutor.new(schema:, overrides: { "Person.birthday" => 123 })
19
+ # casting then raises GraphWeaver::TypeError, exactly as a bad server
20
+ # payload would. For partial failures, see FakeExecutor's fail_at:.
21
+ module Failure
22
+ include Kernel # for sorbet
23
+ module_function
24
+
25
+ # the request never reaches the server — cause preserved, like the
26
+ # bundled transports do
27
+ def transport(message = "simulated network failure", cause: SocketError)
28
+ FailureExecutor.new do
29
+ raise cause, message
30
+ rescue cause => e
31
+ raise GraphWeaver::TransportError, e.message
32
+ end
33
+ end
34
+
35
+ # the server answered non-2xx
36
+ def server(status: 500, body: "simulated server error")
37
+ FailureExecutor.new { raise GraphWeaver::ServerError.new(status:, body:) }
38
+ end
39
+
40
+ # top-level GraphQL errors: strings, or hashes with message/path/
41
+ # extensions; data: rides along for partial-failure envelopes
42
+ def graphql(*errors, data: nil, extensions: {})
43
+ normalized = errors.flatten.map do |error|
44
+ error.is_a?(String) ? { "message" => error } : JSON.parse(JSON.generate(error))
45
+ end
46
+
47
+ response = { "errors" => normalized }
48
+ response["data"] = data if data
49
+ response["extensions"] = JSON.parse(JSON.generate(extensions)) unless extensions.empty?
50
+ FailureExecutor.new { response }
51
+ end
52
+
53
+ def throttled
54
+ # array-wrapped so the hash can't parse as kwargs
55
+ graphql([{ message: "rate limited", extensions: { code: "THROTTLED" } }])
56
+ end
57
+
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):
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
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
+
77
+ graphql("Field '#{field || "someField"}' doesn't exist on type '#{type || "SomeType"}'")
78
+ end
79
+ end
80
+
81
+ # runs the block per request — raise or return an envelope
82
+ class FailureExecutor
83
+ def initialize(&response)
84
+ @response = response
85
+ end
86
+
87
+ def execute(_query, variables: {})
88
+ @response.call
89
+ end
90
+ end
91
+
92
+ # Delegates each call to the next executor in line (the last one
93
+ # repeats) — fail N times, then succeed, for retry/backoff testing:
94
+ #
95
+ # SequenceExecutor.new(Failure.transport, Failure.transport, fake)
96
+ class SequenceExecutor
97
+ def initialize(*executors)
98
+ @executors = executors.flatten
99
+ @calls = 0
100
+ end
101
+
102
+ def execute(query, variables: {})
103
+ executor = @executors[[@calls, @executors.size - 1].min]
104
+ @calls += 1
105
+ executor.execute(query, variables:)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,228 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "graphql"
5
+ require "json"
6
+
7
+ # An executor that fabricates schema-correct responses for whatever query
8
+ # arrives — the zero-setup way to test code built on generated modules:
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
13
+ #
14
+ # Values are type-correct by construction (real enum values, valid
15
+ # __typename members for unions/interfaces, iso8601 for date scalars), so
16
+ # every fake response casts cleanly through the generated structs. See
17
+ # Values for value fabrication (mode: :faker / :literal).
18
+ #
19
+ # overrides: pin fields by GraphQL name — schema vocabulary, so keys
20
+ # survive query refactors. "Type.field" beats "field"; values are
21
+ # literals or zero-arg procs. (An override with a wrong-typed value is
22
+ # also the way to simulate a corrupt payload — casting raises
23
+ # GraphWeaver::TypeError.)
24
+ #
25
+ # FakeExecutor.new(schema:, overrides: {
26
+ # "Person.name" => "Daniel",
27
+ # "email" => -> { "test@example.com" },
28
+ # })
29
+ #
30
+ # Partial failures: fail_at simulates a field-level error with
31
+ # spec-correct null propagation — the field's error lands in the errors
32
+ # array (with its concrete path), the field becomes null, and nulls
33
+ # bubble past non-null positions to the nearest nullable ancestor, just
34
+ # like a real server:
35
+ #
36
+ # FakeExecutor.new(schema:, fail_at: "person.pets.name")
37
+ # FakeExecutor.new(schema:, fail_at: { path: "person.email", message: "hidden", code: "PRIVATE" })
38
+ #
39
+ # errors: appends verbatim top-level errors alongside the fake data.
40
+ #
41
+ # Type mismatches: corrupt: names fields ("Type.field") that should
42
+ # arrive wire-corrupted — a wrong-typed value derived from the schema,
43
+ # so casting raises GraphWeaver::TypeError. One spec checks the failure
44
+ # path; every other spec gets working data:
45
+ #
46
+ # FakeExecutor.new(schema:, corrupt: "Person.birthday")
47
+ #
48
+ # seed: makes a run reproducible (also seeds faker). Per-executor options
49
+ # fall back to GraphWeaver::Testing.config.
50
+ class GraphWeaver::Testing::FakeExecutor
51
+ include GraphWeaver::Selection
52
+
53
+ # sentinel: a simulated failure bubbling up to the nearest nullable spot
54
+ NULL_BUBBLE = Object.new.freeze
55
+
56
+ def initialize(schema:, overrides: {}, seed: nil, mode: nil, list_size: nil, null_chance: nil,
57
+ errors: nil, fail_at: nil, corrupt: nil)
58
+ config = GraphWeaver::Testing.config
59
+ @schema = schema
60
+ @overrides = config.overrides.merge(overrides)
61
+ @values = GraphWeaver::Testing::Values.new(seed:, mode:)
62
+ @list_size = list_size || config.list_size
63
+ @null_chance = null_chance || config.null_chance
64
+ # NOT Array(): it would explode a bare Hash into key/value pairs
65
+ @extra_errors = wrap(errors).map { |error| normalize_error(error) }
66
+ @fail_at = wrap(fail_at).map { |spec| normalize_fail_spec(spec) }
67
+ @corrupt = wrap(corrupt)
68
+ end
69
+
70
+ def execute(query, variables: {})
71
+ operation = load_operation(query)
72
+ root_type = operation_root_type(operation)
73
+
74
+ @path = []
75
+ @failures = []
76
+ data = object_value(root_type, operation.selections)
77
+ data = nil if data.equal?(NULL_BUBBLE) # total propagation, like a real server
78
+
79
+ response = { "data" => data }
80
+ errors = @failures + @extra_errors
81
+ response["errors"] = errors unless errors.empty?
82
+ response
83
+ end
84
+
85
+ private
86
+
87
+ def rng = @values.rng
88
+
89
+ def wrap(value)
90
+ case value
91
+ when nil then []
92
+ when Array then value
93
+ else [value]
94
+ end
95
+ end
96
+
97
+ def normalize_error(error)
98
+ error.is_a?(String) ? { "message" => error } : JSON.parse(JSON.generate(error))
99
+ end
100
+
101
+ def normalize_fail_spec(spec)
102
+ spec.is_a?(String) ? { "path" => spec } : JSON.parse(JSON.generate(spec))
103
+ end
104
+
105
+ def object_value(type, selections)
106
+ result = {}
107
+ each_field(type, selections) do |key, node|
108
+ @path.push(key)
109
+ value = node.name == "__typename" ? type.graphql_name : field_value(type, node)
110
+ @path.pop
111
+
112
+ if value.equal?(NULL_BUBBLE)
113
+ # bubble past non-null fields to the nearest nullable ancestor
114
+ return NULL_BUBBLE if non_null_field?(type, node)
115
+
116
+ value = nil
117
+ end
118
+ result[key] = value
119
+ end
120
+
121
+ result
122
+ end
123
+
124
+ def non_null_field?(type, node)
125
+ return false if node.name == "__typename"
126
+
127
+ @schema.get_field(type.graphql_name, node.name).type.kind.name == "NON_NULL"
128
+ end
129
+
130
+ def field_value(parent_type, node)
131
+ if (spec = matching_failure)
132
+ @failures << {
133
+ "message" => spec["message"] || "simulated failure",
134
+ "path" => @path.dup,
135
+ }.merge(spec["code"] ? { "extensions" => { "code" => spec["code"] } } : {})
136
+ spec["triggered"] = true
137
+
138
+ return NULL_BUBBLE
139
+ end
140
+
141
+ override = @overrides.fetch("#{parent_type.graphql_name}.#{node.name}") do
142
+ @overrides[node.name]
143
+ end
144
+ return override.is_a?(Proc) ? override.call : override unless override.nil?
145
+
146
+ field_type = @schema.get_field(parent_type.graphql_name, node.name).type
147
+ if @corrupt.include?("#{parent_type.graphql_name}.#{node.name}")
148
+ return corrupt_value(field_type)
149
+ end
150
+
151
+ type_value(field_type, node)
152
+ end
153
+
154
+ # a value casting can't accept, derived from the field's own type — and
155
+ # wrapped per list layer so the corruption lands on the element cast
156
+ def corrupt_value(type)
157
+ case type.kind.name
158
+ when "NON_NULL" then corrupt_value(type.of_type)
159
+ when "LIST" then [corrupt_value(type.of_type)]
160
+ when "SCALAR"
161
+ case type.graphql_name
162
+ when "Int", "Float" then "not-a-number"
163
+ when "Boolean" then "not-a-boolean"
164
+ else 123 # breaks String/ID props and every string-wire custom scalar
165
+ end
166
+ when "ENUM" then "__NOT_A_REAL_VALUE__"
167
+ else [] # objects/unions: an Array fails Hash-shaped casting loudly
168
+ end
169
+ end
170
+
171
+ # first untriggered fail_at spec whose field chain (indices stripped)
172
+ # matches where we are
173
+ def matching_failure
174
+ chain = @path.reject { |segment| segment.is_a?(Integer) }.join(".")
175
+ @fail_at.find { |spec| !spec["triggered"] && spec["path"] == chain }
176
+ end
177
+
178
+ # honor pagination-ish arg semantics: first/last/limit with a literal
179
+ # int caps the fabricated list length
180
+ def list_length(node)
181
+ argument = node.arguments.find { |arg| %w[first last limit].include?(arg.name) }
182
+ return argument.value if argument && argument.value.is_a?(Integer)
183
+
184
+ rng.rand(@list_size)
185
+ end
186
+
187
+ def type_value(type, node, non_null: false)
188
+ case type.kind.name
189
+ when "NON_NULL"
190
+ type_value(type.of_type, node, non_null: true)
191
+ when "LIST"
192
+ elements = Array.new(list_length(node)) do |index|
193
+ @path.push(index)
194
+ element = type_value(type.of_type, node)
195
+ @path.pop
196
+ element
197
+ end
198
+
199
+ if elements.any? { |element| element.equal?(NULL_BUBBLE) }
200
+ # non-null elements bubble the whole list; nullable ones go nil
201
+ return NULL_BUBBLE if type.of_type.kind.name == "NON_NULL"
202
+
203
+ elements.map! { |element| element.equal?(NULL_BUBBLE) ? nil : element }
204
+ end
205
+ elements
206
+ else
207
+ return if !non_null && rng.rand < @null_chance
208
+
209
+ core_value(type, node)
210
+ end
211
+ end
212
+
213
+ def core_value(type, node)
214
+ case type.kind.name
215
+ when "SCALAR"
216
+ @values.scalar(type.graphql_name, node.name)
217
+ when "ENUM"
218
+ type.values.keys.sort.sample(random: rng)
219
+ when "OBJECT"
220
+ object_value(type, node.selections)
221
+ when "UNION", "INTERFACE"
222
+ member = @schema.possible_types(type).sort_by(&:graphql_name).sample(random: rng)
223
+ object_value(member, node.selections)
224
+ else
225
+ raise NotImplementedError, "cannot fake kind: #{type.kind.name}"
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,5 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ # moved — the canonical entry point is:
5
+ require_relative "../rspec"