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.
- checksums.yaml +4 -4
- data/.yardopts +6 -0
- data/CHANGELOG.md +279 -0
- data/Gemfile.lock +75 -17
- data/Makefile +11 -1
- data/NOTES.md +1 -1
- data/PLAN.md +85 -22
- data/README.md +86 -26
- data/docs/cassettes.md +76 -0
- data/docs/errors.md +134 -0
- data/docs/generated_modules.md +229 -0
- data/docs/getting_started.md +134 -0
- data/docs/logging.md +33 -0
- data/docs/real_world.md +53 -0
- data/docs/scalars.md +183 -0
- data/docs/testing.md +103 -0
- data/docs/transports.md +124 -0
- data/graph_weaver.gemspec +10 -1
- data/lib/graph_weaver/client.rb +200 -0
- data/lib/graph_weaver/codegen/emit.rb +286 -0
- data/lib/graph_weaver/codegen/enum_type.rb +149 -0
- data/lib/graph_weaver/codegen/nodes.rb +356 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +256 -0
- data/lib/graph_weaver/codegen.rb +396 -401
- data/lib/graph_weaver/errors.rb +322 -0
- data/lib/graph_weaver/hints.rb +63 -0
- data/lib/graph_weaver/inflect.rb +19 -0
- data/lib/graph_weaver/logging.rb +41 -0
- data/lib/graph_weaver/railtie.rb +29 -0
- data/lib/graph_weaver/response.rb +55 -0
- data/lib/graph_weaver/retry.rb +97 -0
- data/lib/graph_weaver/rspec.rb +59 -0
- data/lib/graph_weaver/schema_loader.rb +208 -8
- data/lib/graph_weaver/selection.rb +68 -0
- data/lib/graph_weaver/tasks.rb +71 -0
- data/lib/graph_weaver/testing/cassette.rb +224 -0
- data/lib/graph_weaver/testing/failure.rb +109 -0
- data/lib/graph_weaver/testing/fake_client.rb +228 -0
- data/lib/graph_weaver/testing/values.rb +98 -0
- data/lib/graph_weaver/testing.rb +106 -0
- data/lib/graph_weaver/transport/faraday.rb +56 -0
- data/lib/graph_weaver/transport/http.rb +87 -0
- data/lib/graph_weaver/transport.rb +120 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +268 -4
- metadata +132 -2
- data/lib/graph_weaver/http_executor.rb +0 -31
|
@@ -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 clients — 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", 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
|
+
#
|
|
17
|
+
# For type mismatches, corrupt the wire with a FakeClient override:
|
|
18
|
+
# FakeClient.new(schema:, overrides: { "Person.birthday" => 123 })
|
|
19
|
+
# casting then raises GraphWeaver::TypeError, exactly as a bad server
|
|
20
|
+
# payload would. For partial failures, see FakeClient'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
|
+
FailureClient.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
|
+
FailureClient.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
|
+
FailureClient.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 FailureClient
|
|
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 client in line (the last one
|
|
93
|
+
# repeats) — fail N times, then succeed, for retry/backoff testing:
|
|
94
|
+
#
|
|
95
|
+
# Sequence.new(Failure.transport, Failure.transport, fake)
|
|
96
|
+
class Sequence
|
|
97
|
+
def initialize(*clients)
|
|
98
|
+
@clients = clients.flatten
|
|
99
|
+
@calls = 0
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def execute(query, variables: {})
|
|
103
|
+
client = @clients[[@calls, @clients.size - 1].min]
|
|
104
|
+
@calls += 1
|
|
105
|
+
client.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
|
+
# A fake client 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::FakeClient.new(schema:)
|
|
11
|
+
# result = PersonQuery.execute!(id: "1", fake — positionally)
|
|
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
|
+
# FakeClient.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
|
+
# FakeClient.new(schema:, fail_at: "person.pets.name")
|
|
37
|
+
# FakeClient.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
|
+
# FakeClient.new(schema:, corrupt: "Person.birthday")
|
|
47
|
+
#
|
|
48
|
+
# seed: makes a run reproducible (also seeds faker). Per-instance options
|
|
49
|
+
# fall back to GraphWeaver::Testing.config.
|
|
50
|
+
class GraphWeaver::Testing::FakeClient
|
|
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,98 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
# The value engine behind FakeClient and Cassette#anonymize!: seeded,
|
|
7
|
+
# type-correct scalar generation with optional faker-backed semantics
|
|
8
|
+
# matched on field names — strings (name/email/url/...) and numbers
|
|
9
|
+
# (age/price/count/latitude/...) alike. Keeps a consistent id mapping so
|
|
10
|
+
# the same original id always anonymizes to the same fake id.
|
|
11
|
+
class GraphWeaver::Testing::Values
|
|
12
|
+
include GraphWeaver::Inflect
|
|
13
|
+
|
|
14
|
+
STRING_SEMANTICS = {
|
|
15
|
+
/email/ => -> { ::Faker::Internet.email },
|
|
16
|
+
/(^|_)first_name$/ => -> { ::Faker::Name.first_name },
|
|
17
|
+
/(^|_)last_name$/ => -> { ::Faker::Name.last_name },
|
|
18
|
+
/(^|_)(full_)?name$/ => -> { ::Faker::Name.name },
|
|
19
|
+
/(^|_)(url|website|link)$/ => -> { ::Faker::Internet.url },
|
|
20
|
+
/phone/ => -> { ::Faker::PhoneNumber.phone_number },
|
|
21
|
+
/(^|_)address$/ => -> { ::Faker::Address.full_address },
|
|
22
|
+
/(^|_)(city)$/ => -> { ::Faker::Address.city },
|
|
23
|
+
/(^|_)(title|description)$/ => -> { ::Faker::Lorem.sentence(word_count: 3) },
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
NUMBER_SEMANTICS = {
|
|
27
|
+
/(^|_)age$/ => ->(rng) { rng.rand(1..99) },
|
|
28
|
+
/(^|_)(price|amount|cost|total)(_cents)?$/ => ->(rng) { (rng.rand(1.0..10_000.0) * 100).round / 100.0 },
|
|
29
|
+
/(^|_)(count|quantity|size)$/ => ->(rng) { rng.rand(0..100) },
|
|
30
|
+
/latitude/ => ->(rng) { rng.rand(-90.0..90.0).round(6) },
|
|
31
|
+
/longitude/ => ->(rng) { rng.rand(-180.0..180.0).round(6) },
|
|
32
|
+
/(^|_)year$/ => ->(rng) { rng.rand(1970..2030) },
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
attr_reader :rng
|
|
36
|
+
|
|
37
|
+
def initialize(seed: nil, mode: nil)
|
|
38
|
+
config = GraphWeaver::Testing.config
|
|
39
|
+
@rng = Random.new(seed || config.seed || Random.new_seed)
|
|
40
|
+
@mode = resolve_mode(mode || config.mode)
|
|
41
|
+
@sequence = 0
|
|
42
|
+
@id_map = {}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def scalar(type_name, field_name)
|
|
46
|
+
prop = underscore(field_name)
|
|
47
|
+
|
|
48
|
+
if @mode == :faker
|
|
49
|
+
# rebind per call: several Values instances may interleave (e.g. two
|
|
50
|
+
# seeded fakes), and faker's rng is global
|
|
51
|
+
::Faker::Config.random = @rng
|
|
52
|
+
case type_name
|
|
53
|
+
when "String"
|
|
54
|
+
STRING_SEMANTICS.each { |pattern, faker| return faker.call if pattern.match?(prop) }
|
|
55
|
+
when "Int", "Float"
|
|
56
|
+
NUMBER_SEMANTICS.each do |pattern, gen|
|
|
57
|
+
next unless pattern.match?(prop)
|
|
58
|
+
|
|
59
|
+
value = gen.call(@rng)
|
|
60
|
+
return type_name == "Int" ? value.to_i : value.to_f
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
case type_name
|
|
66
|
+
when "ID" then (@sequence += 1).to_s
|
|
67
|
+
when "String" then "#{field_name}-#{@sequence += 1}"
|
|
68
|
+
when "Int" then @rng.rand(0..1_000)
|
|
69
|
+
when "Float" then @rng.rand(0.0..1_000.0).round(2)
|
|
70
|
+
when "Boolean" then [true, false].sample(random: @rng)
|
|
71
|
+
when "Date" then (Date.new(2020, 1, 1) + @rng.rand(0..2_000)).iso8601
|
|
72
|
+
when "DateTime", "Time", "ISO8601DateTime" then Time.at(1_600_000_000 + @rng.rand(0..100_000_000)).utc.iso8601
|
|
73
|
+
else "#{type_name}-#{@sequence += 1}" # unknown custom scalar: override it
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# same original id => same fake id, so relationships survive anonymization
|
|
78
|
+
def mapped_id(original)
|
|
79
|
+
@id_map[original] ||= (@sequence += 1).to_s
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# :faker is an explicit ask — fail loudly when the gem is missing; auto
|
|
85
|
+
# (nil) quietly falls back to :literal
|
|
86
|
+
def resolve_mode(mode)
|
|
87
|
+
case mode
|
|
88
|
+
when :faker
|
|
89
|
+
raise ArgumentError, "mode: :faker requires the faker gem (add it to your Gemfile's test group)" unless defined?(::Faker)
|
|
90
|
+
|
|
91
|
+
:faker
|
|
92
|
+
when :literal then :literal
|
|
93
|
+
when nil then defined?(::Faker) ? :faker : :literal
|
|
94
|
+
else
|
|
95
|
+
raise ArgumentError, "mode: must be one of #{GraphWeaver::Testing::MODES.inspect} (or nil for auto), got #{mode.inspect}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../graph_weaver"
|
|
5
|
+
|
|
6
|
+
# faker is optional — semantic values (name/email/age/price/...) when
|
|
7
|
+
# present, type-based values when not
|
|
8
|
+
begin
|
|
9
|
+
require "faker"
|
|
10
|
+
rescue LoadError
|
|
11
|
+
# fall back to type-based generation
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Opt-in test tooling: require "graph_weaver/testing" from your spec
|
|
15
|
+
# helper (never from production code). Configure once, initializer-style:
|
|
16
|
+
#
|
|
17
|
+
# GraphWeaver::Testing.configure do |config|
|
|
18
|
+
# config.schema = MySchema # for auto_fake / cassettes
|
|
19
|
+
# config.seed = 42 # reproducible fakes
|
|
20
|
+
# config.mode = :faker # or :literal; nil = auto
|
|
21
|
+
# config.overrides = { "Person.name" => "Daniel" }
|
|
22
|
+
# config.list_size = 2..4
|
|
23
|
+
# config.null_chance = 0.1 # nullable fields go nil sometimes
|
|
24
|
+
# config.cassette_dir = "spec/cassettes"
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# mode picks how values are fabricated:
|
|
28
|
+
# :faker — semantic, field-name matched (requires the faker gem)
|
|
29
|
+
# :literal — plain type-derived values ("name-1", seeded numbers)
|
|
30
|
+
# nil — auto: :faker when the gem is loaded, else :literal
|
|
31
|
+
#
|
|
32
|
+
# rspec users: require "graph_weaver/rspec" instead — it hooks the suite
|
|
33
|
+
# (seed from rspec, optional auto-faked client per example).
|
|
34
|
+
module GraphWeaver
|
|
35
|
+
module Testing
|
|
36
|
+
MODES = [:faker, :literal].freeze
|
|
37
|
+
|
|
38
|
+
class Config
|
|
39
|
+
attr_accessor :overrides, :seed, :list_size, :null_chance, :cassette_dir, :auto_fake,
|
|
40
|
+
:record, :anonymize
|
|
41
|
+
attr_writer :schema
|
|
42
|
+
attr_reader :mode
|
|
43
|
+
|
|
44
|
+
def initialize
|
|
45
|
+
@overrides = {}
|
|
46
|
+
@seed = nil
|
|
47
|
+
@list_size = 1..3
|
|
48
|
+
@null_chance = 0.0
|
|
49
|
+
@mode = nil # auto
|
|
50
|
+
@schema = nil
|
|
51
|
+
@cassette_dir = "spec/cassettes"
|
|
52
|
+
# on by default — engages only when a schema resolves (below), so
|
|
53
|
+
# requiring graph_weaver/rspec in a conventional app is
|
|
54
|
+
# zero-config; config.auto_fake = false opts out
|
|
55
|
+
@auto_fake = true
|
|
56
|
+
# GRAPHWEAVER_RECORD=1 rspec ... -> Cassette.use re-records
|
|
57
|
+
@record = !ENV["GRAPHWEAVER_RECORD"].to_s.empty?
|
|
58
|
+
# anonymize responses as they're recorded (needs config.schema)
|
|
59
|
+
@anonymize = false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# the explicitly configured schema, else the conventional dump
|
|
63
|
+
# (SchemaLoader.locate at GraphWeaver.schema_path) — nil when
|
|
64
|
+
# neither exists, which quietly disables auto_fake
|
|
65
|
+
def schema
|
|
66
|
+
@schema ||= GraphWeaver::SchemaLoader.locate
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def mode=(mode)
|
|
70
|
+
unless mode.nil? || MODES.include?(mode)
|
|
71
|
+
raise ArgumentError, "mode: must be one of #{MODES.inspect} (or nil for auto), got #{mode.inspect}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@mode = mode
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class << self
|
|
79
|
+
def config
|
|
80
|
+
@config ||= Config.new
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def configure
|
|
84
|
+
yield config
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# back to defaults — between tests, or to undo an experiment
|
|
88
|
+
def reset!
|
|
89
|
+
@config = nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# resolve a cassette name ("github") against cassette_dir; paths
|
|
93
|
+
# with separators or extensions pass through
|
|
94
|
+
def cassette_path(name)
|
|
95
|
+
return name if name.include?("/") || name.end_with?(".yml", ".yaml")
|
|
96
|
+
|
|
97
|
+
File.join(config.cassette_dir, "#{name}.yml")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
require_relative "testing/values"
|
|
104
|
+
require_relative "testing/fake_client"
|
|
105
|
+
require_relative "testing/failure"
|
|
106
|
+
require_relative "testing/cassette"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "faraday"
|
|
5
|
+
|
|
6
|
+
require_relative "../transport"
|
|
7
|
+
|
|
8
|
+
module GraphWeaver
|
|
9
|
+
class Transport
|
|
10
|
+
# Faraday-backed transport. Opt-in (faraday is not a hard dependency):
|
|
11
|
+
#
|
|
12
|
+
# require "graph_weaver/transport/faraday"
|
|
13
|
+
#
|
|
14
|
+
# # simplest: build a default connection from a url
|
|
15
|
+
# GraphWeaver::Transport::Faraday.new("https://api.example.com/graphql")
|
|
16
|
+
#
|
|
17
|
+
# # customize middleware while building
|
|
18
|
+
# GraphWeaver::Transport::Faraday.new(url) do |conn|
|
|
19
|
+
# conn.request :authorization, "Bearer", -> { Tokens.fetch }
|
|
20
|
+
# conn.response :logger
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# # or bring a fully configured connection
|
|
24
|
+
# GraphWeaver::Transport::Faraday.new(Faraday.new(url:) { |conn| ... })
|
|
25
|
+
class Faraday < Transport
|
|
26
|
+
# Faraday's network-level failures — added to the shared, extensible
|
|
27
|
+
# transport-error set.
|
|
28
|
+
GraphWeaver.register_transport_error(
|
|
29
|
+
::Faraday::ConnectionFailed, ::Faraday::TimeoutError, ::Faraday::SSLError
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def initialize(url_or_connection, headers: {}, &block)
|
|
33
|
+
@connection = case url_or_connection
|
|
34
|
+
when ::Faraday::Connection
|
|
35
|
+
url_or_connection
|
|
36
|
+
else
|
|
37
|
+
# Faraday appends the default adapter when the block doesn't set one
|
|
38
|
+
::Faraday.new(url: url_or_connection, headers:, &block)
|
|
39
|
+
end
|
|
40
|
+
@url = @connection.url_prefix.to_s
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
sig { override.params(body: String).returns([Integer, T.untyped]) }
|
|
46
|
+
def post(body)
|
|
47
|
+
response = @connection.post do |request|
|
|
48
|
+
request.headers["Content-Type"] = "application/json"
|
|
49
|
+
request.body = body
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
[response.status, response.body]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
require_relative "../transport"
|
|
9
|
+
|
|
10
|
+
module GraphWeaver
|
|
11
|
+
class Transport
|
|
12
|
+
# Minimal net/http transport — zero dependencies, loaded by default:
|
|
13
|
+
#
|
|
14
|
+
# GraphWeaver::Transport::HTTP.new(url, headers: { ... }, read_timeout: 10)
|
|
15
|
+
#
|
|
16
|
+
# Timeouts surface as TransportError (retriable). The connection is
|
|
17
|
+
# persistent (keep-alive), serialized behind a mutex — one socket per
|
|
18
|
+
# transport, dropped on any failure so the next call starts fresh.
|
|
19
|
+
# For real connection pooling and a middleware ecosystem, use
|
|
20
|
+
# Transport::Faraday.
|
|
21
|
+
class HTTP < Transport
|
|
22
|
+
# net/http's own network-level failures (Errno/SocketError/IOError
|
|
23
|
+
# are already seeded) — added to the shared, extensible
|
|
24
|
+
# transport-error set.
|
|
25
|
+
GraphWeaver.register_transport_error(Timeout::Error, OpenSSL::SSL::SSLError)
|
|
26
|
+
|
|
27
|
+
def initialize(url, headers: {}, open_timeout: 10, read_timeout: 30, keep_alive_timeout: 2)
|
|
28
|
+
@url = url
|
|
29
|
+
@uri = URI(url)
|
|
30
|
+
@headers = headers
|
|
31
|
+
@open_timeout = open_timeout
|
|
32
|
+
@read_timeout = read_timeout
|
|
33
|
+
@keep_alive_timeout = keep_alive_timeout
|
|
34
|
+
@mutex = Mutex.new
|
|
35
|
+
@http = T.let(nil, T.nilable(Net::HTTP))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
sig { override.params(body: String).returns([Integer, T.untyped]) }
|
|
41
|
+
def post(body)
|
|
42
|
+
request = Net::HTTP::Post.new(@uri, { "Content-Type" => "application/json" }.merge(@headers))
|
|
43
|
+
request.body = body
|
|
44
|
+
|
|
45
|
+
response = @mutex.synchronize do
|
|
46
|
+
begin
|
|
47
|
+
connection.request(request)
|
|
48
|
+
rescue => e
|
|
49
|
+
# socket state is unknown — drop it so the next call starts
|
|
50
|
+
# fresh (retry policy belongs to Retry, not here)
|
|
51
|
+
disconnect
|
|
52
|
+
raise e
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
[response.code.to_i, response.body]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The persistent connection. net/http proactively reconnects when
|
|
60
|
+
# idle past keep_alive_timeout, so a server-closed keep-alive
|
|
61
|
+
# socket doesn't produce spurious failures.
|
|
62
|
+
def connection
|
|
63
|
+
@http ||= begin
|
|
64
|
+
GraphWeaver.log(:debug) { "connecting to #{@uri.hostname}:#{@uri.port}" }
|
|
65
|
+
Net::HTTP.start(
|
|
66
|
+
@uri.hostname, @uri.port,
|
|
67
|
+
use_ssl: @uri.scheme == "https",
|
|
68
|
+
open_timeout: @open_timeout, read_timeout: @read_timeout,
|
|
69
|
+
keep_alive_timeout: @keep_alive_timeout,
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def disconnect
|
|
75
|
+
http = @http
|
|
76
|
+
return unless http
|
|
77
|
+
|
|
78
|
+
GraphWeaver.log(:debug) { "dropping connection to #{@uri.hostname}:#{@uri.port}" }
|
|
79
|
+
http.finish if http.started?
|
|
80
|
+
rescue IOError
|
|
81
|
+
# already closed
|
|
82
|
+
ensure
|
|
83
|
+
@http = nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|