graph_weaver 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +537 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +29 -4
- data/docs/editors.md +3 -1
- data/docs/errors.md +75 -16
- data/docs/federation.md +206 -155
- data/docs/generated_modules.md +223 -166
- data/docs/getting_started.md +106 -82
- data/docs/logging.md +35 -5
- data/docs/scalars.md +119 -24
- data/docs/testing.md +196 -155
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +243 -22
- data/graph_weaver.gemspec +16 -2
- data/lib/generators/graph_weaver/install_generator.rb +31 -16
- data/lib/graph_weaver/client.rb +52 -15
- data/lib/graph_weaver/codegen/aliases.rb +15 -8
- data/lib/graph_weaver/codegen/emit.rb +107 -42
- data/lib/graph_weaver/codegen/enum_type.rb +4 -3
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
- data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
- data/lib/graph_weaver/codegen.rb +382 -105
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +57 -13
- data/lib/graph_weaver/federation.rb +10 -22
- data/lib/graph_weaver/hints.rb +76 -2
- data/lib/graph_weaver/in_process.rb +11 -8
- data/lib/graph_weaver/inflect.rb +2 -0
- data/lib/graph_weaver/input_struct.rb +115 -12
- data/lib/graph_weaver/internal/overrides.rb +101 -0
- data/lib/graph_weaver/internal/planner.rb +868 -0
- data/lib/graph_weaver/internal/schemas.rb +50 -0
- data/lib/graph_weaver/internal/selection.rb +127 -0
- data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
- data/lib/graph_weaver/internal/values.rb +181 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +6 -13
- data/lib/graph_weaver/query_module.rb +2 -0
- data/lib/graph_weaver/railtie.rb +113 -14
- data/lib/graph_weaver/representation.rb +30 -2
- data/lib/graph_weaver/response.rb +15 -0
- data/lib/graph_weaver/retry.rb +54 -22
- data/lib/graph_weaver/rspec.rb +63 -18
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +126 -35
- data/lib/graph_weaver/tasks.rb +88 -36
- data/lib/graph_weaver/testing/cassette.rb +131 -78
- data/lib/graph_weaver/testing/coverage.rb +11 -15
- data/lib/graph_weaver/testing/failure.rb +14 -8
- data/lib/graph_weaver/testing/fake_client.rb +253 -60
- data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
- data/lib/graph_weaver/testing/router.rb +147 -840
- data/lib/graph_weaver/testing.rb +40 -83
- data/lib/graph_weaver/transport/faraday.rb +1 -1
- data/lib/graph_weaver/transport/http.rb +29 -12
- data/lib/graph_weaver/transport.rb +11 -34
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +221 -118
- metadata +17 -13
- data/CLAUDE.md +0 -161
- data/DECISIONS.md +0 -309
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -115
- data/REVIEW.md +0 -946
- data/lib/graph_weaver/schemas.rb +0 -46
- data/lib/graph_weaver/selection.rb +0 -120
- data/lib/graph_weaver/testing/values.rb +0 -98
|
@@ -14,13 +14,14 @@ module GraphWeaver
|
|
|
14
14
|
class MissingRecording < GraphWeaver::Error
|
|
15
15
|
# how many recorded variable sets to print before summarizing
|
|
16
16
|
SHOWN = 5
|
|
17
|
+
private_constant :SHOWN
|
|
17
18
|
|
|
18
19
|
def initialize(path:, query:, variables:, recorded:, size:)
|
|
19
20
|
super([
|
|
20
|
-
"no recording for this request in #{path}",
|
|
21
|
-
" variables: #{
|
|
21
|
+
"no recording for this request in #{GraphWeaver::Internal::Util.relative(path)}",
|
|
22
|
+
" variables: #{GraphWeaver::Internal::Log.filter_variables(Internal::RequestKey.normalize_variables(variables)).inspect}",
|
|
22
23
|
" #{self.class.recorded_summary(recorded, size)}",
|
|
23
|
-
" query: #{
|
|
24
|
+
" query: #{Internal::RequestKey.summarize(query)}",
|
|
24
25
|
"re-record it (GRAPHWEAVER_RECORD=1 with a client:), or delete the cassette to start over.",
|
|
25
26
|
].join("\n"))
|
|
26
27
|
end
|
|
@@ -29,8 +30,9 @@ module GraphWeaver
|
|
|
29
30
|
return "no entry recorded for this query (#{size} in the cassette)" if recorded.empty?
|
|
30
31
|
|
|
31
32
|
more = recorded.size > SHOWN ? " (+#{recorded.size - SHOWN} more)" : ""
|
|
33
|
+
shown = recorded.first(SHOWN).map { |set| GraphWeaver::Internal::Log.filter_variables(set).inspect }
|
|
32
34
|
"#{recorded.size} #{(recorded.size == 1) ? "entry" : "entries"} recorded for this query, " \
|
|
33
|
-
"with variables #{
|
|
35
|
+
"with variables #{shown.join(", ")}#{more}"
|
|
34
36
|
end
|
|
35
37
|
end
|
|
36
38
|
|
|
@@ -51,15 +53,17 @@ module GraphWeaver
|
|
|
51
53
|
elsif config.record
|
|
52
54
|
# record mode without a client would quietly serve the stale
|
|
53
55
|
# 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
|
|
56
|
+
raise GraphWeaver::Error, "record mode is on but no `client:` was given for " \
|
|
57
|
+
"#{GraphWeaver::Internal::Util.relative(file.path)} " \
|
|
55
58
|
"— pass a live `client:` to re-record it, or turn record mode off " \
|
|
56
59
|
"(GRAPHWEAVER_RECORD / Testing.config.record)."
|
|
57
60
|
elsif file.exist?
|
|
58
61
|
Replayer.new(file)
|
|
59
62
|
else
|
|
60
63
|
# a first run, not a missing recording: there is no request yet
|
|
61
|
-
raise GraphWeaver::Error, "#{file.path} doesn't exist
|
|
62
|
-
"record with — pass `client:` on the first run,
|
|
64
|
+
raise GraphWeaver::Error, "#{GraphWeaver::Internal::Util.relative(file.path)} doesn't exist " \
|
|
65
|
+
"and no `client:` was given to record with — pass `client:` on the first run, " \
|
|
66
|
+
"or commit the cassette."
|
|
63
67
|
end
|
|
64
68
|
end
|
|
65
69
|
end
|
|
@@ -72,14 +76,14 @@ module GraphWeaver
|
|
|
72
76
|
# found. `checked` is how many recordings a module claimed: a run that
|
|
73
77
|
# claimed none proved nothing, which is a different answer from "all
|
|
74
78
|
# good" — the same distinction `federation:diff` draws.
|
|
75
|
-
Check =
|
|
79
|
+
Check = Data.define(:path, :checked, :skipped, :stale) do
|
|
76
80
|
def ok? = stale.empty?
|
|
77
81
|
|
|
78
82
|
def report
|
|
79
83
|
counted = ["#{checked} checked"]
|
|
80
84
|
counted << "#{skipped} not sent by any query module" if skipped.positive?
|
|
81
85
|
|
|
82
|
-
["#{path}: #{stale.size} stale (#{counted.join(", ")})"] +
|
|
86
|
+
["#{GraphWeaver::Internal::Util.relative(path)}: #{stale.size} stale (#{counted.join(", ")})"] +
|
|
83
87
|
stale.flat_map do |entry|
|
|
84
88
|
[" #{entry.module_name} #{entry.variables.inspect}", " #{entry.message}"]
|
|
85
89
|
end
|
|
@@ -87,42 +91,66 @@ module GraphWeaver
|
|
|
87
91
|
end
|
|
88
92
|
|
|
89
93
|
# One recording the generated structs can no longer read.
|
|
90
|
-
Stale =
|
|
94
|
+
Stale = Data.define(:module_name, :variables, :message)
|
|
95
|
+
|
|
96
|
+
# Shapes that are a credential whatever the field around them is
|
|
97
|
+
# called. Anonymization can't cover everything a cassette holds — the
|
|
98
|
+
# variables ARE the replay key, so they're written verbatim — so the
|
|
99
|
+
# bytes that reach disk get one look before anyone commits them.
|
|
100
|
+
# Deliberately narrow: a false alarm costs a glance, while a password
|
|
101
|
+
# like "hunter2" has no shape at all, so a quiet run is not a clean
|
|
102
|
+
# bill of health.
|
|
103
|
+
CREDENTIAL_SHAPES = {
|
|
104
|
+
"a JWT" => /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\./,
|
|
105
|
+
"an AWS access key" => /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,
|
|
106
|
+
"a GitHub token" => /\b(?:gh[opusr]|github_pat)_[A-Za-z0-9_]{20,}/,
|
|
107
|
+
"a Slack token" => /\bxox[baprs]-[A-Za-z0-9-]{10,}/,
|
|
108
|
+
"a Stripe key" => /\bsk_(?:live|test)_[A-Za-z0-9]{10,}/,
|
|
109
|
+
"a private key" => /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
110
|
+
"an Authorization header" => /\bBearer\s+\S{16,}/,
|
|
111
|
+
}.freeze
|
|
91
112
|
|
|
92
113
|
attr_reader :path
|
|
93
114
|
|
|
94
115
|
def initialize(path)
|
|
95
116
|
@path = Testing.cassette_path(path)
|
|
96
117
|
@entries = File.exist?(@path) ? YAML.safe_load_file(@path, aliases: true) : []
|
|
118
|
+
@flagged = []
|
|
119
|
+
# record is read-modify-write; two threads recording through one
|
|
120
|
+
# cassette (a parallel spec run) would each save a snapshot missing
|
|
121
|
+
# the other's entry — atomic_write keeps the file whole, not complete
|
|
122
|
+
@lock = Mutex.new
|
|
97
123
|
end
|
|
98
124
|
|
|
99
125
|
def exist? = File.exist?(@path)
|
|
100
126
|
def size = @entries.size
|
|
101
127
|
|
|
102
128
|
def lookup(query, variables, operation_name = nil)
|
|
103
|
-
wanted =
|
|
104
|
-
@entries.find { |entry|
|
|
129
|
+
wanted = Internal::RequestKey.for(query, variables, operation_name)
|
|
130
|
+
@entries.find { |entry| Internal::RequestKey.for_entry(entry) == wanted }
|
|
105
131
|
end
|
|
106
132
|
|
|
107
133
|
# every variables hash recorded for this query — what a miss needs
|
|
108
134
|
# to show, since the variables are what usually differ
|
|
109
135
|
def variants(query, operation_name = nil)
|
|
110
|
-
normalized =
|
|
136
|
+
normalized = Internal::RequestKey.normalize_query(query)
|
|
111
137
|
@entries.select do |entry|
|
|
112
|
-
|
|
138
|
+
Internal::RequestKey.normalize_query(entry["query"]) == normalized && entry["operationName"] == operation_name
|
|
113
139
|
end.map { |entry| entry["variables"] || {} }
|
|
114
140
|
end
|
|
115
141
|
|
|
116
142
|
def record(query, variables, response, operation_name = nil)
|
|
117
143
|
entry = { "query" => query }
|
|
118
144
|
entry["operationName"] = operation_name if operation_name
|
|
119
|
-
entry["variables"] =
|
|
145
|
+
entry["variables"] = Internal::RequestKey.normalize_variables(variables)
|
|
120
146
|
entry["response"] = response
|
|
121
147
|
|
|
122
|
-
wanted =
|
|
123
|
-
@
|
|
124
|
-
|
|
125
|
-
|
|
148
|
+
wanted = Internal::RequestKey.for(query, variables, operation_name)
|
|
149
|
+
@lock.synchronize do
|
|
150
|
+
@entries.reject! { |existing| Internal::RequestKey.for_entry(existing) == wanted }
|
|
151
|
+
@entries << entry
|
|
152
|
+
save
|
|
153
|
+
end
|
|
126
154
|
end
|
|
127
155
|
|
|
128
156
|
# Replay every recording through `modules` — the generated query
|
|
@@ -138,10 +166,10 @@ module GraphWeaver
|
|
|
138
166
|
# Matching is on the query text, which is the module that sent it — a
|
|
139
167
|
# recording no module sends is skipped rather than guessed at.
|
|
140
168
|
def check(modules)
|
|
141
|
-
index = modules.to_h { |mod| [
|
|
169
|
+
index = modules.to_h { |mod| [Internal::RequestKey.normalize_query(mod.const_get(:QUERY)), mod] }
|
|
142
170
|
checked = 0
|
|
143
171
|
stale = @entries.filter_map do |entry|
|
|
144
|
-
mod = index[
|
|
172
|
+
mod = index[Internal::RequestKey.normalize_query(entry["query"])] or next
|
|
145
173
|
checked += 1
|
|
146
174
|
|
|
147
175
|
begin
|
|
@@ -158,51 +186,37 @@ module GraphWeaver
|
|
|
158
186
|
# Replace recorded response values with fakes, preserving structure.
|
|
159
187
|
# Walks each entry's query against the schema (like FakeClient,
|
|
160
188
|
# but transforming what's there instead of generating from scratch).
|
|
161
|
-
def anonymize!(schema:, seed: nil,
|
|
162
|
-
anonymizer = Anonymizer.new(schema:, seed:,
|
|
189
|
+
def anonymize!(schema:, seed: nil, values: nil)
|
|
190
|
+
anonymizer = Anonymizer.new(schema:, seed:, values:)
|
|
163
191
|
@entries.each do |entry|
|
|
164
|
-
|
|
165
|
-
entry["response"]["data"] = anonymizer.anonymize(entry["query"], data) if data
|
|
192
|
+
entry["response"] = anonymizer.anonymize(entry["query"], entry["response"]) if entry["response"]
|
|
166
193
|
end
|
|
167
194
|
save
|
|
168
195
|
self
|
|
169
196
|
end
|
|
170
197
|
|
|
171
|
-
# The request's identity, exactly as the server sees it. operationName
|
|
172
|
-
# is part of that: it picks the operation the document runs, so two
|
|
173
|
-
# requests with identical text but different names are different
|
|
174
|
-
# requests. Derived, never stored — the file holds the request once,
|
|
175
|
-
# so a hand-edited entry can't disagree with what replay matches on.
|
|
176
|
-
def self.key(query, variables, operation_name = nil)
|
|
177
|
-
key = { "query" => normalize_query(query), "variables" => normalize_variables(variables) }
|
|
178
|
-
key["operationName"] = operation_name if operation_name
|
|
179
|
-
key
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
def self.entry_key(entry)
|
|
183
|
-
key(entry["query"], entry["variables"], entry["operationName"])
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def self.normalize_query(query) = query.gsub(/\s+/, " ").strip
|
|
187
|
-
|
|
188
|
-
# one readable line: an error naming a 60-line query is a wall, not a hint
|
|
189
|
-
def self.summarize(query, limit: 160)
|
|
190
|
-
normalized = normalize_query(query)
|
|
191
|
-
(normalized.length > limit) ? "#{normalized[0, limit]}…" : normalized
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
# JSON round-trip so symbol keys become strings — otherwise YAML.dump
|
|
195
|
-
# writes Ruby symbols the safe loader rejects on the next run, and lookup
|
|
196
|
-
# keys stay stable across processes
|
|
197
|
-
def self.normalize_variables(variables)
|
|
198
|
-
JSON.parse(JSON.generate(variables || {}))
|
|
199
|
-
end
|
|
200
|
-
|
|
201
198
|
private
|
|
202
199
|
|
|
203
200
|
def save
|
|
201
|
+
yaml = YAML.dump(@entries)
|
|
204
202
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
205
|
-
|
|
203
|
+
GraphWeaver::Internal::Util.atomic_write(@path, yaml)
|
|
204
|
+
flag_credentials(yaml)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Once per shape per cassette: a recording run saves after every
|
|
208
|
+
# request, and one line is a warning where forty is noise. On stderr
|
|
209
|
+
# rather than GraphWeaver.logger — the logger is silent by default,
|
|
210
|
+
# and this has to reach whoever is about to commit the file.
|
|
211
|
+
def flag_credentials(yaml)
|
|
212
|
+
found = CREDENTIAL_SHAPES.reject { |name, _| @flagged.include?(name) }
|
|
213
|
+
.select { |_, pattern| pattern.match?(yaml) }.keys
|
|
214
|
+
return if found.empty?
|
|
215
|
+
|
|
216
|
+
@flagged.concat(found)
|
|
217
|
+
warn "graph_weaver: #{GraphWeaver::Internal::Util.relative(@path)} contains #{found.join(", ")} — a cassette is committed as " \
|
|
218
|
+
"written, so review this one first. Testing.config.anonymize scrubs the response; the " \
|
|
219
|
+
"query and variables are the replay key and are recorded verbatim."
|
|
206
220
|
end
|
|
207
221
|
end
|
|
208
222
|
|
|
@@ -227,9 +241,7 @@ module GraphWeaver
|
|
|
227
241
|
|
|
228
242
|
def execute(query, variables: {}, operation_name: nil)
|
|
229
243
|
response = @client.execute(query, variables:, operation_name:).to_h
|
|
230
|
-
|
|
231
|
-
response = response.merge("data" => @anonymizer.anonymize(query, data))
|
|
232
|
-
end
|
|
244
|
+
response = @anonymizer.anonymize(query, response) if @anonymizer
|
|
233
245
|
|
|
234
246
|
@cassette.record(query, variables, response, operation_name)
|
|
235
247
|
response
|
|
@@ -258,20 +270,54 @@ module GraphWeaver
|
|
|
258
270
|
# fake values. Enums, booleans, __typename, and null positions are
|
|
259
271
|
# preserved; ids map consistently so relationships survive.
|
|
260
272
|
class Anonymizer
|
|
261
|
-
include GraphWeaver::Selection
|
|
273
|
+
include GraphWeaver::Internal::Selection
|
|
274
|
+
|
|
275
|
+
# Keys under `errors`/`extensions` whose value describes the request
|
|
276
|
+
# rather than carrying data: `path` and `locations` point into the
|
|
277
|
+
# document, and `code` is the errors-world enum — call sites branch on
|
|
278
|
+
# it exactly as they branch on an enum in `data`, which is preserved
|
|
279
|
+
# for the same reason.
|
|
280
|
+
VERBATIM_KEYS = %w[path locations code].freeze
|
|
262
281
|
|
|
263
|
-
def initialize(schema:, seed: nil,
|
|
282
|
+
def initialize(schema:, seed: nil, values: nil)
|
|
264
283
|
@schema = schema
|
|
265
|
-
@values = Values.new(seed:,
|
|
284
|
+
@values = Internal::Values.new(seed:, values:)
|
|
266
285
|
end
|
|
267
286
|
|
|
268
|
-
|
|
269
|
-
|
|
287
|
+
# The whole response, not just `data`: an error message routinely
|
|
288
|
+
# quotes the input that caused it, and `extensions` is whatever the
|
|
289
|
+
# server felt like attaching. One rule — `data` is walked against the
|
|
290
|
+
# schema, everything else by shape.
|
|
291
|
+
def anonymize(query, response)
|
|
292
|
+
response.to_h do |key, value|
|
|
293
|
+
[key, (key == "data") ? data_value(query, value) : untyped_value(key, value)]
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
private
|
|
270
298
|
|
|
299
|
+
def data_value(query, data)
|
|
300
|
+
return if data.nil?
|
|
301
|
+
|
|
302
|
+
operation = load_operation(query)
|
|
271
303
|
object_value(operation_root_type(operation), operation.selections, data)
|
|
272
304
|
end
|
|
273
305
|
|
|
274
|
-
|
|
306
|
+
# No schema stands behind errors or extensions, so shape is all there
|
|
307
|
+
# is to preserve: keys, nesting, list lengths, nulls and booleans
|
|
308
|
+
# survive; every string and number is replaced.
|
|
309
|
+
def untyped_value(key, value)
|
|
310
|
+
return value if VERBATIM_KEYS.include?(key)
|
|
311
|
+
|
|
312
|
+
case value
|
|
313
|
+
when Hash then value.to_h { |name, nested| [name, untyped_value(name, nested)] }
|
|
314
|
+
when Array then value.map { |element| untyped_value(key, element) }
|
|
315
|
+
when String then @values.scalar("String", key)
|
|
316
|
+
when Integer then @values.scalar("Int", key)
|
|
317
|
+
when Float then @values.scalar("Float", key)
|
|
318
|
+
else value
|
|
319
|
+
end
|
|
320
|
+
end
|
|
275
321
|
|
|
276
322
|
# Anonymization walks recorded data, not a live dispatch. When the query
|
|
277
323
|
# narrows an abstract type without selecting __typename (`named { name
|
|
@@ -295,54 +341,61 @@ module GraphWeaver
|
|
|
295
341
|
end
|
|
296
342
|
|
|
297
343
|
result = {}
|
|
298
|
-
|
|
344
|
+
# gather (not each_field) so a key selected twice — `a { x } a { y }` —
|
|
345
|
+
# keeps the MERGED shape codegen's struct expects, not last-writer-wins
|
|
346
|
+
gather(type, selections).each do |key, nodes|
|
|
299
347
|
next unless data.key?(key)
|
|
300
348
|
|
|
349
|
+
node = nodes.first
|
|
301
350
|
result[key] = if node.name == "__typename"
|
|
302
351
|
data[key]
|
|
303
352
|
else
|
|
304
|
-
field_value(type, node, data[key])
|
|
353
|
+
field_value(type, node.name, nodes.flat_map(&:selections), data[key])
|
|
305
354
|
end
|
|
306
355
|
end
|
|
307
356
|
|
|
308
357
|
result
|
|
309
358
|
end
|
|
310
359
|
|
|
311
|
-
def field_value(parent_type,
|
|
360
|
+
def field_value(parent_type, name, selections, value)
|
|
312
361
|
# a field from a `... on Member` fragment lives on the member, not the
|
|
313
362
|
# abstract type we're walking (no __typename to narrow by), so fall back
|
|
314
363
|
# to whichever possible type declares it
|
|
315
|
-
field = @schema.get_field(parent_type.graphql_name,
|
|
316
|
-
@schema.possible_types(parent_type).filter_map { |t| @schema.get_field(t.graphql_name,
|
|
317
|
-
type_value(field.type,
|
|
364
|
+
field = @schema.get_field(parent_type.graphql_name, name) ||
|
|
365
|
+
@schema.possible_types(parent_type).filter_map { |t| @schema.get_field(t.graphql_name, name) }.first
|
|
366
|
+
type_value(field.type, name, selections, value, "#{parent_type.graphql_name}.#{name}")
|
|
318
367
|
end
|
|
319
368
|
|
|
320
|
-
def type_value(type,
|
|
369
|
+
def type_value(type, name, selections, value, coordinate = nil)
|
|
321
370
|
return if value.nil? # preserve null positions
|
|
322
371
|
|
|
323
372
|
case type.kind.name
|
|
324
373
|
when "NON_NULL"
|
|
325
|
-
type_value(type.of_type,
|
|
374
|
+
type_value(type.of_type, name, selections, value, coordinate)
|
|
326
375
|
when "LIST"
|
|
327
|
-
value.map { |element| type_value(type.of_type,
|
|
376
|
+
value.map { |element| type_value(type.of_type, name, selections, element, coordinate) }
|
|
328
377
|
when "SCALAR"
|
|
329
|
-
scalar_value(type.graphql_name,
|
|
378
|
+
scalar_value(type.graphql_name, name, value, coordinate)
|
|
330
379
|
when "ENUM"
|
|
331
380
|
value # enums aren't PII; preserving them keeps semantics
|
|
332
381
|
when "OBJECT", "UNION", "INTERFACE"
|
|
333
|
-
object_value(type,
|
|
382
|
+
object_value(type, selections, value)
|
|
334
383
|
else
|
|
335
384
|
value
|
|
336
385
|
end
|
|
337
386
|
end
|
|
338
387
|
|
|
339
|
-
def scalar_value(type_name, field_name, value)
|
|
388
|
+
def scalar_value(type_name, field_name, value, coordinate = nil)
|
|
340
389
|
case type_name
|
|
341
390
|
when "ID" then @values.mapped_id(value)
|
|
342
391
|
when "Boolean" then value # not PII; preserves branching behavior
|
|
343
|
-
else @values.scalar(type_name, field_name)
|
|
392
|
+
else @values.scalar(type_name, field_name, coordinate)
|
|
344
393
|
end
|
|
345
394
|
end
|
|
346
395
|
end
|
|
396
|
+
|
|
397
|
+
# the scrubbing walk anonymize! runs — reached through a cassette,
|
|
398
|
+
# never named
|
|
399
|
+
private_constant :Anonymizer
|
|
347
400
|
end
|
|
348
401
|
end
|
|
@@ -26,7 +26,7 @@ module GraphWeaver
|
|
|
26
26
|
# plan stitches across a boundary. `absent` names the subgraphs that
|
|
27
27
|
# plan reaches which nothing in this process serves — plannable and
|
|
28
28
|
# runnable-here are different questions.
|
|
29
|
-
Result =
|
|
29
|
+
Result = Data.define(:path, :subgraph, :absent, :category, :detail) do
|
|
30
30
|
def servable? = category.nil? && absent.empty?
|
|
31
31
|
end
|
|
32
32
|
|
|
@@ -35,32 +35,26 @@ module GraphWeaver
|
|
|
35
35
|
.transform_values(&:first)
|
|
36
36
|
.merge(invalid: "doesn't validate against the supergraph")
|
|
37
37
|
.freeze
|
|
38
|
+
private_constant :LABELS
|
|
38
39
|
|
|
39
40
|
attr_reader :results
|
|
40
41
|
|
|
41
42
|
def initialize(supergraph:, queries: GraphWeaver.queries_paths, fragments: GraphWeaver.fragments_paths)
|
|
42
43
|
source = supergraph.to_s
|
|
43
44
|
table = GraphWeaver::SchemaLoader.routing_table(source)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
# incomplete, every number this would report is a guess
|
|
47
|
-
raise Unplannable.new(
|
|
48
|
-
"this supergraph uses federation constructs the local router doesn't read: " +
|
|
49
|
-
table.unsupported.join("; "),
|
|
50
|
-
category: :unsupported_federation,
|
|
51
|
-
)
|
|
52
|
-
end
|
|
45
|
+
# with the table incomplete, every number this would report is a guess
|
|
46
|
+
Unplannable.unsupported!(table)
|
|
53
47
|
|
|
54
48
|
# Planning still runs with `absent` empty — a query is plannable or
|
|
55
49
|
# not whoever is serving. Which subgraphs are *here* is asked
|
|
56
50
|
# separately, from evidence (a loaded schema defining what the table
|
|
57
51
|
# says one resolves), and never refuses: with nothing loaded the
|
|
58
52
|
# answer is simply "none", which is the SDL-alone CI run.
|
|
59
|
-
@planner =
|
|
60
|
-
@absent = table.subgraphs.reject { |name| Subgraphs.
|
|
53
|
+
@planner = Internal::Planner.new(table:, schema: GraphWeaver::SchemaLoader.load(source))
|
|
54
|
+
@absent = table.subgraphs.reject { |name| Internal::Subgraphs.served?(table, name) }
|
|
61
55
|
@local = @absent.size < table.subgraphs.size
|
|
62
56
|
@shared = GraphWeaver::Codegen.load_fragments(fragments)
|
|
63
|
-
@results = GraphWeaver.query_files(queries).map { |path| measure(path) }
|
|
57
|
+
@results = GraphWeaver::Internal::Util.query_files(queries).map { |path| measure(path) }
|
|
64
58
|
end
|
|
65
59
|
|
|
66
60
|
def plannable = @results.count { |result| result.category.nil? }
|
|
@@ -144,8 +138,10 @@ module GraphWeaver
|
|
|
144
138
|
# the directory every query came from, so the report's file column is
|
|
145
139
|
# filenames rather than the same path repeated
|
|
146
140
|
def shared_dir
|
|
147
|
-
|
|
148
|
-
|
|
141
|
+
@shared_dir ||= begin
|
|
142
|
+
dirs = @results.map { |result| File.dirname(result.path) }.uniq
|
|
143
|
+
dirs.one? ? "#{dirs.first}/" : ""
|
|
144
|
+
end
|
|
149
145
|
end
|
|
150
146
|
|
|
151
147
|
def measure(path)
|
|
@@ -22,19 +22,23 @@ module GraphWeaver
|
|
|
22
22
|
include Kernel # for sorbet
|
|
23
23
|
module_function
|
|
24
24
|
|
|
25
|
-
# the request never reaches the server — cause preserved,
|
|
26
|
-
# bundled transports
|
|
25
|
+
# the request never reaches the server — cause preserved, and the
|
|
26
|
+
# message shaped as the bundled transports shape it
|
|
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
|
-
raise GraphWeaver::TransportError, e.message
|
|
31
|
+
raise GraphWeaver::TransportError, "#{e.class}: #{e.message}"
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
# The server answered non-2xx. headers: is where the answer to "wait,
|
|
36
|
+
# then" lives — ServerError#retry_after and #throttled? read it, so a
|
|
37
|
+
# backoff is only exercised by a failure that carries one:
|
|
38
|
+
#
|
|
39
|
+
# Failure.server(status: 429, headers: { "retry-after" => "2" })
|
|
40
|
+
def server(status: 500, body: "simulated server error", headers: {})
|
|
41
|
+
FailureClient.new { raise GraphWeaver::ServerError.new(status:, body:, headers:) }
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
# top-level GraphQL errors: strings, or hashes with message/path/
|
|
@@ -51,8 +55,10 @@ module GraphWeaver
|
|
|
51
55
|
end
|
|
52
56
|
|
|
53
57
|
def throttled
|
|
54
|
-
#
|
|
55
|
-
|
|
58
|
+
# a code from the list #throttled? recognizes, not one spelled here —
|
|
59
|
+
# a fake that doesn't trip the predicate it exists to exercise is worse
|
|
60
|
+
# than no fake. (array-wrapped so the hash can't parse as kwargs)
|
|
61
|
+
graphql([{ message: "rate limited", extensions: { code: GraphWeaver::GraphQLError::THROTTLE_CODES.first } }])
|
|
56
62
|
end
|
|
57
63
|
|
|
58
64
|
# A validation-shaped rejection — trips schema_stale? and its
|