graph_weaver 0.4.6 → 0.5.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/CHANGELOG.md +1442 -0
- data/Gemfile.lock +23 -23
- data/README.md +115 -96
- data/docs/cassettes.md +93 -46
- data/docs/editors.md +82 -0
- data/docs/errors.md +34 -30
- data/docs/federation.md +521 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -154
- data/docs/testing.md +300 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +134 -0
- data/graph_weaver.gemspec +19 -3
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +118 -111
- data/lib/graph_weaver/codegen/aliases.rb +223 -0
- data/lib/graph_weaver/codegen/emit.rb +283 -261
- data/lib/graph_weaver/codegen/enum_type.rb +25 -124
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +69 -66
- data/lib/graph_weaver/codegen/type_helpers.rb +140 -0
- data/lib/graph_weaver/codegen.rb +672 -336
- data/lib/graph_weaver/errors.rb +154 -16
- data/lib/graph_weaver/federation.rb +259 -0
- data/lib/graph_weaver/hints.rb +9 -1
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +14 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +59 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +7 -0
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +220 -16
- data/lib/graph_weaver/schema_loader.rb +819 -60
- data/lib/graph_weaver/schemas.rb +48 -0
- data/lib/graph_weaver/selection.rb +43 -8
- data/lib/graph_weaver/tasks.rb +220 -22
- data/lib/graph_weaver/testing/cassette.rb +249 -81
- data/lib/graph_weaver/testing/coverage.rb +160 -0
- data/lib/graph_weaver/testing/failure.rb +14 -25
- data/lib/graph_weaver/testing/fake_client.rb +182 -22
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1452 -0
- data/lib/graph_weaver/testing/subgraphs.rb +134 -0
- data/lib/graph_weaver/testing.rb +209 -13
- data/lib/graph_weaver/transport/faraday.rb +28 -10
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +67 -14
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +416 -172
- metadata +25 -9
- data/CLAUDE.md +0 -69
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -144
|
@@ -7,92 +7,205 @@ require "yaml"
|
|
|
7
7
|
|
|
8
8
|
module GraphWeaver
|
|
9
9
|
module Testing
|
|
10
|
-
# Raised by Replayer when a request has no recording.
|
|
10
|
+
# Raised by Replayer when a request has no recording. The query
|
|
11
|
+
# usually matches and the variables don't, so the variables lead and
|
|
12
|
+
# the recorded ones for the same query come next — the diff you'd
|
|
13
|
+
# otherwise do by eye against the YAML.
|
|
11
14
|
class MissingRecording < GraphWeaver::Error
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
#{
|
|
18
|
-
|
|
15
|
+
# how many recorded variable sets to print before summarizing
|
|
16
|
+
SHOWN = 5
|
|
17
|
+
|
|
18
|
+
def initialize(path:, query:, variables:, recorded:, size:)
|
|
19
|
+
super([
|
|
20
|
+
"no recording for this request in #{path}",
|
|
21
|
+
" variables: #{Cassette.normalize_variables(variables).inspect}",
|
|
22
|
+
" #{self.class.recorded_summary(recorded, size)}",
|
|
23
|
+
" query: #{Cassette.summarize(query)}",
|
|
24
|
+
"re-record it (GRAPHWEAVER_RECORD=1 with a client:), or delete the cassette to start over.",
|
|
25
|
+
].join("\n"))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.recorded_summary(recorded, size)
|
|
29
|
+
return "no entry recorded for this query (#{size} in the cassette)" if recorded.empty?
|
|
30
|
+
|
|
31
|
+
more = recorded.size > SHOWN ? " (+#{recorded.size - SHOWN} more)" : ""
|
|
32
|
+
"#{recorded.size} #{(recorded.size == 1) ? "entry" : "entries"} recorded for this query, " \
|
|
33
|
+
"with variables #{recorded.first(SHOWN).map(&:inspect).join(", ")}#{more}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# The cassette-backed client: replays spec/cassettes/<name>.yml when
|
|
39
|
+
# it exists, records it through client: when it doesn't (VCR's once
|
|
40
|
+
# mode). Record mode (GRAPHWEAVER_RECORD=1 / config.record) always
|
|
41
|
+
# records, so it needs a client: too.
|
|
42
|
+
#
|
|
43
|
+
# client = GraphWeaver::Testing.cassette("github", client: live)
|
|
44
|
+
# result = RepoQuery.execute!(client:, owner: "dpep")
|
|
45
|
+
#
|
|
46
|
+
def cassette(name, client: nil)
|
|
47
|
+
file = Cassette.new(name)
|
|
48
|
+
|
|
49
|
+
if client && (config.record || !file.exist?)
|
|
50
|
+
Recorder.new(client, file)
|
|
51
|
+
elsif config.record
|
|
52
|
+
# record mode without a client would quietly serve the stale
|
|
53
|
+
# recording — the one thing "re-record everything" didn't ask for
|
|
54
|
+
raise GraphWeaver::Error, "record mode is on but no `client:` was given for #{file.path} " \
|
|
55
|
+
"— pass a live `client:` to re-record it, or turn record mode off " \
|
|
56
|
+
"(GRAPHWEAVER_RECORD / Testing.config.record)."
|
|
57
|
+
elsif file.exist?
|
|
58
|
+
Replayer.new(file)
|
|
59
|
+
else
|
|
60
|
+
# a first run, not a missing recording: there is no request yet
|
|
61
|
+
raise GraphWeaver::Error, "#{file.path} doesn't exist and no `client:` was given to " \
|
|
62
|
+
"record with — pass `client:` on the first run, or commit the cassette."
|
|
63
|
+
end
|
|
19
64
|
end
|
|
20
65
|
end
|
|
21
66
|
|
|
22
|
-
#
|
|
23
|
-
# cassette
|
|
24
|
-
#
|
|
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.
|
|
67
|
+
# The cassette file itself: a YAML list of {query, variables, response}
|
|
68
|
+
# entries. Testing.cassette wraps one in a record/replay client; this is
|
|
69
|
+
# the file object behind it — and what the anonymize rake task rewrites.
|
|
36
70
|
class Cassette
|
|
71
|
+
# What replaying one cassette through the current generated modules
|
|
72
|
+
# found. `checked` is how many recordings a module claimed: a run that
|
|
73
|
+
# claimed none proved nothing, which is a different answer from "all
|
|
74
|
+
# good" — the same distinction `federation:diff` draws.
|
|
75
|
+
Check = Struct.new(:path, :checked, :skipped, :stale, keyword_init: true) do
|
|
76
|
+
def ok? = stale.empty?
|
|
77
|
+
|
|
78
|
+
def report
|
|
79
|
+
counted = ["#{checked} checked"]
|
|
80
|
+
counted << "#{skipped} not sent by any query module" if skipped.positive?
|
|
81
|
+
|
|
82
|
+
["#{path}: #{stale.size} stale (#{counted.join(", ")})"] +
|
|
83
|
+
stale.flat_map do |entry|
|
|
84
|
+
[" #{entry.module_name} #{entry.variables.inspect}", " #{entry.message}"]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# One recording the generated structs can no longer read.
|
|
90
|
+
Stale = Struct.new(:module_name, :variables, :message, keyword_init: true)
|
|
91
|
+
|
|
92
|
+
# Shapes that are a credential whatever the field around them is
|
|
93
|
+
# called. Anonymization can't cover everything a cassette holds — the
|
|
94
|
+
# variables ARE the replay key, so they're written verbatim — so the
|
|
95
|
+
# bytes that reach disk get one look before anyone commits them.
|
|
96
|
+
# Deliberately narrow: a false alarm costs a glance, while a password
|
|
97
|
+
# like "hunter2" has no shape at all, so a quiet run is not a clean
|
|
98
|
+
# bill of health.
|
|
99
|
+
CREDENTIAL_SHAPES = {
|
|
100
|
+
"a JWT" => /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\./,
|
|
101
|
+
"an AWS access key" => /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,
|
|
102
|
+
"a GitHub token" => /\b(?:gh[opusr]|github_pat)_[A-Za-z0-9_]{20,}/,
|
|
103
|
+
"a Slack token" => /\bxox[baprs]-[A-Za-z0-9-]{10,}/,
|
|
104
|
+
"a Stripe key" => /\bsk_(?:live|test)_[A-Za-z0-9]{10,}/,
|
|
105
|
+
"a private key" => /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
106
|
+
"an Authorization header" => /\bBearer\s+\S{16,}/,
|
|
107
|
+
}.freeze
|
|
108
|
+
|
|
37
109
|
attr_reader :path
|
|
38
110
|
|
|
39
111
|
def initialize(path)
|
|
40
112
|
@path = Testing.cassette_path(path)
|
|
41
113
|
@entries = File.exist?(@path) ? YAML.safe_load_file(@path, aliases: true) : []
|
|
114
|
+
@flagged = []
|
|
42
115
|
end
|
|
43
116
|
|
|
44
117
|
def exist? = File.exist?(@path)
|
|
45
118
|
def size = @entries.size
|
|
46
119
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
120
|
+
def lookup(query, variables, operation_name = nil)
|
|
121
|
+
wanted = self.class.key(query, variables, operation_name)
|
|
122
|
+
@entries.find { |entry| self.class.entry_key(entry) == wanted }
|
|
62
123
|
end
|
|
63
124
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
125
|
+
# every variables hash recorded for this query — what a miss needs
|
|
126
|
+
# to show, since the variables are what usually differ
|
|
127
|
+
def variants(query, operation_name = nil)
|
|
128
|
+
normalized = self.class.normalize_query(query)
|
|
129
|
+
@entries.select do |entry|
|
|
130
|
+
self.class.normalize_query(entry["query"]) == normalized && entry["operationName"] == operation_name
|
|
131
|
+
end.map { |entry| entry["variables"] || {} }
|
|
67
132
|
end
|
|
68
133
|
|
|
69
|
-
def record(query, variables, response)
|
|
70
|
-
entry = {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
@entries.reject! { |existing| existing
|
|
134
|
+
def record(query, variables, response, operation_name = nil)
|
|
135
|
+
entry = { "query" => query }
|
|
136
|
+
entry["operationName"] = operation_name if operation_name
|
|
137
|
+
entry["variables"] = self.class.normalize_variables(variables)
|
|
138
|
+
entry["response"] = response
|
|
139
|
+
|
|
140
|
+
wanted = self.class.key(query, variables, operation_name)
|
|
141
|
+
@entries.reject! { |existing| self.class.entry_key(existing) == wanted }
|
|
77
142
|
@entries << entry
|
|
78
143
|
save
|
|
79
144
|
end
|
|
80
145
|
|
|
146
|
+
# Replay every recording through `modules` — the generated query
|
|
147
|
+
# modules — and report the ones that no longer cast.
|
|
148
|
+
#
|
|
149
|
+
# A cassette is the one artifact here recorded from a *foreign* server,
|
|
150
|
+
# and nothing else notices when that server's answers drift out of the
|
|
151
|
+
# shape the structs were generated for: `verify`, `queries:check` and
|
|
152
|
+
# `schema:diff` all ask about the local side. Without this the drift
|
|
153
|
+
# surfaces mid-spec as a `TypeError` naming a struct and a sorbet
|
|
154
|
+
# frame, with nothing pointing at the stale file.
|
|
155
|
+
#
|
|
156
|
+
# Matching is on the query text, which is the module that sent it — a
|
|
157
|
+
# recording no module sends is skipped rather than guessed at.
|
|
158
|
+
def check(modules)
|
|
159
|
+
index = modules.to_h { |mod| [self.class.normalize_query(mod.const_get(:QUERY)), mod] }
|
|
160
|
+
checked = 0
|
|
161
|
+
stale = @entries.filter_map do |entry|
|
|
162
|
+
mod = index[self.class.normalize_query(entry["query"])] or next
|
|
163
|
+
checked += 1
|
|
164
|
+
|
|
165
|
+
begin
|
|
166
|
+
mod.from_response(entry["response"])
|
|
167
|
+
nil
|
|
168
|
+
rescue GraphWeaver::Error => e
|
|
169
|
+
Stale.new(module_name: mod.name, variables: entry["variables"] || {}, message: e.message)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
Check.new(path: @path, checked:, skipped: @entries.size - checked, stale:)
|
|
174
|
+
end
|
|
175
|
+
|
|
81
176
|
# Replace recorded response values with fakes, preserving structure.
|
|
82
177
|
# Walks each entry's query against the schema (like FakeClient,
|
|
83
178
|
# but transforming what's there instead of generating from scratch).
|
|
84
179
|
def anonymize!(schema:, seed: nil, mode: nil)
|
|
85
180
|
anonymizer = Anonymizer.new(schema:, seed:, mode:)
|
|
86
181
|
@entries.each do |entry|
|
|
87
|
-
|
|
88
|
-
entry["response"]["data"] = anonymizer.anonymize(entry["query"], data) if data
|
|
182
|
+
entry["response"] = anonymizer.anonymize(entry["query"], entry["response"]) if entry["response"]
|
|
89
183
|
end
|
|
90
184
|
save
|
|
91
185
|
self
|
|
92
186
|
end
|
|
93
187
|
|
|
94
|
-
|
|
95
|
-
|
|
188
|
+
# The request's identity, exactly as the server sees it. operationName
|
|
189
|
+
# is part of that: it picks the operation the document runs, so two
|
|
190
|
+
# requests with identical text but different names are different
|
|
191
|
+
# requests. Derived, never stored — the file holds the request once,
|
|
192
|
+
# so a hand-edited entry can't disagree with what replay matches on.
|
|
193
|
+
def self.key(query, variables, operation_name = nil)
|
|
194
|
+
key = { "query" => normalize_query(query), "variables" => normalize_variables(variables) }
|
|
195
|
+
key["operationName"] = operation_name if operation_name
|
|
196
|
+
key
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def self.entry_key(entry)
|
|
200
|
+
key(entry["query"], entry["variables"], entry["operationName"])
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def self.normalize_query(query) = query.gsub(/\s+/, " ").strip
|
|
204
|
+
|
|
205
|
+
# one readable line: an error naming a 60-line query is a wall, not a hint
|
|
206
|
+
def self.summarize(query, limit: 160)
|
|
207
|
+
normalized = normalize_query(query)
|
|
208
|
+
(normalized.length > limit) ? "#{normalized[0, limit]}…" : normalized
|
|
96
209
|
end
|
|
97
210
|
|
|
98
211
|
# JSON round-trip so symbol keys become strings — otherwise YAML.dump
|
|
@@ -105,22 +218,39 @@ module GraphWeaver
|
|
|
105
218
|
private
|
|
106
219
|
|
|
107
220
|
def save
|
|
221
|
+
yaml = YAML.dump(@entries)
|
|
108
222
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
109
|
-
File.write(@path,
|
|
223
|
+
File.write(@path, yaml)
|
|
224
|
+
flag_credentials(yaml)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Once per shape per cassette: a recording run saves after every
|
|
228
|
+
# request, and one line is a warning where forty is noise. On stderr
|
|
229
|
+
# rather than GraphWeaver.logger — the logger is silent by default,
|
|
230
|
+
# and this has to reach whoever is about to commit the file.
|
|
231
|
+
def flag_credentials(yaml)
|
|
232
|
+
found = CREDENTIAL_SHAPES.reject { |name, _| @flagged.include?(name) }
|
|
233
|
+
.select { |_, pattern| pattern.match?(yaml) }.keys
|
|
234
|
+
return if found.empty?
|
|
235
|
+
|
|
236
|
+
@flagged.concat(found)
|
|
237
|
+
warn "graph_weaver: #{@path} contains #{found.join(", ")} — a cassette is committed as " \
|
|
238
|
+
"written, so review this one first. Testing.config.anonymize scrubs the response; the " \
|
|
239
|
+
"query and variables are the replay key and are recorded verbatim."
|
|
110
240
|
end
|
|
111
241
|
end
|
|
112
242
|
|
|
113
243
|
# Tees requests through a live client and records every response.
|
|
114
|
-
# With Testing.config.anonymize
|
|
115
|
-
#
|
|
116
|
-
#
|
|
244
|
+
# With Testing.config.anonymize, responses are anonymized as they're
|
|
245
|
+
# recorded — and the anonymized version is what the caller sees too,
|
|
246
|
+
# so assertions written now hold on replay.
|
|
117
247
|
class Recorder
|
|
118
|
-
def initialize(client, cassette
|
|
248
|
+
def initialize(client, cassette)
|
|
119
249
|
@client = client
|
|
120
250
|
@cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
|
|
121
251
|
|
|
122
252
|
config = Testing.config
|
|
123
|
-
if
|
|
253
|
+
if config.anonymize
|
|
124
254
|
unless config.schema
|
|
125
255
|
raise ArgumentError, "anonymizing recordings needs GraphWeaver::Testing.config.schema"
|
|
126
256
|
end
|
|
@@ -129,13 +259,11 @@ module GraphWeaver
|
|
|
129
259
|
end
|
|
130
260
|
end
|
|
131
261
|
|
|
132
|
-
def execute(query, variables: {})
|
|
133
|
-
response = @client.execute(query, variables:).to_h
|
|
134
|
-
|
|
135
|
-
response = response.merge("data" => @anonymizer.anonymize(query, data))
|
|
136
|
-
end
|
|
262
|
+
def execute(query, variables: {}, operation_name: nil)
|
|
263
|
+
response = @client.execute(query, variables:, operation_name:).to_h
|
|
264
|
+
response = @anonymizer.anonymize(query, response) if @anonymizer
|
|
137
265
|
|
|
138
|
-
@cassette.record(query, variables, response)
|
|
266
|
+
@cassette.record(query, variables, response, operation_name)
|
|
139
267
|
response
|
|
140
268
|
end
|
|
141
269
|
end
|
|
@@ -147,9 +275,12 @@ module GraphWeaver
|
|
|
147
275
|
@cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
|
|
148
276
|
end
|
|
149
277
|
|
|
150
|
-
def execute(query, variables: {})
|
|
151
|
-
entry = @cassette.lookup(query, variables)
|
|
152
|
-
|
|
278
|
+
def execute(query, variables: {}, operation_name: nil)
|
|
279
|
+
entry = @cassette.lookup(query, variables, operation_name)
|
|
280
|
+
unless entry
|
|
281
|
+
raise MissingRecording.new(path: @cassette.path, query:, variables:,
|
|
282
|
+
recorded: @cassette.variants(query, operation_name), size: @cassette.size)
|
|
283
|
+
end
|
|
153
284
|
|
|
154
285
|
entry["response"]
|
|
155
286
|
end
|
|
@@ -161,18 +292,52 @@ module GraphWeaver
|
|
|
161
292
|
class Anonymizer
|
|
162
293
|
include GraphWeaver::Selection
|
|
163
294
|
|
|
295
|
+
# Keys under `errors`/`extensions` whose value describes the request
|
|
296
|
+
# rather than carrying data: `path` and `locations` point into the
|
|
297
|
+
# document, and `code` is the errors-world enum — call sites branch on
|
|
298
|
+
# it exactly as they branch on an enum in `data`, which is preserved
|
|
299
|
+
# for the same reason.
|
|
300
|
+
VERBATIM_KEYS = %w[path locations code].freeze
|
|
301
|
+
|
|
164
302
|
def initialize(schema:, seed: nil, mode: nil)
|
|
165
303
|
@schema = schema
|
|
166
304
|
@values = Values.new(seed:, mode:)
|
|
167
305
|
end
|
|
168
306
|
|
|
169
|
-
|
|
170
|
-
|
|
307
|
+
# The whole response, not just `data`: an error message routinely
|
|
308
|
+
# quotes the input that caused it, and `extensions` is whatever the
|
|
309
|
+
# server felt like attaching. One rule — `data` is walked against the
|
|
310
|
+
# schema, everything else by shape.
|
|
311
|
+
def anonymize(query, response)
|
|
312
|
+
response.to_h do |key, value|
|
|
313
|
+
[key, (key == "data") ? data_value(query, value) : untyped_value(key, value)]
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
private
|
|
171
318
|
|
|
319
|
+
def data_value(query, data)
|
|
320
|
+
return if data.nil?
|
|
321
|
+
|
|
322
|
+
operation = load_operation(query)
|
|
172
323
|
object_value(operation_root_type(operation), operation.selections, data)
|
|
173
324
|
end
|
|
174
325
|
|
|
175
|
-
|
|
326
|
+
# No schema stands behind errors or extensions, so shape is all there
|
|
327
|
+
# is to preserve: keys, nesting, list lengths, nulls and booleans
|
|
328
|
+
# survive; every string and number is replaced.
|
|
329
|
+
def untyped_value(key, value)
|
|
330
|
+
return value if VERBATIM_KEYS.include?(key)
|
|
331
|
+
|
|
332
|
+
case value
|
|
333
|
+
when Hash then value.to_h { |name, nested| [name, untyped_value(name, nested)] }
|
|
334
|
+
when Array then value.map { |element| untyped_value(key, element) }
|
|
335
|
+
when String then @values.scalar("String", key)
|
|
336
|
+
when Integer then @values.scalar("Int", key)
|
|
337
|
+
when Float then @values.scalar("Float", key)
|
|
338
|
+
else value
|
|
339
|
+
end
|
|
340
|
+
end
|
|
176
341
|
|
|
177
342
|
# Anonymization walks recorded data, not a live dispatch. When the query
|
|
178
343
|
# narrows an abstract type without selecting __typename (`named { name
|
|
@@ -196,42 +361,45 @@ module GraphWeaver
|
|
|
196
361
|
end
|
|
197
362
|
|
|
198
363
|
result = {}
|
|
199
|
-
|
|
364
|
+
# gather (not each_field) so a key selected twice — `a { x } a { y }` —
|
|
365
|
+
# keeps the MERGED shape codegen's struct expects, not last-writer-wins
|
|
366
|
+
gather(type, selections).each do |key, nodes|
|
|
200
367
|
next unless data.key?(key)
|
|
201
368
|
|
|
369
|
+
node = nodes.first
|
|
202
370
|
result[key] = if node.name == "__typename"
|
|
203
371
|
data[key]
|
|
204
372
|
else
|
|
205
|
-
field_value(type, node, data[key])
|
|
373
|
+
field_value(type, node.name, nodes.flat_map(&:selections), data[key])
|
|
206
374
|
end
|
|
207
375
|
end
|
|
208
376
|
|
|
209
377
|
result
|
|
210
378
|
end
|
|
211
379
|
|
|
212
|
-
def field_value(parent_type,
|
|
380
|
+
def field_value(parent_type, name, selections, value)
|
|
213
381
|
# a field from a `... on Member` fragment lives on the member, not the
|
|
214
382
|
# abstract type we're walking (no __typename to narrow by), so fall back
|
|
215
383
|
# to whichever possible type declares it
|
|
216
|
-
field = @schema.get_field(parent_type.graphql_name,
|
|
217
|
-
@schema.possible_types(parent_type).filter_map { |t| @schema.get_field(t.graphql_name,
|
|
218
|
-
type_value(field.type,
|
|
384
|
+
field = @schema.get_field(parent_type.graphql_name, name) ||
|
|
385
|
+
@schema.possible_types(parent_type).filter_map { |t| @schema.get_field(t.graphql_name, name) }.first
|
|
386
|
+
type_value(field.type, name, selections, value)
|
|
219
387
|
end
|
|
220
388
|
|
|
221
|
-
def type_value(type,
|
|
389
|
+
def type_value(type, name, selections, value)
|
|
222
390
|
return if value.nil? # preserve null positions
|
|
223
391
|
|
|
224
392
|
case type.kind.name
|
|
225
393
|
when "NON_NULL"
|
|
226
|
-
type_value(type.of_type,
|
|
394
|
+
type_value(type.of_type, name, selections, value)
|
|
227
395
|
when "LIST"
|
|
228
|
-
value.map { |element| type_value(type.of_type,
|
|
396
|
+
value.map { |element| type_value(type.of_type, name, selections, element) }
|
|
229
397
|
when "SCALAR"
|
|
230
|
-
scalar_value(type.graphql_name,
|
|
398
|
+
scalar_value(type.graphql_name, name, value)
|
|
231
399
|
when "ENUM"
|
|
232
400
|
value # enums aren't PII; preserving them keeps semantics
|
|
233
401
|
when "OBJECT", "UNION", "INTERFACE"
|
|
234
|
-
object_value(type,
|
|
402
|
+
object_value(type, selections, value)
|
|
235
403
|
else
|
|
236
404
|
value
|
|
237
405
|
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "graphql"
|
|
5
|
+
|
|
6
|
+
require_relative "router"
|
|
7
|
+
|
|
8
|
+
module GraphWeaver
|
|
9
|
+
module Testing
|
|
10
|
+
# How much of a query set the local {Router} can plan, and — for the rest
|
|
11
|
+
# — exactly what stopped it:
|
|
12
|
+
#
|
|
13
|
+
# rake graph_weaver:federation:coverage SUPERGRAPH=supergraph.graphql
|
|
14
|
+
#
|
|
15
|
+
# The router refuses every query that crosses a subgraph boundary, so its
|
|
16
|
+
# worth to a suite is one number: the fraction of *your* queries it can
|
|
17
|
+
# answer. Nobody can guess that from outside — it depends on the shape of
|
|
18
|
+
# your graph and the shape of your queries — so measure it before
|
|
19
|
+
# deciding the router is (or isn't) enough.
|
|
20
|
+
#
|
|
21
|
+
# Planning needs the supergraph and nothing else, so this runs without
|
|
22
|
+
# any subgraph being loadable, in CI or on a laptop with the SDL alone.
|
|
23
|
+
class Coverage
|
|
24
|
+
# One query file's verdict: `category` nil means the router can plan
|
|
25
|
+
# it, and `subgraph` is where it runs — "accounts+reviews" when the
|
|
26
|
+
# plan stitches across a boundary. `absent` names the subgraphs that
|
|
27
|
+
# plan reaches which nothing in this process serves — plannable and
|
|
28
|
+
# runnable-here are different questions.
|
|
29
|
+
Result = Struct.new(:path, :subgraph, :absent, :category, :detail) do
|
|
30
|
+
def servable? = category.nil? && absent.empty?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# the label each verdict groups under
|
|
34
|
+
LABELS = Unplannable::CATEGORIES
|
|
35
|
+
.transform_values(&:first)
|
|
36
|
+
.merge(invalid: "doesn't validate against the supergraph")
|
|
37
|
+
.freeze
|
|
38
|
+
|
|
39
|
+
attr_reader :results
|
|
40
|
+
|
|
41
|
+
def initialize(supergraph:, queries: GraphWeaver.queries_paths, fragments: GraphWeaver.fragments_paths)
|
|
42
|
+
source = supergraph.to_s
|
|
43
|
+
table = GraphWeaver::SchemaLoader.routing_table(source)
|
|
44
|
+
# with the table incomplete, every number this would report is a guess
|
|
45
|
+
Unplannable.unsupported!(table)
|
|
46
|
+
|
|
47
|
+
# Planning still runs with `absent` empty — a query is plannable or
|
|
48
|
+
# not whoever is serving. Which subgraphs are *here* is asked
|
|
49
|
+
# separately, from evidence (a loaded schema defining what the table
|
|
50
|
+
# says one resolves), and never refuses: with nothing loaded the
|
|
51
|
+
# answer is simply "none", which is the SDL-alone CI run.
|
|
52
|
+
@planner = Router::Planner.new(table:, schema: GraphWeaver::SchemaLoader.load(source))
|
|
53
|
+
@absent = table.subgraphs.reject { |name| Subgraphs.served?(table, name) }
|
|
54
|
+
@local = @absent.size < table.subgraphs.size
|
|
55
|
+
@shared = GraphWeaver::Codegen.load_fragments(fragments)
|
|
56
|
+
@results = GraphWeaver.query_files(queries).map { |path| measure(path) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def plannable = @results.count { |result| result.category.nil? }
|
|
60
|
+
|
|
61
|
+
# plannable *and* every subgraph the plan reaches is served here
|
|
62
|
+
def servable = @results.count(&:servable?)
|
|
63
|
+
|
|
64
|
+
# plannable, but reaching a subgraph another process serves
|
|
65
|
+
def elsewhere = @results.select { |result| result.category.nil? && result.absent.any? }
|
|
66
|
+
|
|
67
|
+
def refused = @results.reject { |result| result.category.nil? }
|
|
68
|
+
|
|
69
|
+
# whole percent: this is a count of a handful of files, and a decimal
|
|
70
|
+
# place would claim precision the sample doesn't have
|
|
71
|
+
def percent = @results.empty? ? 0 : (plannable * 100.0 / @results.size).round
|
|
72
|
+
|
|
73
|
+
def report
|
|
74
|
+
return "no queries found" if @results.empty?
|
|
75
|
+
|
|
76
|
+
[headline, *breakdown, *served_here, *refusals].join("\n")
|
|
77
|
+
end
|
|
78
|
+
alias to_s report
|
|
79
|
+
|
|
80
|
+
def inspect = "#<#{self.class.name} #{plannable}/#{@results.size} plannable>"
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def headline
|
|
85
|
+
counted = "#{plannable}/#{@results.size} #{(@results.size == 1) ? "query" : "queries"} " \
|
|
86
|
+
"plannable locally (#{percent}%)"
|
|
87
|
+
@local ? "#{counted}, #{servable} servable here" : counted
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Plan-only is by design — a query is plannable whoever serves it — but
|
|
91
|
+
# the question this report exists to answer is whether wiring the router
|
|
92
|
+
# up is worth it, and a suite can only *run* what this process serves.
|
|
93
|
+
# A partly-local supergraph is the usual migration shape, so the second
|
|
94
|
+
# number has to be here rather than inferred from a refusal later.
|
|
95
|
+
def served_here
|
|
96
|
+
unless @local
|
|
97
|
+
return ["", "nothing here serves any of this supergraph's subgraphs " \
|
|
98
|
+
"(#{@absent.join(", ")}), so this counts planning only"]
|
|
99
|
+
end
|
|
100
|
+
return [] if elsewhere.empty?
|
|
101
|
+
|
|
102
|
+
example = elsewhere.first.absent.first
|
|
103
|
+
["", "plannable, but nothing here serves what they reach (#{elsewhere.size}) — name a " \
|
|
104
|
+
"schema for those subgraphs, fake them (subgraphs: { #{example.inspect} => :fake }), " \
|
|
105
|
+
"or run these against a real router:"] +
|
|
106
|
+
elsewhere.map { |result| " #{name(result)} #{result.absent.join(", ")}" }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# where the plannable ones land — a graph whose queries all sit in one
|
|
110
|
+
# subgraph is a different situation from one that's evenly spread
|
|
111
|
+
def breakdown
|
|
112
|
+
by_subgraph = @results.filter_map(&:subgraph).tally.sort_by { |name, count| [-count, name] }
|
|
113
|
+
return [] if by_subgraph.empty?
|
|
114
|
+
|
|
115
|
+
[" " + by_subgraph.map { |name, count| "#{name} #{count}" }.join(", ")]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def refusals
|
|
119
|
+
return [] if refused.empty?
|
|
120
|
+
|
|
121
|
+
groups = refused.group_by(&:category).sort_by { |category, group| [-group.size, category.to_s] }
|
|
122
|
+
|
|
123
|
+
["", "refused (#{refused.size})"] + groups.flat_map do |category, group|
|
|
124
|
+
["", " #{LABELS.fetch(category, category)} (#{group.size})"] +
|
|
125
|
+
group.map { |result| " #{name(result)} #{result.detail}" }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# the file column: filenames when every query came from one directory,
|
|
130
|
+
# padded so what sits beside them lines up
|
|
131
|
+
def name(result) = basename(result).ljust(width)
|
|
132
|
+
|
|
133
|
+
def basename(result) = result.path.delete_prefix(shared_dir)
|
|
134
|
+
|
|
135
|
+
def width = @width ||= @results.map { |result| basename(result).length }.max
|
|
136
|
+
|
|
137
|
+
# the directory every query came from, so the report's file column is
|
|
138
|
+
# filenames rather than the same path repeated
|
|
139
|
+
def shared_dir
|
|
140
|
+
@shared_dir ||= begin
|
|
141
|
+
dirs = @results.map { |result| File.dirname(result.path) }.uniq
|
|
142
|
+
dirs.one? ? "#{dirs.first}/" : ""
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def measure(path)
|
|
147
|
+
document = GraphQL.parse(GraphWeaver::Codegen.inline_fragments(File.read(path), @shared, path))
|
|
148
|
+
errors = @planner.validate(document)
|
|
149
|
+
return Result.new(path, nil, [], :invalid, errors.first["message"]) if errors.any?
|
|
150
|
+
|
|
151
|
+
plan = @planner.plan(document)
|
|
152
|
+
Result.new(path, plan.where, plan.subgraphs & @absent, nil, nil)
|
|
153
|
+
rescue Unplannable => e
|
|
154
|
+
Result.new(path, nil, [], e.category, e.detail)
|
|
155
|
+
rescue GraphQL::ParseError, GraphWeaver::Error => e
|
|
156
|
+
Result.new(path, nil, [], :invalid, e.message)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|