graph_weaver 0.5.0 → 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 +128 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -1
- data/docs/cassettes.md +23 -3
- data/docs/errors.md +2 -0
- data/docs/federation.md +8 -7
- data/docs/generated_modules.md +2 -2
- data/docs/getting_started.md +1 -1
- data/docs/logging.md +1 -1
- data/docs/testing.md +8 -7
- data/docs/upgrading.md +34 -12
- data/graph_weaver.gemspec +16 -2
- data/lib/generators/graph_weaver/install_generator.rb +15 -15
- data/lib/graph_weaver/client.rb +6 -2
- data/lib/graph_weaver/codegen/aliases.rb +10 -4
- data/lib/graph_weaver/codegen/emit.rb +11 -3
- data/lib/graph_weaver/codegen/enum_type.rb +1 -3
- data/lib/graph_weaver/codegen/scalar_type.rb +5 -4
- data/lib/graph_weaver/codegen/type_helpers.rb +1 -3
- data/lib/graph_weaver/codegen.rb +99 -22
- data/lib/graph_weaver/errors.rb +27 -6
- data/lib/graph_weaver/federation.rb +4 -17
- data/lib/graph_weaver/parsing.rb +1 -9
- data/lib/graph_weaver/rspec.rb +13 -7
- data/lib/graph_weaver/schema_loader.rb +30 -6
- data/lib/graph_weaver/schemas.rb +4 -2
- data/lib/graph_weaver/tasks.rb +24 -21
- data/lib/graph_weaver/testing/cassette.rb +89 -20
- data/lib/graph_weaver/testing/coverage.rb +7 -12
- data/lib/graph_weaver/testing/failure.rb +4 -2
- data/lib/graph_weaver/testing/fake_client.rb +1 -1
- data/lib/graph_weaver/testing/router.rb +68 -47
- data/lib/graph_weaver/testing/subgraphs.rb +11 -7
- data/lib/graph_weaver/testing.rb +8 -2
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +39 -14
- metadata +8 -9
- 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
|
@@ -89,11 +89,29 @@ module GraphWeaver
|
|
|
89
89
|
# One recording the generated structs can no longer read.
|
|
90
90
|
Stale = Struct.new(:module_name, :variables, :message, keyword_init: true)
|
|
91
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
|
+
|
|
92
109
|
attr_reader :path
|
|
93
110
|
|
|
94
111
|
def initialize(path)
|
|
95
112
|
@path = Testing.cassette_path(path)
|
|
96
113
|
@entries = File.exist?(@path) ? YAML.safe_load_file(@path, aliases: true) : []
|
|
114
|
+
@flagged = []
|
|
97
115
|
end
|
|
98
116
|
|
|
99
117
|
def exist? = File.exist?(@path)
|
|
@@ -161,8 +179,7 @@ module GraphWeaver
|
|
|
161
179
|
def anonymize!(schema:, seed: nil, mode: nil)
|
|
162
180
|
anonymizer = Anonymizer.new(schema:, seed:, mode:)
|
|
163
181
|
@entries.each do |entry|
|
|
164
|
-
|
|
165
|
-
entry["response"]["data"] = anonymizer.anonymize(entry["query"], data) if data
|
|
182
|
+
entry["response"] = anonymizer.anonymize(entry["query"], entry["response"]) if entry["response"]
|
|
166
183
|
end
|
|
167
184
|
save
|
|
168
185
|
self
|
|
@@ -201,8 +218,25 @@ module GraphWeaver
|
|
|
201
218
|
private
|
|
202
219
|
|
|
203
220
|
def save
|
|
221
|
+
yaml = YAML.dump(@entries)
|
|
204
222
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
205
|
-
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."
|
|
206
240
|
end
|
|
207
241
|
end
|
|
208
242
|
|
|
@@ -227,9 +261,7 @@ module GraphWeaver
|
|
|
227
261
|
|
|
228
262
|
def execute(query, variables: {}, operation_name: nil)
|
|
229
263
|
response = @client.execute(query, variables:, operation_name:).to_h
|
|
230
|
-
|
|
231
|
-
response = response.merge("data" => @anonymizer.anonymize(query, data))
|
|
232
|
-
end
|
|
264
|
+
response = @anonymizer.anonymize(query, response) if @anonymizer
|
|
233
265
|
|
|
234
266
|
@cassette.record(query, variables, response, operation_name)
|
|
235
267
|
response
|
|
@@ -260,18 +292,52 @@ module GraphWeaver
|
|
|
260
292
|
class Anonymizer
|
|
261
293
|
include GraphWeaver::Selection
|
|
262
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
|
+
|
|
263
302
|
def initialize(schema:, seed: nil, mode: nil)
|
|
264
303
|
@schema = schema
|
|
265
304
|
@values = Values.new(seed:, mode:)
|
|
266
305
|
end
|
|
267
306
|
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
270
316
|
|
|
317
|
+
private
|
|
318
|
+
|
|
319
|
+
def data_value(query, data)
|
|
320
|
+
return if data.nil?
|
|
321
|
+
|
|
322
|
+
operation = load_operation(query)
|
|
271
323
|
object_value(operation_root_type(operation), operation.selections, data)
|
|
272
324
|
end
|
|
273
325
|
|
|
274
|
-
|
|
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
|
|
275
341
|
|
|
276
342
|
# Anonymization walks recorded data, not a live dispatch. When the query
|
|
277
343
|
# narrows an abstract type without selecting __typename (`named { name
|
|
@@ -295,42 +361,45 @@ module GraphWeaver
|
|
|
295
361
|
end
|
|
296
362
|
|
|
297
363
|
result = {}
|
|
298
|
-
|
|
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|
|
|
299
367
|
next unless data.key?(key)
|
|
300
368
|
|
|
369
|
+
node = nodes.first
|
|
301
370
|
result[key] = if node.name == "__typename"
|
|
302
371
|
data[key]
|
|
303
372
|
else
|
|
304
|
-
field_value(type, node, data[key])
|
|
373
|
+
field_value(type, node.name, nodes.flat_map(&:selections), data[key])
|
|
305
374
|
end
|
|
306
375
|
end
|
|
307
376
|
|
|
308
377
|
result
|
|
309
378
|
end
|
|
310
379
|
|
|
311
|
-
def field_value(parent_type,
|
|
380
|
+
def field_value(parent_type, name, selections, value)
|
|
312
381
|
# a field from a `... on Member` fragment lives on the member, not the
|
|
313
382
|
# abstract type we're walking (no __typename to narrow by), so fall back
|
|
314
383
|
# 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,
|
|
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)
|
|
318
387
|
end
|
|
319
388
|
|
|
320
|
-
def type_value(type,
|
|
389
|
+
def type_value(type, name, selections, value)
|
|
321
390
|
return if value.nil? # preserve null positions
|
|
322
391
|
|
|
323
392
|
case type.kind.name
|
|
324
393
|
when "NON_NULL"
|
|
325
|
-
type_value(type.of_type,
|
|
394
|
+
type_value(type.of_type, name, selections, value)
|
|
326
395
|
when "LIST"
|
|
327
|
-
value.map { |element| type_value(type.of_type,
|
|
396
|
+
value.map { |element| type_value(type.of_type, name, selections, element) }
|
|
328
397
|
when "SCALAR"
|
|
329
|
-
scalar_value(type.graphql_name,
|
|
398
|
+
scalar_value(type.graphql_name, name, value)
|
|
330
399
|
when "ENUM"
|
|
331
400
|
value # enums aren't PII; preserving them keeps semantics
|
|
332
401
|
when "OBJECT", "UNION", "INTERFACE"
|
|
333
|
-
object_value(type,
|
|
402
|
+
object_value(type, selections, value)
|
|
334
403
|
else
|
|
335
404
|
value
|
|
336
405
|
end
|
|
@@ -41,15 +41,8 @@ module GraphWeaver
|
|
|
41
41
|
def initialize(supergraph:, queries: GraphWeaver.queries_paths, fragments: GraphWeaver.fragments_paths)
|
|
42
42
|
source = supergraph.to_s
|
|
43
43
|
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
|
|
44
|
+
# with the table incomplete, every number this would report is a guess
|
|
45
|
+
Unplannable.unsupported!(table)
|
|
53
46
|
|
|
54
47
|
# Planning still runs with `absent` empty — a query is plannable or
|
|
55
48
|
# not whoever is serving. Which subgraphs are *here* is asked
|
|
@@ -57,7 +50,7 @@ module GraphWeaver
|
|
|
57
50
|
# says one resolves), and never refuses: with nothing loaded the
|
|
58
51
|
# answer is simply "none", which is the SDL-alone CI run.
|
|
59
52
|
@planner = Router::Planner.new(table:, schema: GraphWeaver::SchemaLoader.load(source))
|
|
60
|
-
@absent = table.subgraphs.reject { |name| Subgraphs.
|
|
53
|
+
@absent = table.subgraphs.reject { |name| Subgraphs.served?(table, name) }
|
|
61
54
|
@local = @absent.size < table.subgraphs.size
|
|
62
55
|
@shared = GraphWeaver::Codegen.load_fragments(fragments)
|
|
63
56
|
@results = GraphWeaver.query_files(queries).map { |path| measure(path) }
|
|
@@ -144,8 +137,10 @@ module GraphWeaver
|
|
|
144
137
|
# the directory every query came from, so the report's file column is
|
|
145
138
|
# filenames rather than the same path repeated
|
|
146
139
|
def shared_dir
|
|
147
|
-
|
|
148
|
-
|
|
140
|
+
@shared_dir ||= begin
|
|
141
|
+
dirs = @results.map { |result| File.dirname(result.path) }.uniq
|
|
142
|
+
dirs.one? ? "#{dirs.first}/" : ""
|
|
143
|
+
end
|
|
149
144
|
end
|
|
150
145
|
|
|
151
146
|
def measure(path)
|
|
@@ -51,8 +51,10 @@ module GraphWeaver
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def throttled
|
|
54
|
-
#
|
|
55
|
-
|
|
54
|
+
# a code from the list #throttled? recognizes, not one spelled here —
|
|
55
|
+
# a fake that doesn't trip the predicate it exists to exercise is worse
|
|
56
|
+
# than no fake. (array-wrapped so the hash can't parse as kwargs)
|
|
57
|
+
graphql([{ message: "rate limited", extensions: { code: GraphWeaver::GraphQLError::THROTTLE_CODES.first } }])
|
|
56
58
|
end
|
|
57
59
|
|
|
58
60
|
# A validation-shaped rejection — trips schema_stale? and its
|
|
@@ -96,7 +96,7 @@ class GraphWeaver::Testing::FakeClient
|
|
|
96
96
|
@schema = schema || config.schema || raise(GraphWeaver::Error,
|
|
97
97
|
"no schema to fake against — set GraphWeaver::Testing.config.schema, pass schema:, " \
|
|
98
98
|
"or commit a schema dump at #{GraphWeaver.schema_path}")
|
|
99
|
-
@overrides = config.overrides.merge(overrides)
|
|
99
|
+
@overrides = config.overrides.merge(overrides).transform_keys(&:to_s)
|
|
100
100
|
GraphWeaver::Testing.validate_overrides!(@schema, @overrides)
|
|
101
101
|
@values = GraphWeaver::Testing::Values.new(seed:, mode:)
|
|
102
102
|
@list_size = list_size || config.list_size
|
|
@@ -6,6 +6,7 @@ require "json"
|
|
|
6
6
|
|
|
7
7
|
require_relative "../parsing"
|
|
8
8
|
require_relative "../schema_loader"
|
|
9
|
+
require_relative "../selection"
|
|
9
10
|
require_relative "../transport"
|
|
10
11
|
require_relative "subgraphs"
|
|
11
12
|
|
|
@@ -116,6 +117,21 @@ module GraphWeaver
|
|
|
116
117
|
# the short label a report groups this refusal under
|
|
117
118
|
def label = CATEGORIES.fetch(category).first
|
|
118
119
|
|
|
120
|
+
# Refuse a supergraph the routing table couldn't read whole. An unread
|
|
121
|
+
# @join__ construct leaves the table incomplete, so every answer drawn
|
|
122
|
+
# from it is a guess — including which schema serves which subgraph, so
|
|
123
|
+
# this comes before resolving those. Router and Coverage both refuse at
|
|
124
|
+
# construction, before any query, and say it the same way.
|
|
125
|
+
def self.unsupported!(table)
|
|
126
|
+
return if table.unsupported.empty?
|
|
127
|
+
|
|
128
|
+
raise new(
|
|
129
|
+
"this supergraph uses federation constructs the local router doesn't read: " +
|
|
130
|
+
table.unsupported.join("; "),
|
|
131
|
+
category: :unsupported_federation,
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
119
135
|
def to_h = super.merge("category" => category.to_s, "detail" => detail)
|
|
120
136
|
end
|
|
121
137
|
|
|
@@ -146,12 +162,12 @@ module GraphWeaver
|
|
|
146
162
|
# the object the SDL spells rather than as a flattened path; and a union
|
|
147
163
|
# or interface at a boundary, planned per concrete type and bucketed on
|
|
148
164
|
# the `__typename` the data comes back with.
|
|
149
|
-
#
|
|
150
|
-
# *faithfully* raises {Unplannable}, before any
|
|
151
|
-
# refusal can never be a half-executed query. Apollo's
|
|
152
|
-
# thousand lines; a double that approximated the rest of
|
|
153
|
-
# test pass on an answer production disagrees with, which is
|
|
154
|
-
# expensive thing this library can produce.
|
|
165
|
+
#
|
|
166
|
+
# Everything it can't plan *faithfully* raises {Unplannable}, before any
|
|
167
|
+
# subgraph runs, so a refusal can never be a half-executed query. Apollo's
|
|
168
|
+
# planner is twenty thousand lines; a double that approximated the rest of
|
|
169
|
+
# it would let a test pass on an answer production disagrees with, which is
|
|
170
|
+
# the most expensive thing this library can produce.
|
|
155
171
|
#
|
|
156
172
|
# Introspection is answered from the composed API schema — never from a
|
|
157
173
|
# subgraph, which would reply with its own slice. That is the one split a
|
|
@@ -224,6 +240,12 @@ module GraphWeaver
|
|
|
224
240
|
end
|
|
225
241
|
end
|
|
226
242
|
|
|
243
|
+
# one error in the shape a GraphQL response carries them — a class
|
|
244
|
+
# method because the Planner refuses documents before a Router exists
|
|
245
|
+
def self.graphql_error(message, code)
|
|
246
|
+
{ "message" => message, "extensions" => { "code" => code } }
|
|
247
|
+
end
|
|
248
|
+
|
|
227
249
|
# subgraphs: names the Ruby schema serving each subgraph. Omit it (or
|
|
228
250
|
# any of its entries) and the rest are derived from what each loaded
|
|
229
251
|
# schema defines — see {Subgraphs}, which also checks the ones you name.
|
|
@@ -236,17 +258,7 @@ module GraphWeaver
|
|
|
236
258
|
@context = context
|
|
237
259
|
@trace = []
|
|
238
260
|
|
|
239
|
-
|
|
240
|
-
# means the routing table is incomplete, and every answer it gives
|
|
241
|
-
# about this supergraph is a guess — including which schema serves
|
|
242
|
-
# which subgraph, so this comes before resolving those
|
|
243
|
-
unless @table.unsupported.empty?
|
|
244
|
-
raise Unplannable.new(
|
|
245
|
-
"this supergraph uses federation constructs the local router doesn't read: " +
|
|
246
|
-
@table.unsupported.join("; "),
|
|
247
|
-
category: :unsupported_federation,
|
|
248
|
-
)
|
|
249
|
-
end
|
|
261
|
+
Unplannable.unsupported!(@table)
|
|
250
262
|
|
|
251
263
|
served = Subgraphs.resolve(@table, subgraphs)
|
|
252
264
|
@faked = served.select { |_name, schema| schema == Subgraphs::FAKE }.keys.freeze
|
|
@@ -257,19 +269,30 @@ module GraphWeaver
|
|
|
257
269
|
@planner = Planner.new(table: @table, schema: @schema, absent: @absent)
|
|
258
270
|
end
|
|
259
271
|
|
|
260
|
-
# Drop the fetches recorded so far
|
|
261
|
-
#
|
|
262
|
-
#
|
|
272
|
+
# Drop the fetches recorded so far, so #trace answers about what runs
|
|
273
|
+
# next. For counting fetches across part of a run — the whole example
|
|
274
|
+
# boundary is #reset!.
|
|
263
275
|
def reset_trace
|
|
264
276
|
@trace = []
|
|
265
277
|
self
|
|
266
278
|
end
|
|
267
279
|
|
|
280
|
+
# An example boundary. A router is built once and reused (the rspec tag
|
|
281
|
+
# builds one per suite), so a faked subgraph would otherwise keep
|
|
282
|
+
# fabricating from wherever the previous example left its sequence —
|
|
283
|
+
# making the same example give different data alone than in a full run,
|
|
284
|
+
# which is exactly what `rspec --seed` promises it won't.
|
|
285
|
+
def reset!
|
|
286
|
+
reset_trace
|
|
287
|
+
@faked.each { |name| @subgraphs[name] = FakeSubgraph.new(name, @schema) }
|
|
288
|
+
self
|
|
289
|
+
end
|
|
290
|
+
|
|
268
291
|
def execute(query, variables: {}, operation_name: nil)
|
|
269
292
|
document = begin
|
|
270
293
|
GraphQL.parse(query)
|
|
271
294
|
rescue GraphQL::ParseError => e
|
|
272
|
-
return { "data" => nil, "errors" => [graphql_error(e.message, "GRAPHQL_PARSE_FAILED")] }
|
|
295
|
+
return { "data" => nil, "errors" => [Router.graphql_error(e.message, "GRAPHQL_PARSE_FAILED")] }
|
|
273
296
|
end
|
|
274
297
|
|
|
275
298
|
# validate the way a router does, so a stale query fails as it fails
|
|
@@ -341,13 +364,16 @@ module GraphWeaver
|
|
|
341
364
|
response
|
|
342
365
|
end
|
|
343
366
|
|
|
344
|
-
#
|
|
345
|
-
#
|
|
346
|
-
#
|
|
367
|
+
# Only for evaluating @skip/@include, which read Booleans — so only the
|
|
368
|
+
# scalar defaults the parser hands back as Ruby values are wanted. An
|
|
369
|
+
# enum or input-object default is an AST node; sending one to a subgraph
|
|
370
|
+
# puts a parser back-pointer on the wire, or raises inside JSON. The
|
|
371
|
+
# subgraph applies those itself: used_variables copies each declaration
|
|
372
|
+
# verbatim, defaults and all.
|
|
347
373
|
def variable_defaults(operation)
|
|
348
374
|
operation.variables.each_with_object({}) do |definition, defaults|
|
|
349
375
|
value = definition.default_value
|
|
350
|
-
next
|
|
376
|
+
next unless value == true || value == false
|
|
351
377
|
|
|
352
378
|
defaults[definition.name] = value
|
|
353
379
|
end
|
|
@@ -358,7 +384,7 @@ module GraphWeaver
|
|
|
358
384
|
# and includes under @skip — the same way graphql-ruby resolves it.
|
|
359
385
|
def included?(node, variables)
|
|
360
386
|
node.directives.all? do |directive|
|
|
361
|
-
next true unless
|
|
387
|
+
next true unless GraphWeaver::Selection::CONDITIONAL_DIRECTIVES.include?(directive.name)
|
|
362
388
|
|
|
363
389
|
argument = directive.arguments.find { |arg| arg.name == "if" } or next true
|
|
364
390
|
value = argument.value
|
|
@@ -368,6 +394,9 @@ module GraphWeaver
|
|
|
368
394
|
end
|
|
369
395
|
end
|
|
370
396
|
|
|
397
|
+
# Everything the plan applies at this level: one _entities fetch per
|
|
398
|
+
# subgraph the level defers to (all nodes at once — _entities answers
|
|
399
|
+
# in representation order), then the same again one level down.
|
|
371
400
|
def stitch(step, nodes, operation, variables, errors)
|
|
372
401
|
return if nodes.empty?
|
|
373
402
|
|
|
@@ -386,14 +415,14 @@ module GraphWeaver
|
|
|
386
415
|
|
|
387
416
|
blocked = prefetch(step, nodes, operation, variables, errors)
|
|
388
417
|
|
|
389
|
-
# a @requires fetch and a plain one need different node sets, so they
|
|
390
|
-
# can't share a call even into the same subgraph — which is the split
|
|
391
|
-
# a real router makes too
|
|
392
418
|
# A fetch for a selection the operation excluded is a fetch a real
|
|
393
419
|
# router never makes, and `trace` is something specs assert on. The
|
|
394
420
|
# plan is built once and reused, so only here are the variables known.
|
|
395
421
|
wanted = step.deferrals.select { |d| included?(d.node, variables) }
|
|
396
422
|
|
|
423
|
+
# a @requires fetch and a plain one need different node sets, so they
|
|
424
|
+
# can't share a call even into the same subgraph — which is the split
|
|
425
|
+
# a real router makes too
|
|
397
426
|
wanted.group_by { |d| [d.subgraph, d.requires.any?] }.each do |(target, chained), deferrals|
|
|
398
427
|
fetched = chained ? nodes.reject { |(node, _)| blocked.include?(node.object_id) } : nodes
|
|
399
428
|
tree = Router.field_tree(deferrals.flat_map(&:representation).uniq)
|
|
@@ -609,10 +638,6 @@ module GraphWeaver
|
|
|
609
638
|
end
|
|
610
639
|
end
|
|
611
640
|
|
|
612
|
-
def graphql_error(message, code)
|
|
613
|
-
{ "message" => message, "extensions" => { "code" => code } }
|
|
614
|
-
end
|
|
615
|
-
|
|
616
641
|
# ---- null propagation ---------------------------------------------
|
|
617
642
|
|
|
618
643
|
# a position whose type forbids null but whose value is null: the
|
|
@@ -634,7 +659,10 @@ module GraphWeaver
|
|
|
634
659
|
# __typename the fetch bucketed by says what this object is. Without
|
|
635
660
|
# one the subtree ran whole in one subgraph, which already applied
|
|
636
661
|
# its own propagation — there is nothing to redo.
|
|
637
|
-
|
|
662
|
+
# get_type, not types[]: the latter merges every type through a
|
|
663
|
+
# visibility filter into a fresh hash, and this runs once per
|
|
664
|
+
# response row — so it would cost rows x schema size
|
|
665
|
+
concrete = value.is_a?(Hash) ? @schema.get_type(value[TYPENAME] || value["__typename"]) : nil
|
|
638
666
|
return value unless concrete&.kind&.fields?
|
|
639
667
|
|
|
640
668
|
propagate_object(value, concrete, @planner.narrow(concrete.graphql_name, selections, fragments), fragments)
|
|
@@ -782,9 +810,8 @@ module GraphWeaver
|
|
|
782
810
|
|
|
783
811
|
# the operation's validation errors, GraphQL-wire shaped
|
|
784
812
|
def validate(document)
|
|
785
|
-
@schema.validate(document)
|
|
786
|
-
{
|
|
787
|
-
end
|
|
813
|
+
@schema.validate(document)
|
|
814
|
+
.map { |error| Router.graphql_error(error.message, "GRAPHQL_VALIDATION_FAILED") }
|
|
788
815
|
end
|
|
789
816
|
|
|
790
817
|
def plan(document, operation_name: nil)
|
|
@@ -897,7 +924,7 @@ module GraphWeaver
|
|
|
897
924
|
def applies?(condition, concrete)
|
|
898
925
|
return true if condition.nil? || condition == concrete
|
|
899
926
|
|
|
900
|
-
type = @schema.
|
|
927
|
+
type = @schema.get_type(condition)
|
|
901
928
|
!!type&.kind&.abstract? && @schema.possible_types(type).any? { |t| t.graphql_name == concrete }
|
|
902
929
|
end
|
|
903
930
|
|
|
@@ -1028,7 +1055,7 @@ module GraphWeaver
|
|
|
1028
1055
|
def plan_child(type_name, node, subgraph, field, fragments, depth)
|
|
1029
1056
|
child_type = child_type_name(type_name, node.name)
|
|
1030
1057
|
return plan_branches(type_name, node, child_type, subgraph, field, fragments, depth) if
|
|
1031
|
-
@schema.
|
|
1058
|
+
@schema.get_type(child_type)&.kind&.abstract?
|
|
1032
1059
|
|
|
1033
1060
|
plan_step(child_type, narrow(child_type, node.selections, fragments), subgraph, fragments,
|
|
1034
1061
|
provides(field), depth + 1)
|
|
@@ -1206,13 +1233,7 @@ module GraphWeaver
|
|
|
1206
1233
|
# Dotted paths back to the selection set they were parsed from — the
|
|
1207
1234
|
# inverse of RoutingTable.parse_field_set, so a refusal spells the
|
|
1208
1235
|
# field set the way the schema does and is greppable against it.
|
|
1209
|
-
def field_set(paths)
|
|
1210
|
-
tree = {}
|
|
1211
|
-
paths.each do |path|
|
|
1212
|
-
path.split(".").reduce(tree) { |node, segment| node[segment] ||= {} }
|
|
1213
|
-
end
|
|
1214
|
-
render_field_set(tree)
|
|
1215
|
-
end
|
|
1236
|
+
def field_set(paths) = render_field_set(Router.field_tree(paths))
|
|
1216
1237
|
|
|
1217
1238
|
def render_field_set(tree)
|
|
1218
1239
|
tree.map { |name, children|
|
|
@@ -1415,7 +1436,7 @@ module GraphWeaver
|
|
|
1415
1436
|
end
|
|
1416
1437
|
|
|
1417
1438
|
def raw_child_type(type_name, field_name)
|
|
1418
|
-
type = @schema.
|
|
1439
|
+
type = @schema.get_type(type_name)
|
|
1419
1440
|
return unless type.respond_to?(:fields)
|
|
1420
1441
|
|
|
1421
1442
|
field = type.fields[field_name] or return
|
|
@@ -45,13 +45,7 @@ module GraphWeaver
|
|
|
45
45
|
# Names in `given` skip detection (:fake included); the rest are
|
|
46
46
|
# derived, and both go through the same check.
|
|
47
47
|
def resolve(table, given = nil, schemas: nil)
|
|
48
|
-
named = (given
|
|
49
|
-
unknown = named.keys - table.subgraphs
|
|
50
|
-
if unknown.any?
|
|
51
|
-
raise GraphWeaver::ConfigurationError, "subgraphs: names #{unknown.join(", ")}, which " \
|
|
52
|
-
"this supergraph doesn't have (its subgraphs are #{table.subgraphs.join(", ")})"
|
|
53
|
-
end
|
|
54
|
-
|
|
48
|
+
named = table.named_subgraphs(given)
|
|
55
49
|
searched = schemas || GraphWeaver::Schemas.loaded
|
|
56
50
|
table.subgraphs.filter_map do |name|
|
|
57
51
|
served = named.key?(name) ? check!(table, name, named[name]) : detect(table, name, searched)
|
|
@@ -81,6 +75,16 @@ module GraphWeaver
|
|
|
81
75
|
expected(table, name).reject { |coordinate| GraphWeaver::Schemas.defines?(schema, coordinate) }
|
|
82
76
|
end
|
|
83
77
|
|
|
78
|
+
# Is this subgraph served in this process? Exactly one candidate is
|
|
79
|
+
# what that means: none is somebody else's service, and several is a
|
|
80
|
+
# question only the caller can answer, so neither is something a suite
|
|
81
|
+
# can run against. `detect` turns the several into a refusal at
|
|
82
|
+
# construction; a report counts it as not served here, which is the
|
|
83
|
+
# same verdict phrased for something that never raises.
|
|
84
|
+
def served?(table, name, schemas = GraphWeaver::Schemas.loaded)
|
|
85
|
+
candidates(table, name, schemas).one?
|
|
86
|
+
end
|
|
87
|
+
|
|
84
88
|
private
|
|
85
89
|
|
|
86
90
|
# The schema serving `name`, or nil when nothing here does — the
|
data/lib/graph_weaver/testing.rb
CHANGED
|
@@ -50,8 +50,11 @@ module GraphWeaver
|
|
|
50
50
|
CLIENT_MODES = %i[fake in_process router].freeze
|
|
51
51
|
|
|
52
52
|
class Config
|
|
53
|
-
attr_accessor :
|
|
53
|
+
attr_accessor :overrides, :seed, :list_size, :null_chance, :cassette_dir, :context,
|
|
54
54
|
:record, :anonymize
|
|
55
|
+
# #schema is written plainly and read with a fallback (below), the way
|
|
56
|
+
# #mode, #router and #default_mode are read plainly and written with a check
|
|
57
|
+
attr_writer :schema
|
|
55
58
|
attr_reader :mode, :router, :default_mode
|
|
56
59
|
|
|
57
60
|
def initialize
|
|
@@ -169,7 +172,10 @@ module GraphWeaver
|
|
|
169
172
|
# client already runs in-process. Only a live class has resolvers, so
|
|
170
173
|
# there is nothing else to fall back to: a dump is type information.
|
|
171
174
|
def schema_class!
|
|
172
|
-
|
|
175
|
+
# explicit_schema, not schema: the latter falls back to the committed
|
|
176
|
+
# dump, which loads as an anonymous GraphQL::Schema subclass — runnable
|
|
177
|
+
# by every test that matters, and holding not one resolver.
|
|
178
|
+
runnable(explicit_schema) || GraphWeaver.live_schema ||
|
|
173
179
|
raise(GraphWeaver::Error, ":in_process runs your resolvers, so it needs the live " \
|
|
174
180
|
"GraphQL::Schema class — and GraphWeaver.client isn't running one in-process to " \
|
|
175
181
|
"borrow. Name it in the example — graphql_in_process(MySchema) — or set " \
|
data/lib/graph_weaver/version.rb
CHANGED
data/lib/graph_weaver.rb
CHANGED
|
@@ -68,6 +68,27 @@ module GraphWeaver
|
|
|
68
68
|
# body has to brand rather than escape as a raw Sorbet TypeError from a sig
|
|
69
69
|
# (which fires before the struct's own rescue can see it). Lives here rather
|
|
70
70
|
# than unrolled into every generated module.
|
|
71
|
+
# Cast a response's data, keeping the server's own errors on a failure.
|
|
72
|
+
# The common cause of a cast failure is a field that came back null *with
|
|
73
|
+
# a reason attached* — a permission rule, a partial outage — and raising
|
|
74
|
+
# only Sorbet's nil complaint throws that reason away, leaving whoever is
|
|
75
|
+
# on call with a type error and no explanation.
|
|
76
|
+
sig do
|
|
77
|
+
params(struct: T.untyped, data: T.untyped, errors: T::Array[GraphWeaver::GraphQLError])
|
|
78
|
+
.returns(T.untyped)
|
|
79
|
+
end
|
|
80
|
+
def cast_data(struct, data, errors)
|
|
81
|
+
struct.from_h(data)
|
|
82
|
+
rescue GraphWeaver::TypeError => e
|
|
83
|
+
raise if errors.empty?
|
|
84
|
+
|
|
85
|
+
detail = e.message.delete_prefix("failed to cast response into #{struct}: ")
|
|
86
|
+
raise GraphWeaver::TypeError.new(
|
|
87
|
+
struct:,
|
|
88
|
+
message: "#{detail} — the server also reported: #{errors.map(&:message).join("; ")}",
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
71
92
|
def check_envelope!(raw, struct)
|
|
72
93
|
unless raw.is_a?(Hash)
|
|
73
94
|
raise GraphWeaver::TypeError.new(struct:, message: "response must be an object, got #{raw.class}")
|
|
@@ -131,11 +152,6 @@ module GraphWeaver
|
|
|
131
152
|
# read. A second schema is a second generate! (schema: names it).
|
|
132
153
|
attr_writer :schema_path
|
|
133
154
|
|
|
134
|
-
# Set by every graph_weaver rake task: those tasks WRITE the generated
|
|
135
|
-
# files, so loading them first lets a stale one block its own repair.
|
|
136
|
-
# None of them needs the modules loaded.
|
|
137
|
-
attr_accessor :skip_generated_load
|
|
138
|
-
|
|
139
155
|
def queries_paths = @queries_paths ||= ["app/graphql/queries"]
|
|
140
156
|
def generated_paths = @generated_paths ||= ["app/graphql/generated", "app/graphql/*/generated"]
|
|
141
157
|
|
|
@@ -159,6 +175,11 @@ module GraphWeaver
|
|
|
159
175
|
|
|
160
176
|
def schema_path = @schema_path || "app/graphql/schema.json"
|
|
161
177
|
|
|
178
|
+
# Set by every graph_weaver rake task: those tasks WRITE the generated
|
|
179
|
+
# files, so loading them first lets a stale one block its own repair.
|
|
180
|
+
# None of them needs the modules loaded.
|
|
181
|
+
attr_accessor :skip_generated_load
|
|
182
|
+
|
|
162
183
|
# Every query document under these directories, sorted — the files
|
|
163
184
|
# generate!, verify_generated!, check_queries and load_queries! all read.
|
|
164
185
|
def query_files(paths = queries_paths)
|
|
@@ -324,10 +345,11 @@ module GraphWeaver
|
|
|
324
345
|
|
|
325
346
|
# locate_schema! raises the conventional "no schema dump" message
|
|
326
347
|
path = SchemaLoader.locate_path or locate_schema!
|
|
327
|
-
|
|
328
|
-
return SchemaLoader.load(path) unless meta&.key?("url")
|
|
348
|
+
return SchemaLoader.load(path) unless SchemaLoader.provenance(path)&.key?("url")
|
|
329
349
|
|
|
330
|
-
|
|
350
|
+
# source_transport rather than one built here: it reads the auth ENV var
|
|
351
|
+
# the dump named, so `--auth MY_TOKEN` reaches this path too
|
|
352
|
+
SchemaLoader.introspect(SchemaLoader.source_transport(path))
|
|
331
353
|
end
|
|
332
354
|
private :refreshed_schema
|
|
333
355
|
|
|
@@ -607,15 +629,18 @@ module GraphWeaver
|
|
|
607
629
|
#
|
|
608
630
|
# schema: is a graphql-ruby schema or a Client (its schema, and its
|
|
609
631
|
# transport as the module's default). query is a .graphql/.gql path (module
|
|
610
|
-
# name derived from the file name
|
|
611
|
-
#
|
|
612
|
-
#
|
|
613
|
-
#
|
|
614
|
-
#
|
|
615
|
-
# override, client: to bake the module's default client/transport.
|
|
632
|
+
# name derived from the file name and the operation — see #module_name) or
|
|
633
|
+
# a raw query string (name derived from the operation name, falling back to
|
|
634
|
+
# "Query" for anonymous operations — collisions are impossible since each
|
|
635
|
+
# parse gets its own container). Pass name: to override, client: to bake
|
|
636
|
+
# the module's default client/transport.
|
|
616
637
|
def parse(schema:, query:, name: nil, client: nil, fragments: fragments_paths)
|
|
617
638
|
client ||= schema if schema.is_a?(Client)
|
|
618
639
|
schema = schema_for(schema)
|
|
640
|
+
# Rails.root.join(...) hands you a Pathname, and to_path is the
|
|
641
|
+
# ecosystem's "I am a path" — the same conversion schema: gets through
|
|
642
|
+
# SchemaLoader.load. Without it end_with? below is a NoMethodError.
|
|
643
|
+
query = query.to_path if query.respond_to?(:to_path)
|
|
619
644
|
path = query if query.end_with?(".graphql", ".gql")
|
|
620
645
|
if path
|
|
621
646
|
query = File.read(path)
|