graph_weaver 0.4.4 → 0.5.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 +1357 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -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 -136
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -251
- data/lib/graph_weaver/codegen/enum_type.rb +27 -98
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +617 -264
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +12 -6
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +21 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -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 +15 -1
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +820 -57
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +59 -7
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +186 -62
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +194 -28
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +31 -6
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +74 -18
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +398 -170
- metadata +20 -3
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
require "graphql"
|
|
5
5
|
require "json"
|
|
6
6
|
|
|
7
|
+
require_relative "../parsing"
|
|
8
|
+
|
|
7
9
|
# A fake client that fabricates schema-correct responses for whatever query
|
|
8
10
|
# arrives — the zero-setup way to test code built on generated modules:
|
|
9
11
|
#
|
|
10
|
-
# fake = GraphWeaver::Testing::FakeClient.new
|
|
11
|
-
# result = PersonQuery.execute!(id: "1"
|
|
12
|
+
# fake = GraphWeaver::Testing::FakeClient.new
|
|
13
|
+
# result = PersonQuery.execute!(client: fake, id: "1")
|
|
12
14
|
# result.person.name # => a plausible String, typed and castable
|
|
13
15
|
#
|
|
14
16
|
# Values are type-correct by construction (real enum values, valid
|
|
@@ -18,15 +20,33 @@ require "json"
|
|
|
18
20
|
#
|
|
19
21
|
# overrides: pin fields by GraphQL name — schema vocabulary, so keys
|
|
20
22
|
# survive query refactors. "Type.field" beats "field"; values are
|
|
21
|
-
# literals or zero-arg procs.
|
|
22
|
-
#
|
|
23
|
-
#
|
|
23
|
+
# literals or zero-arg procs. Keys are checked against the schema, since
|
|
24
|
+
# a typo'd one would pin nothing and leave the test green. (An override
|
|
25
|
+
# with a wrong-typed value is also the way to simulate a corrupt
|
|
26
|
+
# payload — casting raises GraphWeaver::TypeError.)
|
|
24
27
|
#
|
|
25
28
|
# FakeClient.new(schema:, overrides: {
|
|
26
29
|
# "Person.name" => "Daniel",
|
|
27
30
|
# "email" => -> { "test@example.com" },
|
|
28
31
|
# })
|
|
29
32
|
#
|
|
33
|
+
# An override pins a whole subtree as readily as a leaf, and **merges**
|
|
34
|
+
# rather than replaces: name the fields the test is about and the rest of
|
|
35
|
+
# the selection is still fabricated. A list pins its own length, so "two
|
|
36
|
+
# orders, the first one paid" is the literal thing you write:
|
|
37
|
+
#
|
|
38
|
+
# FakeClient.new(schema:, overrides: {
|
|
39
|
+
# "Reader.name" => "Ada",
|
|
40
|
+
# "Reader.orders" => [{ "status" => "PAID" }, {}],
|
|
41
|
+
# })
|
|
42
|
+
#
|
|
43
|
+
# Keys inside a pinned subtree are response keys — what comes back on the
|
|
44
|
+
# wire, aliases included — and one the query doesn't select is refused,
|
|
45
|
+
# for the same reason a typo'd coordinate is.
|
|
46
|
+
#
|
|
47
|
+
# requests: every execute, in order ({ query:, variables:, operation_name: })
|
|
48
|
+
# — "did we send the right variables", and "did we call it at all".
|
|
49
|
+
#
|
|
30
50
|
# Partial failures: fail_at simulates a field-level error with
|
|
31
51
|
# spec-correct null propagation — the field's error lands in the errors
|
|
32
52
|
# array (with its concrete path), the field becomes null, and nulls
|
|
@@ -45,19 +65,39 @@ require "json"
|
|
|
45
65
|
#
|
|
46
66
|
# FakeClient.new(schema:, corrupt: "Person.birthday")
|
|
47
67
|
#
|
|
48
|
-
# seed: makes a run reproducible (also seeds faker).
|
|
49
|
-
#
|
|
68
|
+
# seed: makes a run reproducible (also seeds faker). Every option,
|
|
69
|
+
# schema: included, falls back to GraphWeaver::Testing.config — and the
|
|
70
|
+
# config's schema falls back to the committed dump.
|
|
50
71
|
class GraphWeaver::Testing::FakeClient
|
|
72
|
+
include GraphWeaver::Parsing
|
|
51
73
|
include GraphWeaver::Selection
|
|
52
74
|
|
|
53
75
|
# sentinel: a simulated failure bubbling up to the nearest nullable spot
|
|
54
76
|
NULL_BUBBLE = Object.new.freeze
|
|
55
77
|
|
|
56
|
-
|
|
78
|
+
# sentinel: no override here — distinct from an override OF nil, which
|
|
79
|
+
# pins the field null
|
|
80
|
+
UNPINNED = Object.new.freeze
|
|
81
|
+
private_constant :UNPINNED
|
|
82
|
+
|
|
83
|
+
# the schema responses are fabricated against — the way to reach it from
|
|
84
|
+
# a graphql: :fake spec, where GraphWeaver.client is one of these
|
|
85
|
+
attr_reader :schema
|
|
86
|
+
|
|
87
|
+
# Every execute, in order: { query:, variables:, operation_name: }.
|
|
88
|
+
#
|
|
89
|
+
# expect(fake.requests.size).to eq 1 # memoized, then
|
|
90
|
+
# expect(fake.requests.last[:variables]).to eq({ "id" => "1" })
|
|
91
|
+
attr_reader :requests
|
|
92
|
+
|
|
93
|
+
def initialize(schema: nil, overrides: {}, seed: nil, mode: nil, list_size: nil, null_chance: nil,
|
|
57
94
|
errors: nil, fail_at: nil, corrupt: nil)
|
|
58
95
|
config = GraphWeaver::Testing.config
|
|
59
|
-
@schema = schema
|
|
96
|
+
@schema = schema || config.schema || raise(GraphWeaver::Error,
|
|
97
|
+
"no schema to fake against — set GraphWeaver::Testing.config.schema, pass schema:, " \
|
|
98
|
+
"or commit a schema dump at #{GraphWeaver.schema_path}")
|
|
60
99
|
@overrides = config.overrides.merge(overrides)
|
|
100
|
+
GraphWeaver::Testing.validate_overrides!(@schema, @overrides)
|
|
61
101
|
@values = GraphWeaver::Testing::Values.new(seed:, mode:)
|
|
62
102
|
@list_size = list_size || config.list_size
|
|
63
103
|
@null_chance = null_chance || config.null_chance
|
|
@@ -65,14 +105,32 @@ class GraphWeaver::Testing::FakeClient
|
|
|
65
105
|
@extra_errors = wrap(errors).map { |error| normalize_error(error) }
|
|
66
106
|
@fail_at = wrap(fail_at).map { |spec| normalize_fail_spec(spec) }
|
|
67
107
|
@corrupt = wrap(corrupt)
|
|
108
|
+
@requests = []
|
|
68
109
|
end
|
|
69
110
|
|
|
70
|
-
|
|
111
|
+
# operation_name: is accepted for contract parity and ignored — one
|
|
112
|
+
# document holds one operation, so there is nothing to select between
|
|
113
|
+
# (see Selection#load_operation).
|
|
114
|
+
def execute(query, variables: {}, operation_name: nil)
|
|
115
|
+
# recorded before validation: "we never called it" and "we called it with
|
|
116
|
+
# a query that doesn't compile" are different failures
|
|
117
|
+
@requests << { query:, variables:, operation_name: }.freeze
|
|
118
|
+
|
|
119
|
+
# Validate first, as the other clients in the slot do: a field the schema
|
|
120
|
+
# doesn't have would otherwise walk into `get_field(...).type` on nil, and
|
|
121
|
+
# a NoMethodError from inside the fabricator is undiagnosable next to the
|
|
122
|
+
# "Did you mean" a real server gives. This is the commonest mistake there
|
|
123
|
+
# is — a query drifting ahead of the dump, or a typo in an ad-hoc one.
|
|
124
|
+
invalid = @schema.validate(GraphQL.parse(query))
|
|
125
|
+
return { "data" => nil, "errors" => invalid.map(&:to_h) } if invalid.any?
|
|
126
|
+
|
|
71
127
|
operation = load_operation(query)
|
|
72
128
|
root_type = operation_root_type(operation)
|
|
73
129
|
|
|
74
130
|
@path = []
|
|
75
131
|
@failures = []
|
|
132
|
+
# fail_at fires once per execute, not once per client lifetime
|
|
133
|
+
@fail_at.each { |spec| spec.delete("triggered") }
|
|
76
134
|
data = object_value(root_type, operation.selections)
|
|
77
135
|
data = nil if data.equal?(NULL_BUBBLE) # total propagation, like a real server
|
|
78
136
|
|
|
@@ -82,6 +140,27 @@ class GraphWeaver::Testing::FakeClient
|
|
|
82
140
|
response
|
|
83
141
|
end
|
|
84
142
|
|
|
143
|
+
# One fabricated object of `type_name` for these selections — the seam
|
|
144
|
+
# {FakeSubgraph} answers a federation `_entities` fetch through, where the
|
|
145
|
+
# representation names the type and the document only ever reached it
|
|
146
|
+
# through an inline fragment.
|
|
147
|
+
def object(type_name, selections, fragments: {})
|
|
148
|
+
type = @schema.get_type(type_name) or
|
|
149
|
+
raise GraphWeaver::Error, "#{type_name} is not a type of this schema"
|
|
150
|
+
|
|
151
|
+
@fragments = fragments
|
|
152
|
+
@path = []
|
|
153
|
+
@failures = []
|
|
154
|
+
value = object_value(type, selections)
|
|
155
|
+
value.equal?(NULL_BUBBLE) ? nil : value
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The Selection mixin's walk is machinery, not interface: a developer who
|
|
159
|
+
# types `.methods` on a fake should find execute, schema, requests, object
|
|
160
|
+
# and parse, not the twelve steps behind them.
|
|
161
|
+
private :load_operation, :operation_root_type, :each_field, :gather,
|
|
162
|
+
:gather_conditional, :applies?, :conditional?
|
|
163
|
+
|
|
85
164
|
private
|
|
86
165
|
|
|
87
166
|
def rng = @values.rng
|
|
@@ -102,11 +181,24 @@ class GraphWeaver::Testing::FakeClient
|
|
|
102
181
|
spec.is_a?(String) ? { "path" => spec } : JSON.parse(JSON.generate(spec))
|
|
103
182
|
end
|
|
104
183
|
|
|
105
|
-
|
|
184
|
+
# pins: the response keys an override pinned at this object, merged in as
|
|
185
|
+
# the walk reaches them — everything it doesn't name is fabricated.
|
|
186
|
+
def object_value(type, selections, pins: nil, source: nil)
|
|
187
|
+
# gather (not each_field) so a field selected twice — `a { x } a { y }` —
|
|
188
|
+
# fabricates the MERGED shape codegen's struct expects, not last-writer-wins
|
|
189
|
+
fields = gather(type, selections)
|
|
190
|
+
check_pins!(fields, pins, source) if pins
|
|
191
|
+
|
|
106
192
|
result = {}
|
|
107
|
-
|
|
193
|
+
fields.each do |key, nodes|
|
|
194
|
+
node = nodes.first
|
|
195
|
+
pin = (pins && pins.key?(key)) ? pins[key] : UNPINNED
|
|
108
196
|
@path.push(key)
|
|
109
|
-
value = node.name == "__typename"
|
|
197
|
+
value = if node.name == "__typename"
|
|
198
|
+
type.graphql_name
|
|
199
|
+
else
|
|
200
|
+
field_value(type, node, nodes.flat_map(&:selections), pin, source)
|
|
201
|
+
end
|
|
110
202
|
@path.pop
|
|
111
203
|
|
|
112
204
|
if value.equal?(NULL_BUBBLE)
|
|
@@ -127,7 +219,7 @@ class GraphWeaver::Testing::FakeClient
|
|
|
127
219
|
@schema.get_field(type.graphql_name, node.name).type.kind.name == "NON_NULL"
|
|
128
220
|
end
|
|
129
221
|
|
|
130
|
-
def field_value(parent_type, node)
|
|
222
|
+
def field_value(parent_type, node, selections, pin = UNPINNED, source = nil)
|
|
131
223
|
if (spec = matching_failure)
|
|
132
224
|
@failures << {
|
|
133
225
|
"message" => spec["message"] || "simulated failure",
|
|
@@ -138,19 +230,92 @@ class GraphWeaver::Testing::FakeClient
|
|
|
138
230
|
return NULL_BUBBLE
|
|
139
231
|
end
|
|
140
232
|
|
|
141
|
-
|
|
142
|
-
|
|
233
|
+
coordinate = "#{parent_type.graphql_name}.#{node.name}"
|
|
234
|
+
if pin.equal?(UNPINNED)
|
|
235
|
+
# most specific key wins; #fetch (not #[]) so an override OF nil pins null
|
|
236
|
+
pin = @overrides.fetch(coordinate) { @overrides.fetch(node.name, UNPINNED) }
|
|
237
|
+
source = @overrides.key?(coordinate) ? coordinate : node.name unless pin.equal?(UNPINNED)
|
|
143
238
|
end
|
|
144
|
-
return override.is_a?(Proc) ? override.call : override unless override.nil?
|
|
145
239
|
|
|
146
240
|
field_type = @schema.get_field(parent_type.graphql_name, node.name).type
|
|
147
|
-
|
|
148
|
-
|
|
241
|
+
return pinned_value(field_type, node, selections, pin, source) unless pin.equal?(UNPINNED)
|
|
242
|
+
return corrupt_value(field_type) if @corrupt.include?(coordinate)
|
|
243
|
+
|
|
244
|
+
type_value(field_type, node, selections)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# What an override pins here. A leaf takes the value outright; a composite
|
|
248
|
+
# MERGES — the keys it names are pinned and the rest of the selection is
|
|
249
|
+
# fabricated, so pinning one nested field never means hand-writing the
|
|
250
|
+
# subtree around it. A pinned list is exactly as long as it is written.
|
|
251
|
+
def pinned_value(type, node, selections, value, source)
|
|
252
|
+
value = value.call if value.is_a?(Proc)
|
|
253
|
+
|
|
254
|
+
case type.kind.name
|
|
255
|
+
when "NON_NULL" then pinned_value(type.of_type, node, selections, value, source)
|
|
256
|
+
when "LIST"
|
|
257
|
+
return value unless value.is_a?(Array)
|
|
258
|
+
|
|
259
|
+
value.each_with_index.map do |element, index|
|
|
260
|
+
@path.push(index)
|
|
261
|
+
begin
|
|
262
|
+
pinned_value(type.of_type, node, selections, element, source)
|
|
263
|
+
ensure
|
|
264
|
+
@path.pop
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
when "OBJECT", "UNION", "INTERFACE"
|
|
268
|
+
return value unless value.is_a?(Hash)
|
|
269
|
+
|
|
270
|
+
object_value(pinned_type(type, value, source), selections, pins: value, source:)
|
|
271
|
+
else
|
|
272
|
+
value
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The concrete type a pinned object is fabricated as. At a union or
|
|
277
|
+
# interface the pin has to say: picking a member at random would fabricate
|
|
278
|
+
# a shape the pinned keys don't fit, in whichever fraction of runs the
|
|
279
|
+
# seed lands there.
|
|
280
|
+
def pinned_type(type, value, source)
|
|
281
|
+
named = value["__typename"]
|
|
282
|
+
return type if named == type.graphql_name
|
|
283
|
+
|
|
284
|
+
members = (type.kind.name == "OBJECT") ? [type] : @schema.possible_types(type)
|
|
285
|
+
if named.nil?
|
|
286
|
+
return type if members.one?
|
|
287
|
+
|
|
288
|
+
raise GraphWeaver::Error, "override #{source.inspect} pins an object at #{location}, where " \
|
|
289
|
+
"the query can return #{members.map(&:graphql_name).sort.join(" or ")} — name the one you " \
|
|
290
|
+
"mean with \"__typename\"."
|
|
149
291
|
end
|
|
150
292
|
|
|
151
|
-
|
|
293
|
+
found = members.find { |member| member.graphql_name == named }
|
|
294
|
+
return found if found
|
|
295
|
+
|
|
296
|
+
raise GraphWeaver::Error, "override #{source.inspect} pins __typename #{named.inspect} at " \
|
|
297
|
+
"#{location}, where the query can only return #{members.map(&:graphql_name).sort.join(" or ")}"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# A pinned key the query doesn't select would fabricate the field anyway
|
|
301
|
+
# and quietly leave the pin unread — the same silent-green failure a typo'd
|
|
302
|
+
# coordinate is validated against, one level down.
|
|
303
|
+
def check_pins!(fields, pins, source)
|
|
304
|
+
# __typename in a pin names the type to fabricate (see #pinned_type); it
|
|
305
|
+
# is an instruction, not a field the query has to have selected
|
|
306
|
+
unknown = pins.keys.reject { |key| key == "__typename" || fields.key?(key) }
|
|
307
|
+
return if unknown.empty?
|
|
308
|
+
|
|
309
|
+
suggestion = GraphWeaver.did_you_mean(fields.keys, unknown.first.to_s)
|
|
310
|
+
hint = suggestion ? " — did you mean #{suggestion.inspect}?" : "."
|
|
311
|
+
raise GraphWeaver::Error, "override #{source.inspect} supplies #{unknown.first.inspect} at " \
|
|
312
|
+
"#{location}, which this query doesn't select#{hint} An override's keys are response keys, " \
|
|
313
|
+
"exactly as they arrive on the wire (#{fields.keys.join(", ")})."
|
|
152
314
|
end
|
|
153
315
|
|
|
316
|
+
# where the walk is, for a message: "reader.orders.0"
|
|
317
|
+
def location = @path.empty? ? "the root" : @path.join(".")
|
|
318
|
+
|
|
154
319
|
# a value casting can't accept, derived from the field's own type — and
|
|
155
320
|
# wrapped per list layer so the corruption lands on the element cast
|
|
156
321
|
def corrupt_value(type)
|
|
@@ -181,17 +346,18 @@ class GraphWeaver::Testing::FakeClient
|
|
|
181
346
|
argument = node.arguments.find { |arg| %w[first last limit].include?(arg.name) }
|
|
182
347
|
return argument.value if argument && argument.value.is_a?(Integer)
|
|
183
348
|
|
|
184
|
-
|
|
349
|
+
# an Integer list_size means exactly that many; a Range randomizes within it
|
|
350
|
+
@list_size.is_a?(Range) ? rng.rand(@list_size) : @list_size
|
|
185
351
|
end
|
|
186
352
|
|
|
187
|
-
def type_value(type, node, non_null: false)
|
|
353
|
+
def type_value(type, node, selections, non_null: false)
|
|
188
354
|
case type.kind.name
|
|
189
355
|
when "NON_NULL"
|
|
190
|
-
type_value(type.of_type, node, non_null: true)
|
|
356
|
+
type_value(type.of_type, node, selections, non_null: true)
|
|
191
357
|
when "LIST"
|
|
192
358
|
elements = Array.new(list_length(node)) do |index|
|
|
193
359
|
@path.push(index)
|
|
194
|
-
element = type_value(type.of_type, node)
|
|
360
|
+
element = type_value(type.of_type, node, selections)
|
|
195
361
|
@path.pop
|
|
196
362
|
element
|
|
197
363
|
end
|
|
@@ -206,21 +372,21 @@ class GraphWeaver::Testing::FakeClient
|
|
|
206
372
|
else
|
|
207
373
|
return if !non_null && rng.rand < @null_chance
|
|
208
374
|
|
|
209
|
-
core_value(type, node)
|
|
375
|
+
core_value(type, node, selections)
|
|
210
376
|
end
|
|
211
377
|
end
|
|
212
378
|
|
|
213
|
-
def core_value(type, node)
|
|
379
|
+
def core_value(type, node, selections)
|
|
214
380
|
case type.kind.name
|
|
215
381
|
when "SCALAR"
|
|
216
382
|
@values.scalar(type.graphql_name, node.name)
|
|
217
383
|
when "ENUM"
|
|
218
384
|
type.values.keys.sort.sample(random: rng)
|
|
219
385
|
when "OBJECT"
|
|
220
|
-
object_value(type,
|
|
386
|
+
object_value(type, selections)
|
|
221
387
|
when "UNION", "INTERFACE"
|
|
222
388
|
member = @schema.possible_types(type).sort_by(&:graphql_name).sample(random: rng)
|
|
223
|
-
object_value(member,
|
|
389
|
+
object_value(member, selections)
|
|
224
390
|
else
|
|
225
391
|
raise NotImplementedError, "cannot fake kind: #{type.kind.name}"
|
|
226
392
|
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "graphql"
|
|
5
|
+
|
|
6
|
+
require_relative "fake_client"
|
|
7
|
+
|
|
8
|
+
module GraphWeaver
|
|
9
|
+
module Testing
|
|
10
|
+
# A subgraph this process doesn't serve, answered with fabricated data.
|
|
11
|
+
#
|
|
12
|
+
# Composed into the supergraph but routed to another service, a subgraph
|
|
13
|
+
# has no schema class here, so by default {Router} refuses any query that
|
|
14
|
+
# reaches its fields. `subgraphs: { "reviews" => :fake }` swaps that
|
|
15
|
+
# refusal for schema-correct fabricated data — mid-migration that's often
|
|
16
|
+
# what you want: the fields exist, the shapes are right, and nothing has
|
|
17
|
+
# to be stood up to exercise the rest of the graph.
|
|
18
|
+
#
|
|
19
|
+
# It satisfies the same contract every other subgraph does, including the
|
|
20
|
+
# `_entities(representations:)` fetch a boundary crossing sends. Each
|
|
21
|
+
# representation names its own `__typename`, so the entity comes back as
|
|
22
|
+
# that type — a `_Entity` union faked the ordinary way would answer as a
|
|
23
|
+
# random member and match nothing the query asked for.
|
|
24
|
+
#
|
|
25
|
+
# Fabricated data is invented data, and a green test against invented
|
|
26
|
+
# data is worse than a red one — so {Router} marks every fetch that came
|
|
27
|
+
# from here (`faked: true` in #trace) and logs it at :warn.
|
|
28
|
+
class FakeSubgraph
|
|
29
|
+
# the subgraph being faked, as the supergraph names it
|
|
30
|
+
attr_reader :name
|
|
31
|
+
|
|
32
|
+
# the schema responses are fabricated against — the composed API
|
|
33
|
+
# schema, which carries every type this subgraph can be asked for
|
|
34
|
+
attr_reader :schema
|
|
35
|
+
|
|
36
|
+
def initialize(name, schema, **options)
|
|
37
|
+
@name = name
|
|
38
|
+
@schema = schema
|
|
39
|
+
@client = FakeClient.new(schema:, **options)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def inspect = "#<#{self.class.name} #{@name.inspect}>"
|
|
43
|
+
alias to_s inspect
|
|
44
|
+
|
|
45
|
+
# context: is accepted for contract parity and ignored — there are no
|
|
46
|
+
# resolvers here to receive one.
|
|
47
|
+
def execute(query, variables: {}, operation_name: nil, context: nil)
|
|
48
|
+
document = GraphQL.parse(query)
|
|
49
|
+
entities = entities_field(document)
|
|
50
|
+
return @client.execute(query, variables:, operation_name:) unless entities
|
|
51
|
+
|
|
52
|
+
{ "data" => { "_entities" => entities_value(entities, document, variables) } }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# the router sends _entities as the operation's only root field
|
|
58
|
+
def entities_field(document)
|
|
59
|
+
operation = document.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).first
|
|
60
|
+
operation&.selections&.find do |node|
|
|
61
|
+
node.is_a?(GraphQL::Language::Nodes::Field) && node.name == "_entities"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# one object per representation, in order and as the type it names —
|
|
66
|
+
# which is the contract _entities answers on
|
|
67
|
+
def entities_value(field, document, variables)
|
|
68
|
+
fragments = document.definitions
|
|
69
|
+
.grep(GraphQL::Language::Nodes::FragmentDefinition).to_h { |node| [node.name, node] }
|
|
70
|
+
|
|
71
|
+
representations(variables).map do |representation|
|
|
72
|
+
type_name = representation["__typename"] or raise GraphWeaver::Error,
|
|
73
|
+
"a representation sent to #{@name} carries no __typename: #{representation.inspect}"
|
|
74
|
+
|
|
75
|
+
@client.object(type_name, field.selections, fragments:)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def representations(variables)
|
|
80
|
+
found = variables.to_h.transform_keys(&:to_s)["representations"]
|
|
81
|
+
(found || []).map { |representation| representation.to_h.transform_keys(&:to_s) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|