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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/Gemfile.lock +45 -17
- data/Makefile +6 -1
- data/PLAN.md +52 -22
- data/README.md +62 -17
- data/docs/errors.md +106 -0
- data/docs/generated_modules.md +119 -0
- data/docs/real_world.md +46 -0
- data/docs/scalars.md +69 -0
- data/docs/testing.md +93 -0
- data/graph_weaver.gemspec +3 -0
- data/lib/graph_weaver/codegen/emit.rb +237 -0
- data/lib/graph_weaver/codegen/nodes.rb +256 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +238 -0
- data/lib/graph_weaver/codegen.rb +156 -395
- data/lib/graph_weaver/errors.rb +310 -0
- data/lib/graph_weaver/faraday_executor.rb +61 -0
- data/lib/graph_weaver/http_executor.rb +16 -3
- data/lib/graph_weaver/inflect.rb +18 -0
- data/lib/graph_weaver/response.rb +55 -0
- data/lib/graph_weaver/rspec.rb +54 -0
- data/lib/graph_weaver/schema_loader.rb +73 -9
- data/lib/graph_weaver/selection.rb +68 -0
- data/lib/graph_weaver/testing/cassette.rb +203 -0
- data/lib/graph_weaver/testing/failure.rb +109 -0
- data/lib/graph_weaver/testing/fake_executor.rb +228 -0
- data/lib/graph_weaver/testing/rspec.rb +5 -0
- data/lib/graph_weaver/testing/values.rb +98 -0
- data/lib/graph_weaver/testing.rb +90 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +97 -3
- metadata +63 -1
|
@@ -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
|