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