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
|
@@ -1,20 +1,220 @@
|
|
|
1
1
|
# typed: true
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "fileutils"
|
|
4
5
|
require "graphql"
|
|
5
6
|
require "json"
|
|
7
|
+
require_relative "errors"
|
|
6
8
|
|
|
7
9
|
# Load a schema for codegen from either format a remote service can hand
|
|
8
|
-
# you
|
|
10
|
+
# you — introspection JSON or SDL, as a file path or the content itself —
|
|
11
|
+
# or fetch one straight from a live endpoint via introspect.
|
|
9
12
|
module GraphWeaver::SchemaLoader
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
# Accepts, and detects:
|
|
14
|
+
# - a Hash (a parsed introspection result)
|
|
15
|
+
# - a file path — .json (introspection) or .graphql/.gql (SDL)
|
|
16
|
+
# - raw content — introspection JSON (starts with "{") or SDL
|
|
17
|
+
# so a cache round-trip is symmetrical with introspect:
|
|
18
|
+
# SchemaLoader.load(cached_json) # from Rails.cache/redis/...
|
|
19
|
+
def self.load(source)
|
|
20
|
+
return GraphQL::Schema.from_introspection(source) if source.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
if source.lstrip.start_with?("{") # introspection JSON content
|
|
23
|
+
GraphQL::Schema.from_introspection(JSON.parse(source))
|
|
24
|
+
elsif source.include?("\n") # multi-line: SDL content
|
|
25
|
+
unless source.match?(/^\s*(schema|type|interface|union|enum|scalar|directive|input|")/)
|
|
26
|
+
raise ArgumentError, "unsupported schema content: #{source.lstrip[0, 80].inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
GraphQL::Schema.from_definition(source)
|
|
30
|
+
else # a file path
|
|
31
|
+
case File.extname(source)
|
|
32
|
+
when ".json"
|
|
33
|
+
GraphQL::Schema.from_introspection(JSON.parse(File.read(source)))
|
|
34
|
+
when ".graphql", ".gql"
|
|
35
|
+
GraphQL::Schema.from_definition(File.read(source))
|
|
36
|
+
else
|
|
37
|
+
raise ArgumentError, "unsupported schema format: #{source}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Run the standard introspection query through a transport and build a
|
|
43
|
+
# schema from the result:
|
|
44
|
+
#
|
|
45
|
+
# transport = GraphWeaver::Transport::HTTP.new(url, headers: { ... })
|
|
46
|
+
# schema = GraphWeaver::SchemaLoader.introspect(transport)
|
|
47
|
+
#
|
|
48
|
+
# Introspecting a large API takes seconds, so cache: dumps the schema
|
|
49
|
+
# to a file and reuses it until ttl: seconds elapse (no ttl = until the
|
|
50
|
+
# file is deleted). cache: takes
|
|
51
|
+
# - true — GraphWeaver.schema_path, the file the generation workflow
|
|
52
|
+
# reads (its extension picks the format)
|
|
53
|
+
# - a path — the extension picks the format: .json is the verbatim
|
|
54
|
+
# introspection result, .graphql/.gql is SDL (human-readable,
|
|
55
|
+
# PR-reviewable diffs); both load back identically
|
|
56
|
+
# - :json / :graphql / :gql — GraphWeaver.schema_path's location, in
|
|
57
|
+
# that format
|
|
58
|
+
# Reading is format-agnostic: any fresh sibling dump counts, whatever
|
|
59
|
+
# its format — an existing schema.graphql is reused rather than
|
|
60
|
+
# re-introspecting to write schema.json.
|
|
61
|
+
# GraphQL has no standard schema-version signal to invalidate on — a
|
|
62
|
+
# stale cache surfaces as server-side validation errors (see
|
|
63
|
+
# QueryError#schema_stale?), so pick a ttl that matches how fast the
|
|
64
|
+
# API moves, or delete the file.
|
|
65
|
+
#
|
|
66
|
+
# To cache anywhere else (Rails.cache, redis, ...), serialize the schema
|
|
67
|
+
# itself — schemas round-trip through their introspection JSON:
|
|
68
|
+
#
|
|
69
|
+
# json = Rails.cache.fetch("gh_schema", expires_in: 12.hours) do
|
|
70
|
+
# GraphWeaver::SchemaLoader.introspect(transport).to_json
|
|
71
|
+
# end
|
|
72
|
+
# schema = GraphWeaver::SchemaLoader.load(json)
|
|
73
|
+
def self.introspect(transport, cache: nil, ttl: nil)
|
|
74
|
+
cache = cache_path(cache)
|
|
75
|
+
|
|
76
|
+
if cache
|
|
77
|
+
# reuse whatever fresh dump is present, regardless of format —
|
|
78
|
+
# don't re-introspect to write schema.json when a usable
|
|
79
|
+
# schema.graphql already sits there
|
|
80
|
+
existing = cache_candidates(cache).find { |candidate| fresh?(candidate, ttl) }
|
|
81
|
+
if existing
|
|
82
|
+
GraphWeaver.log(:info) { "schema cache hit: #{existing}#{" (ttl #{ttl}s)" if ttl}" }
|
|
83
|
+
return load(existing)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
GraphWeaver.log(:info) { "schema cache miss: #{cache}" }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
result = GraphWeaver.log_timed(:info, "introspected #{transport.respond_to?(:url) ? transport.url : transport.class}") do
|
|
90
|
+
transport.execute(GraphQL::Introspection.query, variables: {}).to_h
|
|
91
|
+
end
|
|
92
|
+
if (errors = result["errors"])
|
|
93
|
+
raise GraphWeaver::Error, "introspection failed: #{errors.inspect}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
schema = GraphQL::Schema.from_introspection(result)
|
|
97
|
+
|
|
98
|
+
if cache
|
|
99
|
+
FileUtils.mkdir_p(File.dirname(cache))
|
|
100
|
+
# the extension picks the format: .json is the verbatim wire
|
|
101
|
+
# artifact; .graphql/.gql is SDL — human-readable, PR-reviewable
|
|
102
|
+
# diffs (both generate byte-identical code)
|
|
103
|
+
meta = stamp(transport)
|
|
104
|
+
content = if cache.end_with?(".json")
|
|
105
|
+
JSON.generate(meta ? result.merge("graph_weaver" => meta) : result)
|
|
106
|
+
else
|
|
107
|
+
header = meta && "# graph_weaver: #{JSON.generate(meta)}\n\n"
|
|
108
|
+
"#{header}#{schema.to_definition}"
|
|
109
|
+
end
|
|
110
|
+
File.write(cache, content)
|
|
111
|
+
GraphWeaver.log(:info) { "wrote schema cache: #{cache} (#{content.bytesize} bytes)" }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
schema
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The conventional schema dump, whatever its format: schema_path or the
|
|
118
|
+
# first sibling extension that exists. nil when none is on disk.
|
|
119
|
+
def self.locate_path(path = GraphWeaver.schema_path)
|
|
120
|
+
cache_candidates(path).find { |candidate| File.exist?(candidate) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# locate_path, loaded.
|
|
124
|
+
def self.locate(path = GraphWeaver.schema_path)
|
|
125
|
+
found = locate_path(path)
|
|
126
|
+
found && load(found)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The provenance recorded in a dump ({"url" => ..., "introspected_at"
|
|
130
|
+
# => ...}), whichever format holds it; nil for local/unannotated dumps.
|
|
131
|
+
def self.provenance(path)
|
|
132
|
+
content = File.read(path)
|
|
133
|
+
if path.end_with?(".json")
|
|
134
|
+
JSON.parse(content)["graph_weaver"]
|
|
135
|
+
elsif (meta = content[/\A# graph_weaver: (\{.*\})$/, 1])
|
|
136
|
+
JSON.parse(meta)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Re-introspect a dump's source and compare — true when the server has
|
|
141
|
+
# drifted from what's on disk. transport: overrides the transport (auth
|
|
142
|
+
# etc); by default one is built from the dump's recorded url. Wired up
|
|
143
|
+
# as `rake graph_weaver:schema:verify` / `:refresh`.
|
|
144
|
+
def self.stale?(path, transport: nil)
|
|
145
|
+
transport ||= source_transport(path)
|
|
146
|
+
fresh = introspect(transport)
|
|
147
|
+
|
|
148
|
+
fresh.to_definition != load(path).to_definition
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# a transport to the dump's recorded url (GRAPHWEAVER_AUTH supplies a
|
|
152
|
+
# token when set)
|
|
153
|
+
def self.source_transport(path)
|
|
154
|
+
meta = provenance(path)
|
|
155
|
+
unless meta&.key?("url")
|
|
156
|
+
raise GraphWeaver::Error, "#{path} records no source url — pass transport:"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
GraphWeaver.new(meta["url"], auth: ENV["GRAPHWEAVER_AUTH"]).transport
|
|
160
|
+
end
|
|
161
|
+
private_class_method :source_transport
|
|
162
|
+
|
|
163
|
+
# Where a dump came from, recorded into the file so it can be
|
|
164
|
+
# re-verified later — a parsable header comment in SDL, a
|
|
165
|
+
# "graph_weaver" sibling key in introspection JSON (from_introspection
|
|
166
|
+
# reads only "data"). nil when the transport has no url (schema
|
|
167
|
+
# classes, fakes).
|
|
168
|
+
def self.stamp(transport)
|
|
169
|
+
return unless transport.respond_to?(:url) && transport.url
|
|
170
|
+
|
|
171
|
+
require "time"
|
|
172
|
+
{ "url" => transport.url, "introspected_at" => Time.now.utc.iso8601 }
|
|
173
|
+
end
|
|
174
|
+
private_class_method :stamp
|
|
175
|
+
|
|
176
|
+
CACHE_EXTENSIONS = %w[.json .graphql .gql].freeze
|
|
177
|
+
|
|
178
|
+
# cache: true / :json / :graphql / :gql / a path => the file to write
|
|
179
|
+
# (nil for no caching). Symbols and true anchor at GraphWeaver.schema_path —
|
|
180
|
+
# the schema dump the generation workflow reads, so one file serves both
|
|
181
|
+
# (introspect caches it, rake generate loads it).
|
|
182
|
+
def self.cache_path(cache)
|
|
183
|
+
case cache
|
|
184
|
+
when nil, false
|
|
185
|
+
nil
|
|
186
|
+
when true
|
|
187
|
+
GraphWeaver.schema_path
|
|
188
|
+
when Symbol
|
|
189
|
+
unless CACHE_EXTENSIONS.include?(".#{cache}")
|
|
190
|
+
raise ArgumentError, "cache: format must be :json, :graphql, or :gql, got #{cache.inspect}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
"#{strip_extension(GraphWeaver.schema_path)}.#{cache}"
|
|
16
194
|
else
|
|
17
|
-
|
|
195
|
+
unless cache.end_with?(*CACHE_EXTENSIONS)
|
|
196
|
+
raise ArgumentError, "cache: must be a .json or .graphql/.gql path, got #{cache}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
cache
|
|
18
200
|
end
|
|
19
201
|
end
|
|
202
|
+
private_class_method :cache_path
|
|
203
|
+
|
|
204
|
+
# the requested path first, then its siblings in the other formats
|
|
205
|
+
def self.cache_candidates(path)
|
|
206
|
+
base = strip_extension(path)
|
|
207
|
+
[path, *CACHE_EXTENSIONS.map { |ext| base + ext }].uniq
|
|
208
|
+
end
|
|
209
|
+
private_class_method :cache_candidates
|
|
210
|
+
|
|
211
|
+
def self.strip_extension(path)
|
|
212
|
+
path.delete_suffix(File.extname(path))
|
|
213
|
+
end
|
|
214
|
+
private_class_method :strip_extension
|
|
215
|
+
|
|
216
|
+
def self.fresh?(path, ttl)
|
|
217
|
+
File.exist?(path) && (ttl.nil? || Time.now - File.mtime(path) < ttl)
|
|
218
|
+
end
|
|
219
|
+
private_class_method :fresh?
|
|
20
220
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "graphql"
|
|
5
|
+
|
|
6
|
+
module GraphWeaver
|
|
7
|
+
# Shared query-selection walking — the rules Codegen, FakeClient, and
|
|
8
|
+
# the cassette Anonymizer all follow, in one place so they can't drift:
|
|
9
|
+
# how fragments flatten into selections, and when a type condition
|
|
10
|
+
# applies. Hosts set @schema and call load_operation before walking.
|
|
11
|
+
module Selection
|
|
12
|
+
include Kernel # for sorbet: hosts are Objects
|
|
13
|
+
|
|
14
|
+
# Parse a query, stash its fragment definitions for the walk, and
|
|
15
|
+
# return the operation.
|
|
16
|
+
def load_operation(query)
|
|
17
|
+
doc = GraphQL.parse(query)
|
|
18
|
+
@fragments = doc.definitions
|
|
19
|
+
.grep(GraphQL::Language::Nodes::FragmentDefinition)
|
|
20
|
+
.to_h { |fragment| [fragment.name, fragment] }
|
|
21
|
+
|
|
22
|
+
doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).first
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The schema type an operation's selections start from.
|
|
26
|
+
def operation_root_type(operation)
|
|
27
|
+
case operation&.operation_type
|
|
28
|
+
when "query", nil then @schema.query
|
|
29
|
+
when "mutation" then @schema.mutation
|
|
30
|
+
else raise GraphWeaver::Error, "unsupported operation: #{operation.operation_type}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Flatten a selection set as seen by `type`, yielding (result_key,
|
|
35
|
+
# field_node) per field: plain fields yield directly; inline fragments
|
|
36
|
+
# and named spreads recurse when their type condition applies.
|
|
37
|
+
def each_field(type, selections, &block)
|
|
38
|
+
selections.each do |selection|
|
|
39
|
+
case selection
|
|
40
|
+
when GraphQL::Language::Nodes::Field
|
|
41
|
+
yield(selection.alias || selection.name, selection)
|
|
42
|
+
when GraphQL::Language::Nodes::InlineFragment
|
|
43
|
+
each_field(type, selection.selections, &block) if applies?(selection.type&.name, type)
|
|
44
|
+
when GraphQL::Language::Nodes::FragmentSpread
|
|
45
|
+
fragment = @fragments.fetch(selection.name) do
|
|
46
|
+
raise ArgumentError, "unknown fragment: #{selection.name}"
|
|
47
|
+
end
|
|
48
|
+
each_field(type, fragment.selections, &block) if applies?(fragment.type.name, type)
|
|
49
|
+
else
|
|
50
|
+
raise GraphWeaver::Error, "unsupported selection: #{selection.class}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A fragment's type condition applies when it names this type exactly,
|
|
56
|
+
# or an interface/union this type belongs to (`... on Named { ... }`).
|
|
57
|
+
def applies?(condition, type)
|
|
58
|
+
return true if condition.nil? || condition == type.graphql_name
|
|
59
|
+
|
|
60
|
+
condition_type = @schema.get_type(condition)
|
|
61
|
+
return false unless condition_type
|
|
62
|
+
|
|
63
|
+
kind = condition_type.kind.name
|
|
64
|
+
(kind == "INTERFACE" || kind == "UNION") &&
|
|
65
|
+
@schema.possible_types(condition_type).include?(type)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# typed: ignore — Rake DSL at top level, loaded from Rakefiles
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Rake tasks — add to your Rakefile:
|
|
5
|
+
#
|
|
6
|
+
# require "graph_weaver/tasks"
|
|
7
|
+
#
|
|
8
|
+
# The tasks use the conventional paths (GraphWeaver.queries_path /
|
|
9
|
+
# generated_path / schema_path — override in your Rakefile or an
|
|
10
|
+
# initializer). Register custom scalars before the tasks run — they're
|
|
11
|
+
# baked into generated source.
|
|
12
|
+
#
|
|
13
|
+
# rake graph_weaver:generate # queries_path -> generated_path
|
|
14
|
+
# rake graph_weaver:verify # fail if generated files are stale (CI)
|
|
15
|
+
require_relative "../graph_weaver"
|
|
16
|
+
|
|
17
|
+
# in Rails, run app config first (initializers register scalars/enums/
|
|
18
|
+
# helpers, and they're baked into generated source)
|
|
19
|
+
GRAPH_WEAVER_DEPS = Rake::Task.task_defined?("environment") ? ["environment"] : []
|
|
20
|
+
|
|
21
|
+
namespace :graph_weaver do
|
|
22
|
+
desc "Generate typed query modules (#{GraphWeaver.queries_path} -> #{GraphWeaver.generated_path})"
|
|
23
|
+
task generate: GRAPH_WEAVER_DEPS do
|
|
24
|
+
# schema auto-located at GraphWeaver.schema_path, any supported extension
|
|
25
|
+
GraphWeaver.generate!.each { |path| puts "wrote #{path}" }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
desc "Verify generated query modules are up to date"
|
|
29
|
+
task verify: GRAPH_WEAVER_DEPS do
|
|
30
|
+
GraphWeaver.verify_generated!
|
|
31
|
+
puts "generated queries up to date"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
namespace :schema do
|
|
35
|
+
# both tasks re-introspect from the url recorded in the dump
|
|
36
|
+
# (GRAPHWEAVER_AUTH supplies a token for private APIs)
|
|
37
|
+
|
|
38
|
+
desc "Fail when the server's schema has drifted from the local dump"
|
|
39
|
+
task :verify do
|
|
40
|
+
path = GraphWeaver::SchemaLoader.locate_path or abort "no schema dump at #{GraphWeaver.schema_path}"
|
|
41
|
+
if GraphWeaver::SchemaLoader.stale?(path)
|
|
42
|
+
abort "#{path} is stale — the server's schema has drifted (rake graph_weaver:schema:refresh)"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
puts "#{path} matches the server"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
desc "Re-introspect the recorded url and rewrite the local dump"
|
|
49
|
+
task :refresh do
|
|
50
|
+
path = GraphWeaver::SchemaLoader.locate_path or abort "no schema dump at #{GraphWeaver.schema_path}"
|
|
51
|
+
meta = GraphWeaver::SchemaLoader.provenance(path) or abort "#{path} records no source url"
|
|
52
|
+
|
|
53
|
+
transport = GraphWeaver.new(meta["url"], auth: ENV["GRAPHWEAVER_AUTH"]).transport
|
|
54
|
+
GraphWeaver::SchemaLoader.introspect(transport, cache: path, ttl: 0)
|
|
55
|
+
puts "refreshed #{path} from #{meta["url"]}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
namespace :cassettes do
|
|
60
|
+
desc "Anonymize every cassette in Testing.config.cassette_dir (PII-safe to commit)"
|
|
61
|
+
task :anonymize do
|
|
62
|
+
require "graph_weaver/testing"
|
|
63
|
+
|
|
64
|
+
schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
|
|
65
|
+
Dir[File.join(GraphWeaver::Testing.config.cassette_dir, "*.yml")].sort.each do |path|
|
|
66
|
+
GraphWeaver::Testing::Cassette.new(path).anonymize!(schema:)
|
|
67
|
+
puts "anonymized #{path}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "graphql"
|
|
6
|
+
require "yaml"
|
|
7
|
+
|
|
8
|
+
module GraphWeaver
|
|
9
|
+
module Testing
|
|
10
|
+
# Raised by Replayer when a request has no recording.
|
|
11
|
+
class MissingRecording < GraphWeaver::Error
|
|
12
|
+
def initialize(path:, query:)
|
|
13
|
+
super(<<~MSG.strip)
|
|
14
|
+
no recording for this request in #{path} — re-record it
|
|
15
|
+
(Recorder / Cassette.use with a live client, or delete
|
|
16
|
+
the cassette to start over). Query:
|
|
17
|
+
#{query.strip[0, 200]}
|
|
18
|
+
MSG
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Capture/replay above the transport (no HTTP interception): a
|
|
23
|
+
# cassette is a YAML file of {query, variables, response} entries,
|
|
24
|
+
# keyed on the normalized query + variables.
|
|
25
|
+
#
|
|
26
|
+
# # record against a real client, replay when the file exists:
|
|
27
|
+
# client = GraphWeaver::Testing::Cassette.use("github", client: real)
|
|
28
|
+
#
|
|
29
|
+
# Cassettes hold real responses — anonymize before committing:
|
|
30
|
+
#
|
|
31
|
+
# Cassette.new("spec/cassettes/github.yml").anonymize!(schema:)
|
|
32
|
+
#
|
|
33
|
+
# keeps every shape (list lengths, null positions, enums, __typename,
|
|
34
|
+
# id relationships via a consistent mapping) while replacing values
|
|
35
|
+
# with fakes, semantically matched where field names allow.
|
|
36
|
+
class Cassette
|
|
37
|
+
attr_reader :path
|
|
38
|
+
|
|
39
|
+
def initialize(path)
|
|
40
|
+
@path = Testing.cassette_path(path)
|
|
41
|
+
@entries = File.exist?(@path) ? YAML.safe_load_file(@path, aliases: true) : []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def exist? = File.exist?(@path)
|
|
45
|
+
def size = @entries.size
|
|
46
|
+
|
|
47
|
+
# Replay when recorded, record when not (VCR's once mode).
|
|
48
|
+
# client: is required to record; omit it to replay-or-raise.
|
|
49
|
+
# With Testing.config.record on (or GRAPHWEAVER_RECORD=1), always
|
|
50
|
+
# records — the "just re-record everything" switch.
|
|
51
|
+
def self.use(path, client: nil)
|
|
52
|
+
cassette = new(path)
|
|
53
|
+
if Testing.config.record && client
|
|
54
|
+
Recorder.new(client, cassette)
|
|
55
|
+
elsif cassette.exist?
|
|
56
|
+
Replayer.new(cassette)
|
|
57
|
+
elsif client
|
|
58
|
+
Recorder.new(client, cassette)
|
|
59
|
+
else
|
|
60
|
+
raise MissingRecording.new(path: cassette.path, query: "(no client to record with)")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def lookup(query, variables)
|
|
65
|
+
wanted = self.class.key(query, variables)
|
|
66
|
+
@entries.find { |entry| entry["key"] == wanted }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def record(query, variables, response)
|
|
70
|
+
entry = {
|
|
71
|
+
"key" => self.class.key(query, variables),
|
|
72
|
+
"query" => query,
|
|
73
|
+
"variables" => variables,
|
|
74
|
+
"response" => response,
|
|
75
|
+
}
|
|
76
|
+
@entries.reject! { |existing| existing["key"] == entry["key"] }
|
|
77
|
+
@entries << entry
|
|
78
|
+
save
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Replace recorded response values with fakes, preserving structure.
|
|
82
|
+
# Walks each entry's query against the schema (like FakeClient,
|
|
83
|
+
# but transforming what's there instead of generating from scratch).
|
|
84
|
+
def anonymize!(schema:, seed: nil, mode: nil)
|
|
85
|
+
anonymizer = Anonymizer.new(schema:, seed:, mode:)
|
|
86
|
+
@entries.each do |entry|
|
|
87
|
+
data = entry.dig("response", "data")
|
|
88
|
+
entry["response"]["data"] = anonymizer.anonymize(entry["query"], data) if data
|
|
89
|
+
end
|
|
90
|
+
save
|
|
91
|
+
self
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.key(query, variables)
|
|
95
|
+
{ "query" => query.gsub(/\s+/, " ").strip, "variables" => variables || {} }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def save
|
|
101
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
102
|
+
File.write(@path, YAML.dump(@entries))
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Tees requests through a live client and records every response.
|
|
107
|
+
# With Testing.config.anonymize (or anonymize: true), responses are
|
|
108
|
+
# anonymized as they're recorded — and the anonymized version is what
|
|
109
|
+
# the caller sees too, so assertions written now hold on replay.
|
|
110
|
+
class Recorder
|
|
111
|
+
def initialize(client, cassette, anonymize: nil)
|
|
112
|
+
@client = client
|
|
113
|
+
@cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
|
|
114
|
+
|
|
115
|
+
config = Testing.config
|
|
116
|
+
if anonymize.nil? ? config.anonymize : anonymize
|
|
117
|
+
unless config.schema
|
|
118
|
+
raise ArgumentError, "anonymizing recordings needs GraphWeaver::Testing.config.schema"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
@anonymizer = Anonymizer.new(schema: config.schema, seed: config.seed)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def execute(query, variables: {})
|
|
126
|
+
response = @client.execute(query, variables:).to_h
|
|
127
|
+
if @anonymizer && (data = response["data"])
|
|
128
|
+
response = response.merge("data" => @anonymizer.anonymize(query, data))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
@cassette.record(query, variables, response)
|
|
132
|
+
response
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Serves recorded responses; raises MissingRecording on unknown
|
|
137
|
+
# requests rather than silently faking.
|
|
138
|
+
class Replayer
|
|
139
|
+
def initialize(cassette)
|
|
140
|
+
@cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def execute(query, variables: {})
|
|
144
|
+
entry = @cassette.lookup(query, variables)
|
|
145
|
+
raise MissingRecording.new(path: @cassette.path, query:) unless entry
|
|
146
|
+
|
|
147
|
+
entry["response"]
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Rewrites a recorded response through the Values engine: same shape,
|
|
152
|
+
# fake values. Enums, booleans, __typename, and null positions are
|
|
153
|
+
# preserved; ids map consistently so relationships survive.
|
|
154
|
+
class Anonymizer
|
|
155
|
+
include GraphWeaver::Selection
|
|
156
|
+
|
|
157
|
+
def initialize(schema:, seed: nil, mode: nil)
|
|
158
|
+
@schema = schema
|
|
159
|
+
@values = Values.new(seed:, mode:)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def anonymize(query, data)
|
|
163
|
+
operation = load_operation(query)
|
|
164
|
+
|
|
165
|
+
object_value(operation_root_type(operation), operation.selections, data)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def object_value(type, selections, data)
|
|
171
|
+
return data if data.nil?
|
|
172
|
+
|
|
173
|
+
# abstract types anonymize as the member the response says it was
|
|
174
|
+
if (typename = data["__typename"]) && type.graphql_name != typename
|
|
175
|
+
type = @schema.get_type(typename) || type
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
result = {}
|
|
179
|
+
each_field(type, selections) do |key, node|
|
|
180
|
+
next unless data.key?(key)
|
|
181
|
+
|
|
182
|
+
result[key] = if node.name == "__typename"
|
|
183
|
+
data[key]
|
|
184
|
+
else
|
|
185
|
+
field_value(type, node, data[key])
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
result
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def field_value(parent_type, node, value)
|
|
193
|
+
type_value(@schema.get_field(parent_type.graphql_name, node.name).type, node, value)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def type_value(type, node, value)
|
|
197
|
+
return if value.nil? # preserve null positions
|
|
198
|
+
|
|
199
|
+
case type.kind.name
|
|
200
|
+
when "NON_NULL"
|
|
201
|
+
type_value(type.of_type, node, value)
|
|
202
|
+
when "LIST"
|
|
203
|
+
value.map { |element| type_value(type.of_type, node, element) }
|
|
204
|
+
when "SCALAR"
|
|
205
|
+
scalar_value(type.graphql_name, node.name, value)
|
|
206
|
+
when "ENUM"
|
|
207
|
+
value # enums aren't PII; preserving them keeps semantics
|
|
208
|
+
when "OBJECT", "UNION", "INTERFACE"
|
|
209
|
+
object_value(type, node.selections, value)
|
|
210
|
+
else
|
|
211
|
+
value
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def scalar_value(type_name, field_name, value)
|
|
216
|
+
case type_name
|
|
217
|
+
when "ID" then @values.mapped_id(value)
|
|
218
|
+
when "Boolean" then value # not PII; preserves branching behavior
|
|
219
|
+
else @values.scalar(type_name, field_name)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|