graph_weaver 0.5.1 → 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 +409 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +6 -1
- data/docs/editors.md +3 -1
- data/docs/errors.md +73 -16
- data/docs/federation.md +201 -151
- data/docs/generated_modules.md +222 -165
- data/docs/getting_started.md +105 -81
- data/docs/logging.md +34 -4
- data/docs/scalars.md +119 -24
- data/docs/testing.md +191 -151
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +210 -11
- data/lib/generators/graph_weaver/install_generator.rb +16 -1
- data/lib/graph_weaver/client.rb +46 -13
- data/lib/graph_weaver/codegen/aliases.rb +5 -4
- data/lib/graph_weaver/codegen/emit.rb +96 -39
- data/lib/graph_weaver/codegen/enum_type.rb +3 -0
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +82 -79
- data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
- data/lib/graph_weaver/codegen.rb +284 -84
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +30 -7
- data/lib/graph_weaver/federation.rb +6 -5
- 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 +39 -41
- 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 +5 -4
- 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 +50 -11
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +96 -29
- data/lib/graph_weaver/tasks.rb +78 -29
- data/lib/graph_weaver/testing/cassette.rb +49 -65
- data/lib/graph_weaver/testing/coverage.rb +5 -4
- data/lib/graph_weaver/testing/failure.rb +10 -6
- 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 +94 -808
- data/lib/graph_weaver/testing.rb +35 -84
- 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 +188 -110
- metadata +10 -5
- data/lib/graph_weaver/schemas.rb +0 -48
- 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,7 +91,7 @@ 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)
|
|
91
95
|
|
|
92
96
|
# Shapes that are a credential whatever the field around them is
|
|
93
97
|
# called. Anonymization can't cover everything a cassette holds — the
|
|
@@ -112,35 +116,41 @@ module GraphWeaver
|
|
|
112
116
|
@path = Testing.cassette_path(path)
|
|
113
117
|
@entries = File.exist?(@path) ? YAML.safe_load_file(@path, aliases: true) : []
|
|
114
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
|
|
115
123
|
end
|
|
116
124
|
|
|
117
125
|
def exist? = File.exist?(@path)
|
|
118
126
|
def size = @entries.size
|
|
119
127
|
|
|
120
128
|
def lookup(query, variables, operation_name = nil)
|
|
121
|
-
wanted =
|
|
122
|
-
@entries.find { |entry|
|
|
129
|
+
wanted = Internal::RequestKey.for(query, variables, operation_name)
|
|
130
|
+
@entries.find { |entry| Internal::RequestKey.for_entry(entry) == wanted }
|
|
123
131
|
end
|
|
124
132
|
|
|
125
133
|
# every variables hash recorded for this query — what a miss needs
|
|
126
134
|
# to show, since the variables are what usually differ
|
|
127
135
|
def variants(query, operation_name = nil)
|
|
128
|
-
normalized =
|
|
136
|
+
normalized = Internal::RequestKey.normalize_query(query)
|
|
129
137
|
@entries.select do |entry|
|
|
130
|
-
|
|
138
|
+
Internal::RequestKey.normalize_query(entry["query"]) == normalized && entry["operationName"] == operation_name
|
|
131
139
|
end.map { |entry| entry["variables"] || {} }
|
|
132
140
|
end
|
|
133
141
|
|
|
134
142
|
def record(query, variables, response, operation_name = nil)
|
|
135
143
|
entry = { "query" => query }
|
|
136
144
|
entry["operationName"] = operation_name if operation_name
|
|
137
|
-
entry["variables"] =
|
|
145
|
+
entry["variables"] = Internal::RequestKey.normalize_variables(variables)
|
|
138
146
|
entry["response"] = response
|
|
139
147
|
|
|
140
|
-
wanted =
|
|
141
|
-
@
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
144
154
|
end
|
|
145
155
|
|
|
146
156
|
# Replay every recording through `modules` — the generated query
|
|
@@ -156,10 +166,10 @@ module GraphWeaver
|
|
|
156
166
|
# Matching is on the query text, which is the module that sent it — a
|
|
157
167
|
# recording no module sends is skipped rather than guessed at.
|
|
158
168
|
def check(modules)
|
|
159
|
-
index = modules.to_h { |mod| [
|
|
169
|
+
index = modules.to_h { |mod| [Internal::RequestKey.normalize_query(mod.const_get(:QUERY)), mod] }
|
|
160
170
|
checked = 0
|
|
161
171
|
stale = @entries.filter_map do |entry|
|
|
162
|
-
mod = index[
|
|
172
|
+
mod = index[Internal::RequestKey.normalize_query(entry["query"])] or next
|
|
163
173
|
checked += 1
|
|
164
174
|
|
|
165
175
|
begin
|
|
@@ -176,8 +186,8 @@ module GraphWeaver
|
|
|
176
186
|
# Replace recorded response values with fakes, preserving structure.
|
|
177
187
|
# Walks each entry's query against the schema (like FakeClient,
|
|
178
188
|
# but transforming what's there instead of generating from scratch).
|
|
179
|
-
def anonymize!(schema:, seed: nil,
|
|
180
|
-
anonymizer = Anonymizer.new(schema:, seed:,
|
|
189
|
+
def anonymize!(schema:, seed: nil, values: nil)
|
|
190
|
+
anonymizer = Anonymizer.new(schema:, seed:, values:)
|
|
181
191
|
@entries.each do |entry|
|
|
182
192
|
entry["response"] = anonymizer.anonymize(entry["query"], entry["response"]) if entry["response"]
|
|
183
193
|
end
|
|
@@ -185,42 +195,12 @@ module GraphWeaver
|
|
|
185
195
|
self
|
|
186
196
|
end
|
|
187
197
|
|
|
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
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
# JSON round-trip so symbol keys become strings — otherwise YAML.dump
|
|
212
|
-
# writes Ruby symbols the safe loader rejects on the next run, and lookup
|
|
213
|
-
# keys stay stable across processes
|
|
214
|
-
def self.normalize_variables(variables)
|
|
215
|
-
JSON.parse(JSON.generate(variables || {}))
|
|
216
|
-
end
|
|
217
|
-
|
|
218
198
|
private
|
|
219
199
|
|
|
220
200
|
def save
|
|
221
201
|
yaml = YAML.dump(@entries)
|
|
222
202
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
223
|
-
|
|
203
|
+
GraphWeaver::Internal::Util.atomic_write(@path, yaml)
|
|
224
204
|
flag_credentials(yaml)
|
|
225
205
|
end
|
|
226
206
|
|
|
@@ -234,7 +214,7 @@ module GraphWeaver
|
|
|
234
214
|
return if found.empty?
|
|
235
215
|
|
|
236
216
|
@flagged.concat(found)
|
|
237
|
-
warn "graph_weaver: #{@path} contains #{found.join(", ")} — a cassette is committed as " \
|
|
217
|
+
warn "graph_weaver: #{GraphWeaver::Internal::Util.relative(@path)} contains #{found.join(", ")} — a cassette is committed as " \
|
|
238
218
|
"written, so review this one first. Testing.config.anonymize scrubs the response; the " \
|
|
239
219
|
"query and variables are the replay key and are recorded verbatim."
|
|
240
220
|
end
|
|
@@ -290,7 +270,7 @@ module GraphWeaver
|
|
|
290
270
|
# fake values. Enums, booleans, __typename, and null positions are
|
|
291
271
|
# preserved; ids map consistently so relationships survive.
|
|
292
272
|
class Anonymizer
|
|
293
|
-
include GraphWeaver::Selection
|
|
273
|
+
include GraphWeaver::Internal::Selection
|
|
294
274
|
|
|
295
275
|
# Keys under `errors`/`extensions` whose value describes the request
|
|
296
276
|
# rather than carrying data: `path` and `locations` point into the
|
|
@@ -299,9 +279,9 @@ module GraphWeaver
|
|
|
299
279
|
# for the same reason.
|
|
300
280
|
VERBATIM_KEYS = %w[path locations code].freeze
|
|
301
281
|
|
|
302
|
-
def initialize(schema:, seed: nil,
|
|
282
|
+
def initialize(schema:, seed: nil, values: nil)
|
|
303
283
|
@schema = schema
|
|
304
|
-
@values = Values.new(seed:,
|
|
284
|
+
@values = Internal::Values.new(seed:, values:)
|
|
305
285
|
end
|
|
306
286
|
|
|
307
287
|
# The whole response, not just `data`: an error message routinely
|
|
@@ -383,19 +363,19 @@ module GraphWeaver
|
|
|
383
363
|
# to whichever possible type declares it
|
|
384
364
|
field = @schema.get_field(parent_type.graphql_name, name) ||
|
|
385
365
|
@schema.possible_types(parent_type).filter_map { |t| @schema.get_field(t.graphql_name, name) }.first
|
|
386
|
-
type_value(field.type, name, selections, value)
|
|
366
|
+
type_value(field.type, name, selections, value, "#{parent_type.graphql_name}.#{name}")
|
|
387
367
|
end
|
|
388
368
|
|
|
389
|
-
def type_value(type, name, selections, value)
|
|
369
|
+
def type_value(type, name, selections, value, coordinate = nil)
|
|
390
370
|
return if value.nil? # preserve null positions
|
|
391
371
|
|
|
392
372
|
case type.kind.name
|
|
393
373
|
when "NON_NULL"
|
|
394
|
-
type_value(type.of_type, name, selections, value)
|
|
374
|
+
type_value(type.of_type, name, selections, value, coordinate)
|
|
395
375
|
when "LIST"
|
|
396
|
-
value.map { |element| type_value(type.of_type, name, selections, element) }
|
|
376
|
+
value.map { |element| type_value(type.of_type, name, selections, element, coordinate) }
|
|
397
377
|
when "SCALAR"
|
|
398
|
-
scalar_value(type.graphql_name, name, value)
|
|
378
|
+
scalar_value(type.graphql_name, name, value, coordinate)
|
|
399
379
|
when "ENUM"
|
|
400
380
|
value # enums aren't PII; preserving them keeps semantics
|
|
401
381
|
when "OBJECT", "UNION", "INTERFACE"
|
|
@@ -405,13 +385,17 @@ module GraphWeaver
|
|
|
405
385
|
end
|
|
406
386
|
end
|
|
407
387
|
|
|
408
|
-
def scalar_value(type_name, field_name, value)
|
|
388
|
+
def scalar_value(type_name, field_name, value, coordinate = nil)
|
|
409
389
|
case type_name
|
|
410
390
|
when "ID" then @values.mapped_id(value)
|
|
411
391
|
when "Boolean" then value # not PII; preserves branching behavior
|
|
412
|
-
else @values.scalar(type_name, field_name)
|
|
392
|
+
else @values.scalar(type_name, field_name, coordinate)
|
|
413
393
|
end
|
|
414
394
|
end
|
|
415
395
|
end
|
|
396
|
+
|
|
397
|
+
# the scrubbing walk anonymize! runs — reached through a cassette,
|
|
398
|
+
# never named
|
|
399
|
+
private_constant :Anonymizer
|
|
416
400
|
end
|
|
417
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,6 +35,7 @@ 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
|
|
|
@@ -49,11 +50,11 @@ module GraphWeaver
|
|
|
49
50
|
# separately, from evidence (a loaded schema defining what the table
|
|
50
51
|
# says one resolves), and never refuses: with nothing loaded the
|
|
51
52
|
# answer is simply "none", which is the SDL-alone CI run.
|
|
52
|
-
@planner =
|
|
53
|
-
@absent = table.subgraphs.reject { |name| Subgraphs.served?(table, name) }
|
|
53
|
+
@planner = Internal::Planner.new(table:, schema: GraphWeaver::SchemaLoader.load(source))
|
|
54
|
+
@absent = table.subgraphs.reject { |name| Internal::Subgraphs.served?(table, name) }
|
|
54
55
|
@local = @absent.size < table.subgraphs.size
|
|
55
56
|
@shared = GraphWeaver::Codegen.load_fragments(fragments)
|
|
56
|
-
@results = GraphWeaver.query_files(queries).map { |path| measure(path) }
|
|
57
|
+
@results = GraphWeaver::Internal::Util.query_files(queries).map { |path| measure(path) }
|
|
57
58
|
end
|
|
58
59
|
|
|
59
60
|
def plannable = @results.count { |result| result.category.nil? }
|
|
@@ -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/
|