graph_weaver 0.7.4 → 0.7.5
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/Gemfile.lock +2 -2
- data/README.md +1 -0
- data/docs/errors.md +5 -2
- data/docs/generated_modules.md +127 -14
- data/docs/getting_started.md +159 -14
- data/docs/migrating.md +119 -0
- data/docs/scalars.md +39 -4
- data/docs/testing.md +3 -1
- data/docs/upgrading.md +21 -1
- data/lib/generators/graph_weaver/install_generator.rb +32 -3
- data/lib/graph_weaver/codegen/aliases.rb +23 -2
- data/lib/graph_weaver/codegen/emit.rb +13 -9
- data/lib/graph_weaver/codegen/enum_type.rb +26 -9
- data/lib/graph_weaver/codegen/nodes.rb +55 -31
- data/lib/graph_weaver/codegen.rb +198 -90
- data/lib/graph_weaver/coerce.rb +1 -1
- data/lib/graph_weaver/federation.rb +1 -6
- data/lib/graph_weaver/hints.rb +20 -5
- data/lib/graph_weaver/in_process.rb +1 -3
- data/lib/graph_weaver/input_struct.rb +21 -6
- data/lib/graph_weaver/internal/subgraphs.rb +1 -10
- data/lib/graph_weaver/internal/unused.rb +32 -7
- data/lib/graph_weaver/internal/values.rb +7 -2
- data/lib/graph_weaver/internal.rb +17 -0
- data/lib/graph_weaver/logging.rb +26 -29
- data/lib/graph_weaver/query_module.rb +20 -5
- data/lib/graph_weaver/railtie.rb +7 -2
- data/lib/graph_weaver/rspec.rb +0 -1
- data/lib/graph_weaver/schema_loader.rb +7 -8
- data/lib/graph_weaver/tasks.rb +60 -5
- data/lib/graph_weaver/testing/fake_client.rb +4 -10
- data/lib/graph_weaver/testing/router.rb +26 -25
- data/lib/graph_weaver/testing.rb +1 -2
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +13 -7
- metadata +3 -2
|
@@ -57,6 +57,11 @@ class GraphWeaver::Internal::Values
|
|
|
57
57
|
# registration says the server writes "12.5".
|
|
58
58
|
WIRE = [NilClass, TrueClass, FalseClass, Integer, Float, String, Symbol, Array, Hash].freeze
|
|
59
59
|
|
|
60
|
+
# Whether a value is already one of those. At a leaf it means the registry's
|
|
61
|
+
# serializer has nothing to do; at a composite position (FakeClient) it means
|
|
62
|
+
# the pin stands as written.
|
|
63
|
+
def self.wire?(value) = WIRE.any? { |klass| value.is_a?(klass) }
|
|
64
|
+
|
|
60
65
|
# The fallback, for a scalar nobody registered: its prop is T.untyped, so
|
|
61
66
|
# anything holds and a plausible shape beats a placeholder.
|
|
62
67
|
NAMED_SHAPES = {
|
|
@@ -146,10 +151,10 @@ class GraphWeaver::Internal::Values
|
|
|
146
151
|
# as written. Shared with the object-pin door, so both read a pin the same
|
|
147
152
|
# way.
|
|
148
153
|
def wire(type_name, value, coordinate = nil)
|
|
149
|
-
return value if
|
|
154
|
+
return value if self.class.wire?(value)
|
|
150
155
|
|
|
151
156
|
serialized = @registry.scalar(type_name, coordinate).serialize_value(value)
|
|
152
|
-
return serialized if
|
|
157
|
+
return serialized if self.class.wire?(serialized)
|
|
153
158
|
|
|
154
159
|
article = GraphWeaver::Internal::Util.article(value.class.to_s)
|
|
155
160
|
raise GraphWeaver::Error, "the pin for #{type_name.inspect} is #{article} #{value.class}, and a pin " \
|
|
@@ -18,6 +18,11 @@ module GraphWeaver
|
|
|
18
18
|
# lexical scope, so a private constant would be unreachable from exactly
|
|
19
19
|
# the files that need it. The name and the surface lock carry the rule.
|
|
20
20
|
module Internal
|
|
21
|
+
# The wire value the member register_enum fallback: true adds to a
|
|
22
|
+
# generated enum serializes to. The GraphQL spec reserves a leading `__`,
|
|
23
|
+
# so no schema can declare a value that collides with it.
|
|
24
|
+
ENUM_FALLBACK_WIRE = "__other__"
|
|
25
|
+
|
|
21
26
|
# Odds and ends several files share. Each is here because more than one
|
|
22
27
|
# caller needs it, not because it belongs together with the others.
|
|
23
28
|
module Util
|
|
@@ -50,6 +55,18 @@ module GraphWeaver
|
|
|
50
55
|
# "a" or "an" for a word an error message is about to name.
|
|
51
56
|
def article(word) = word.downcase.start_with?(/[aeiou]/) ? "an" : "a"
|
|
52
57
|
|
|
58
|
+
# how many entries a message names before it says "and N more"
|
|
59
|
+
SAMPLE = 5
|
|
60
|
+
private_constant :SAMPLE
|
|
61
|
+
|
|
62
|
+
# A list a message names inline, held to a readable length — a wall
|
|
63
|
+
# of schema coordinates says less than the first few and a count.
|
|
64
|
+
def sample(list)
|
|
65
|
+
return list.join(", ") if list.size <= SAMPLE
|
|
66
|
+
|
|
67
|
+
"#{list.first(SAMPLE).join(", ")} and #{list.size - SAMPLE} more"
|
|
68
|
+
end
|
|
69
|
+
|
|
53
70
|
# The module a .graphql file generates, and the basename of the file
|
|
54
71
|
# it generates into: the camelized file name plus the operation's own
|
|
55
72
|
# word. Every run of non-alphanumerics in the name is a word boundary,
|
data/lib/graph_weaver/logging.rb
CHANGED
|
@@ -101,7 +101,11 @@ module GraphWeaver
|
|
|
101
101
|
# about the key the value arrived under, so it reads a filtered key one
|
|
102
102
|
# level in as safe; this scrubs at every depth, like #value. The key is
|
|
103
103
|
# optional because a coercer refusing a value hasn't been told one.
|
|
104
|
-
def shown(raw, key = nil) = filtered?(key) ? FILTERED : cap(value(key, raw)
|
|
104
|
+
def shown(raw, key = nil) = filtered?(key) ? FILTERED : cap(spell(value(key, raw)))
|
|
105
|
+
|
|
106
|
+
# How a value reads inside a sentence. inspect, except that
|
|
107
|
+
# BigDecimal#inspect is scientific ("0.25e1" for the 2.5 a caller wrote).
|
|
108
|
+
def spell(value) = defined?(BigDecimal) && value.is_a?(BigDecimal) ? value.to_s("F") : value.inspect
|
|
105
109
|
|
|
106
110
|
# A short server-chosen string the library republishes inside its own
|
|
107
111
|
# text — the APM's :code, the [CODE] in the one line info writes, a
|
|
@@ -215,36 +219,13 @@ module GraphWeaver
|
|
|
215
219
|
end
|
|
216
220
|
|
|
217
221
|
# What a Retry has already spent, read by the attempt it is about
|
|
218
|
-
# to make.
|
|
219
|
-
|
|
220
|
-
# that never reaches instrument can't leave a stale one behind.
|
|
221
|
-
def with_retries(count)
|
|
222
|
-
return yield unless GraphWeaver.instrumenter
|
|
223
|
-
|
|
224
|
-
previous = Thread.current[RETRIES]
|
|
225
|
-
Thread.current[RETRIES] = count
|
|
226
|
-
begin
|
|
227
|
-
yield
|
|
228
|
-
ensure
|
|
229
|
-
Thread.current[RETRIES] = previous
|
|
230
|
-
end
|
|
231
|
-
end
|
|
222
|
+
# to make.
|
|
223
|
+
def with_retries(count, &block) = during(RETRIES, count, &block)
|
|
232
224
|
|
|
233
225
|
# The graph a generated module is dispatching, read by the request it
|
|
234
|
-
# is about to make
|
|
235
|
-
#
|
|
236
|
-
|
|
237
|
-
def with_graph(name)
|
|
238
|
-
return yield unless GraphWeaver.instrumenter
|
|
239
|
-
|
|
240
|
-
previous = Thread.current[GRAPH]
|
|
241
|
-
Thread.current[GRAPH] = name
|
|
242
|
-
begin
|
|
243
|
-
yield
|
|
244
|
-
ensure
|
|
245
|
-
Thread.current[GRAPH] = previous
|
|
246
|
-
end
|
|
247
|
-
end
|
|
226
|
+
# is about to make — and instrument clears it for the duration of the
|
|
227
|
+
# request it labels, so exactly one request wears the label.
|
|
228
|
+
def with_graph(name, &block) = during(GRAPH, name, &block)
|
|
248
229
|
|
|
249
230
|
# The variables as one JSON line for a log: filtered, and unable to
|
|
250
231
|
# raise. A value with no JSON form (NaN, binary) is the caller's bug
|
|
@@ -278,6 +259,22 @@ module GraphWeaver
|
|
|
278
259
|
|
|
279
260
|
private
|
|
280
261
|
|
|
262
|
+
# One fiber-local, set for the length of one call. A dynamic extent
|
|
263
|
+
# rather than a global: the value is only visible while the call it
|
|
264
|
+
# describes is on the stack, so a client that never reaches instrument
|
|
265
|
+
# can't leave a stale one behind.
|
|
266
|
+
def during(key, value)
|
|
267
|
+
return yield unless GraphWeaver.instrumenter
|
|
268
|
+
|
|
269
|
+
previous = Thread.current[key]
|
|
270
|
+
Thread.current[key] = value
|
|
271
|
+
begin
|
|
272
|
+
yield
|
|
273
|
+
ensure
|
|
274
|
+
Thread.current[key] = previous
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
281
278
|
# The GraphQL errors a response carries, whatever answered it — a
|
|
282
279
|
# Hash from a transport, a graphql-ruby Result in-process, a fake.
|
|
283
280
|
# Never raises: an instrumenter that decides which exception a
|
|
@@ -7,8 +7,6 @@ require_relative "internal"
|
|
|
7
7
|
require_relative "internal/test_clients"
|
|
8
8
|
|
|
9
9
|
module GraphWeaver
|
|
10
|
-
# Called by generated code — not semver'd for direct use.
|
|
11
|
-
#
|
|
12
10
|
# Runtime for generated query modules: the client plumbing, which is the
|
|
13
11
|
# one part of a generated module that carries no per-query type
|
|
14
12
|
# information — every module's copy was identical. `extend
|
|
@@ -16,6 +14,13 @@ module GraphWeaver
|
|
|
16
14
|
# stay generated, since their sigs are the query's types and those are the
|
|
17
15
|
# point.
|
|
18
16
|
#
|
|
17
|
+
# It is also the type every generated module satisfies, so code that takes
|
|
18
|
+
# any of them says `GraphWeaver::QueryModule` and reads `query_string` /
|
|
19
|
+
# `operation_name` with a sig behind each — rather than `const_get(:QUERY)`
|
|
20
|
+
# on a Module, which is what rubocop-sorbet forbids (ConstantsFromStrings,
|
|
21
|
+
# and ForbidTUnsafe for the T.unsafe that gets around it). Those readers
|
|
22
|
+
# and `client` are the supported surface; the rest is generated code's.
|
|
23
|
+
#
|
|
19
24
|
# Resolution order, per the docs: per call → a test mode's stand-in
|
|
20
25
|
# (Internal::TestClients) → the client the module's graph names →
|
|
21
26
|
# `GraphWeaver.client`. A module has no fifth slot you can set: a parsed
|
|
@@ -32,6 +37,18 @@ module GraphWeaver
|
|
|
32
37
|
@client || default_client
|
|
33
38
|
end
|
|
34
39
|
|
|
40
|
+
# The operation, verbatim — what goes on the wire as `query`.
|
|
41
|
+
sig { returns(String) }
|
|
42
|
+
def query_string
|
|
43
|
+
T.unsafe(self).const_get(:QUERY)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# What goes on the wire as `operationName`; nil for an anonymous operation.
|
|
47
|
+
sig { returns(T.nilable(String)) }
|
|
48
|
+
def operation_name
|
|
49
|
+
T.unsafe(self).const_get(:OPERATION_NAME)
|
|
50
|
+
end
|
|
51
|
+
|
|
35
52
|
private
|
|
36
53
|
|
|
37
54
|
# Bound by GraphWeaver.parse, which is the only caller: a parsed module
|
|
@@ -59,12 +76,10 @@ module GraphWeaver
|
|
|
59
76
|
# comes through here.)
|
|
60
77
|
GraphWeaver::Internal::Wire.check_variables!(variables)
|
|
61
78
|
|
|
62
|
-
mod = T.unsafe(self)
|
|
63
79
|
# the graph codegen baked in, never one inferred from the client — a
|
|
64
80
|
# wrong label on a request is worse than no label
|
|
65
81
|
GraphWeaver::Internal::Log.with_graph(graph_name) do
|
|
66
|
-
client_for(client).execute(
|
|
67
|
-
operation_name: mod.const_get(:OPERATION_NAME))
|
|
82
|
+
client_for(client).execute(query_string, variables:, operation_name:)
|
|
68
83
|
end
|
|
69
84
|
end
|
|
70
85
|
|
data/lib/graph_weaver/railtie.rb
CHANGED
|
@@ -35,7 +35,7 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
35
35
|
KEYS = %i[watch].freeze
|
|
36
36
|
|
|
37
37
|
def method_missing(name, *args)
|
|
38
|
-
key = name
|
|
38
|
+
key = setting(name)
|
|
39
39
|
return super if KEYS.include?(key)
|
|
40
40
|
|
|
41
41
|
raise ArgumentError, refusal(key)
|
|
@@ -53,11 +53,16 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
53
53
|
alias_method :store, :[]=
|
|
54
54
|
|
|
55
55
|
def respond_to_missing?(name, _private = false)
|
|
56
|
-
KEYS.include?(name
|
|
56
|
+
KEYS.include?(setting(name))
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
private
|
|
60
60
|
|
|
61
|
+
# the setting a reader, writer or predicate is about
|
|
62
|
+
def setting(name)
|
|
63
|
+
name.to_s.delete_suffix("=").delete_suffix("?").delete_suffix("!").to_sym
|
|
64
|
+
end
|
|
65
|
+
|
|
61
66
|
def refusal(key)
|
|
62
67
|
near = GraphWeaver::Internal::Util.did_you_mean(KEYS.map(&:to_s), key.to_s)
|
|
63
68
|
fix =
|
data/lib/graph_weaver/rspec.rb
CHANGED
|
@@ -887,9 +887,10 @@ module GraphWeaver::SchemaLoader
|
|
|
887
887
|
return recompose_hint(path) if composed_dump?(path)
|
|
888
888
|
|
|
889
889
|
missing = path ? "#{path} records no source url" : "no schema dump at #{GraphWeaver.schema_path}"
|
|
890
|
-
"#{missing} — pass one: rake graph_weaver:schema:refresh
|
|
891
|
-
"
|
|
892
|
-
"
|
|
890
|
+
"#{missing} — pass one: rake graph_weaver:schema:refresh " \
|
|
891
|
+
"URL=https://api.example.com/graphql, or point the graph's client at the server — an app " \
|
|
892
|
+
"that serves the schema itself points GraphWeaver.client at the class and the dump is built " \
|
|
893
|
+
"from that (docs/getting_started.md#your-apps-own-schema-in-process)"
|
|
893
894
|
end
|
|
894
895
|
private_class_method :refresh_hint
|
|
895
896
|
|
|
@@ -922,11 +923,9 @@ module GraphWeaver::SchemaLoader
|
|
|
922
923
|
# ends up honoured in some places and not others.
|
|
923
924
|
def self.source_transport(path)
|
|
924
925
|
meta = provenance(path)
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
"or rebuild it from the schema class that produced it."
|
|
929
|
-
end
|
|
926
|
+
# the same sentence refresh! gives: this is reached by typing a rake task
|
|
927
|
+
# (schema:diff), and `transport:` is not something a rake user can pass
|
|
928
|
+
raise GraphWeaver::Error, refresh_hint(path) unless meta&.key?("url")
|
|
930
929
|
|
|
931
930
|
GraphWeaver.new(meta["url"], auth: ENV[auth_env(path)]).transport
|
|
932
931
|
end
|
data/lib/graph_weaver/tasks.rb
CHANGED
|
@@ -170,6 +170,30 @@ module GraphWeaver
|
|
|
170
170
|
}.filter_map { |kind, names| " #{kind}: #{names.sort.join(", ")}" if names.any? }
|
|
171
171
|
end
|
|
172
172
|
|
|
173
|
+
# What `queries:check` just answered. It re-introspects the url a dump
|
|
174
|
+
# records and asks a live class directly; anything else it checks as it
|
|
175
|
+
# stands on disk, which is `verify`'s question — and both exited 0
|
|
176
|
+
# saying "against the schema".
|
|
177
|
+
def self.validated
|
|
178
|
+
dumps = GraphWeaver.graphs.filter_map { |graph| as_committed(graph) }
|
|
179
|
+
return "every query validates against the schema" if dumps.empty?
|
|
180
|
+
|
|
181
|
+
"every query validates against #{dumps.join(", ")} as committed — not the server " \
|
|
182
|
+
"(rake graph_weaver:schema:diff asks whether the server moved)"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# A graph whose queries were checked against the file rather than the
|
|
186
|
+
# server: a graph that names its own schema is checked against exactly
|
|
187
|
+
# that, and a dump with no recorded url has nothing to re-read.
|
|
188
|
+
def self.as_committed(graph)
|
|
189
|
+
path = graph.dump_path
|
|
190
|
+
return unless path && graph.live_schema.nil?
|
|
191
|
+
return if !graph.named_schema? && GraphWeaver::SchemaLoader.provenance(path)&.key?("url")
|
|
192
|
+
|
|
193
|
+
GraphWeaver::Internal::Util.relative(path)
|
|
194
|
+
end
|
|
195
|
+
private_class_method :as_committed
|
|
196
|
+
|
|
173
197
|
# Neither task that needs the committed dump can take one itself, so both
|
|
174
198
|
# say which task can — the same sentence SchemaLoader gives on refresh.
|
|
175
199
|
def self.no_dump
|
|
@@ -184,7 +208,23 @@ module GraphWeaver
|
|
|
184
208
|
# rewrites it from the graph's source, `diff` says how far that source
|
|
185
209
|
# has drifted from it, whichever the source is. A graph whose schema IS
|
|
186
210
|
# a live class reads no dump at all, so it has neither.
|
|
187
|
-
|
|
211
|
+
#
|
|
212
|
+
# The dump's own record of where it came from first, then the client the
|
|
213
|
+
# graph's modules already call: a dump inherited with no provenance, or a
|
|
214
|
+
# schema kept by hand, still has a server behind it, and refusing one
|
|
215
|
+
# took every other graph's refresh down with it.
|
|
216
|
+
def self.dumps
|
|
217
|
+
GraphWeaver.graphs.map { |graph| [graph, graph.dump_path, graph.dump_source || graph.client_url] }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A dump with no recorded url whose graph names no client: nothing
|
|
221
|
+
# behind the file to re-read, so the file is the schema. Said and
|
|
222
|
+
# stepped over rather than refused — the task's job is the graphs it
|
|
223
|
+
# CAN refresh.
|
|
224
|
+
def self.no_source(path)
|
|
225
|
+
"#{GraphWeaver::Internal::Util.relative(path)} records no source url and the graph names " \
|
|
226
|
+
"no client — left as checked in"
|
|
227
|
+
end
|
|
188
228
|
|
|
189
229
|
# A graph that generates straight from a schema class has no dump
|
|
190
230
|
# between the code and the output — so there is nothing here to
|
|
@@ -202,6 +242,19 @@ module GraphWeaver
|
|
|
202
242
|
"#{whose} generates from #{source_name(source)} directly — no dump to keep in step"
|
|
203
243
|
end
|
|
204
244
|
|
|
245
|
+
# What `diff` re-introspects for one graph. A schema class answers
|
|
246
|
+
# introspection itself. A url the dump recorded is left to
|
|
247
|
+
# SchemaLoader.diff, which builds the transport and so honours the
|
|
248
|
+
# auth_env the dump named. A source that came from the graph's client
|
|
249
|
+
# instead is that client's own transport — headers, auth and all.
|
|
250
|
+
def self.diff_transport(graph, source)
|
|
251
|
+
return source if source.is_a?(Module)
|
|
252
|
+
return if graph.dump_source
|
|
253
|
+
|
|
254
|
+
client = graph.client || GraphWeaver.client
|
|
255
|
+
client.respond_to?(:transport) ? client.transport : client
|
|
256
|
+
end
|
|
257
|
+
|
|
205
258
|
# How a dump's source reads in a report: a url as itself, a schema
|
|
206
259
|
# class by name.
|
|
207
260
|
def self.source_name(source) = source.is_a?(Module) ? GraphWeaver::SchemaLoader.endpoint(source) : source
|
|
@@ -350,9 +403,8 @@ namespace :graph_weaver do
|
|
|
350
403
|
# dump between the code and the output, so nothing can be stale
|
|
351
404
|
next puts GraphWeaver::Internal::Tasks.no_dump_needed(graph, source) unless path
|
|
352
405
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
diff = GraphWeaver::SchemaLoader.diff(path, transport: (source if source.is_a?(Module)))
|
|
406
|
+
diff = GraphWeaver::SchemaLoader.diff(path,
|
|
407
|
+
transport: GraphWeaver::Internal::Tasks.diff_transport(graph, source))
|
|
356
408
|
dump = GraphWeaver::Internal::Util.relative(path)
|
|
357
409
|
next puts "#{dump} matches #{GraphWeaver::Internal::Tasks.source_name(source)}" if diff.empty?
|
|
358
410
|
|
|
@@ -398,6 +450,9 @@ namespace :graph_weaver do
|
|
|
398
450
|
# below and refresh! bootstraps its first dump (or says how).
|
|
399
451
|
path ||= graph.named_dump_path
|
|
400
452
|
next puts GraphWeaver::Internal::Tasks.no_dump_needed(graph, source) if !path && graph.named_schema?
|
|
453
|
+
# a supergraph has no source by construction — nothing serves one —
|
|
454
|
+
# and refresh! answers that with "recompose", which is not a skip
|
|
455
|
+
next puts GraphWeaver::Internal::Tasks.no_source(path) if path && !source && !graph.supergraph
|
|
401
456
|
|
|
402
457
|
written, from = GraphWeaver::SchemaLoader.refresh!(url: (source unless source.is_a?(Module)),
|
|
403
458
|
schema: (source if source.is_a?(Module)), path:)
|
|
@@ -425,7 +480,7 @@ namespace :graph_weaver do
|
|
|
425
480
|
# block-buffered stdout, so a piped CI log shows the verdict first
|
|
426
481
|
$stdout.flush
|
|
427
482
|
abort "#{failures.size} invalid #{(failures.size == 1) ? "query" : "queries"}" if failures.any?
|
|
428
|
-
puts
|
|
483
|
+
puts GraphWeaver::Internal::Tasks.validated
|
|
429
484
|
end
|
|
430
485
|
end
|
|
431
486
|
|
|
@@ -137,12 +137,6 @@ class GraphWeaver::Testing::FakeClient
|
|
|
137
137
|
null_chance: nil, errors: nil, fail_at: nil, corrupt: nil,
|
|
138
138
|
}.freeze
|
|
139
139
|
|
|
140
|
-
# JSON's own types are already on the wire: at a leaf they skip the
|
|
141
|
-
# registry's serializer (Values#wire), and at a composite position (a Hash
|
|
142
|
-
# aside, which is response keys) they pin the field as written — nil is
|
|
143
|
-
# null, the rest is the corrupt payload the example asked for.
|
|
144
|
-
WIRE = GraphWeaver::Internal::Values::WIRE
|
|
145
|
-
|
|
146
140
|
# Methods every Ruby object answers aren't fields: a schema does have a
|
|
147
141
|
# `hash` or a `count`, and a Struct answers both with plausible nonsense
|
|
148
142
|
# where fabricating is right.
|
|
@@ -151,7 +145,7 @@ class GraphWeaver::Testing::FakeClient
|
|
|
151
145
|
# The scalars the GraphQL spec serializes as JSON strings, whatever Ruby
|
|
152
146
|
# holds them.
|
|
153
147
|
STRING_SCALARS = %w[ID String].freeze
|
|
154
|
-
private_constant :OPTIONS, :
|
|
148
|
+
private_constant :OPTIONS, :RUBY_OWN, :STRING_SCALARS
|
|
155
149
|
|
|
156
150
|
def initialize(pins = {}, **options)
|
|
157
151
|
config = GraphWeaver::Testing.config
|
|
@@ -467,7 +461,9 @@ class GraphWeaver::Testing::FakeClient
|
|
|
467
461
|
# reads them off — a FactoryBot build, a model, a Struct. Either way it
|
|
468
462
|
# MERGES: what it doesn't answer is fabricated.
|
|
469
463
|
def pinned_object(type, selections, value, source)
|
|
470
|
-
|
|
464
|
+
# what JSON already holds is the pin as written — nil is null, and the rest
|
|
465
|
+
# is the corrupt payload the example asked for (a Hash is response keys)
|
|
466
|
+
return value if !value.is_a?(Hash) && GraphWeaver::Internal::Values.wire?(value)
|
|
471
467
|
|
|
472
468
|
concrete = pinned_type(type, value, source)
|
|
473
469
|
pins = value.is_a?(Hash) ? value : read_fields(concrete, selections, value)
|
|
@@ -497,8 +493,6 @@ class GraphWeaver::Testing::FakeClient
|
|
|
497
493
|
object.respond_to?(name) && !RUBY_OWN.include?(object.method(name).owner)
|
|
498
494
|
end
|
|
499
495
|
|
|
500
|
-
def wire?(value) = WIRE.any? { |klass| value.is_a?(klass) }
|
|
501
|
-
|
|
502
496
|
# An object pin holds Ruby values — a Time, a Money, a T::Enum — where the
|
|
503
497
|
# wire holds what the registration says they serialize to. A value that
|
|
504
498
|
# is already wire-shaped is taken as written.
|
|
@@ -326,11 +326,14 @@ module GraphWeaver
|
|
|
326
326
|
|
|
327
327
|
plan = @planner.plan(document, operation_name:)
|
|
328
328
|
return introspect(query, variables, plan.operation_name) if plan.introspection
|
|
329
|
+
# read once, so every hop runs as the identity the query started
|
|
330
|
+
# with, whatever writes #context= while it is in flight
|
|
331
|
+
context = Internal::Util.context!(@context)
|
|
329
332
|
# one subgraph answers the whole thing: hand it the document as
|
|
330
333
|
# written, so nothing is rewritten that didn't have to be
|
|
331
|
-
return fetch(plan.entry, query, variables, plan.operation_name) if plan.verbatim
|
|
334
|
+
return fetch(plan.entry, query, variables, plan.operation_name, context) if plan.verbatim
|
|
332
335
|
|
|
333
|
-
run(plan, variables)
|
|
336
|
+
run(plan, variables, context)
|
|
334
337
|
end
|
|
335
338
|
|
|
336
339
|
# never leak the context (tokens, current_user) through logs or errors
|
|
@@ -374,7 +377,7 @@ module GraphWeaver
|
|
|
374
377
|
|
|
375
378
|
# ---- execution ----------------------------------------------------
|
|
376
379
|
|
|
377
|
-
def run(plan, variables)
|
|
380
|
+
def run(plan, variables, context)
|
|
378
381
|
errors = []
|
|
379
382
|
data = {}
|
|
380
383
|
# An operation's declared defaults are part of the variables, and
|
|
@@ -384,7 +387,7 @@ module GraphWeaver
|
|
|
384
387
|
.merge(variables.to_h { |name, value| [name.to_s, value] })
|
|
385
388
|
|
|
386
389
|
plan.steps.each do |step|
|
|
387
|
-
result = fetch_step(step, plan.operation, given)
|
|
390
|
+
result = fetch_step(step, plan.operation, given, context)
|
|
388
391
|
Array(result["errors"]).each { |error| errors << rewrite(error, step.subgraph) }
|
|
389
392
|
payload = result["data"]
|
|
390
393
|
if payload.nil?
|
|
@@ -397,7 +400,7 @@ module GraphWeaver
|
|
|
397
400
|
end
|
|
398
401
|
end
|
|
399
402
|
|
|
400
|
-
plan.steps.each { |step| stitch(step, [[data, []]], plan.operation, given, errors) }
|
|
403
|
+
plan.steps.each { |step| stitch(step, [[data, []]], plan.operation, given, context, errors) }
|
|
401
404
|
|
|
402
405
|
# A stitched fetch can leave a null where the composed schema says
|
|
403
406
|
# non-null, and nothing re-applies GraphQL's propagation rules over a
|
|
@@ -443,7 +446,7 @@ module GraphWeaver
|
|
|
443
446
|
# Everything the plan applies at this level: one _entities fetch per
|
|
444
447
|
# subgraph the level defers to (all nodes at once — _entities answers
|
|
445
448
|
# in representation order), then the same again one level down.
|
|
446
|
-
def stitch(step, nodes, operation, variables, errors)
|
|
449
|
+
def stitch(step, nodes, operation, variables, context, errors)
|
|
447
450
|
return if nodes.empty?
|
|
448
451
|
|
|
449
452
|
# An abstract position: the plan holds one branch per concrete type
|
|
@@ -454,12 +457,12 @@ module GraphWeaver
|
|
|
454
457
|
if step.is_a?(Internal::Planner::Branches)
|
|
455
458
|
step.steps.each do |type_name, branch|
|
|
456
459
|
stitch(branch, nodes.select { |(node, _)| node[TYPENAME] == type_name },
|
|
457
|
-
operation, variables, errors)
|
|
460
|
+
operation, variables, context, errors)
|
|
458
461
|
end
|
|
459
462
|
return
|
|
460
463
|
end
|
|
461
464
|
|
|
462
|
-
blocked = prefetch(step, nodes, operation, variables, errors)
|
|
465
|
+
blocked = prefetch(step, nodes, operation, variables, context, errors)
|
|
463
466
|
|
|
464
467
|
# A fetch for a selection the operation excluded is a fetch a real
|
|
465
468
|
# router never makes, and `trace` is something specs assert on. The
|
|
@@ -478,7 +481,8 @@ module GraphWeaver
|
|
|
478
481
|
|
|
479
482
|
entities = []
|
|
480
483
|
if fetched.any?
|
|
481
|
-
result = entities_fetch(target, step.type_name, deferrals.map(&:node), representations, operation,
|
|
484
|
+
result = entities_fetch(target, step.type_name, deferrals.map(&:node), representations, operation,
|
|
485
|
+
variables, context)
|
|
482
486
|
entities = result.dig("data", "_entities") || []
|
|
483
487
|
Array(result["errors"]).each { |error| errors << rewrite(error, target, fetched) }
|
|
484
488
|
end
|
|
@@ -502,12 +506,12 @@ module GraphWeaver
|
|
|
502
506
|
deferrals.each do |deferral|
|
|
503
507
|
next unless deferral.step
|
|
504
508
|
|
|
505
|
-
stitch(deferral.step, descend(nodes, deferral.response_key), operation, variables, errors)
|
|
509
|
+
stitch(deferral.step, descend(nodes, deferral.response_key), operation, variables, context, errors)
|
|
506
510
|
end
|
|
507
511
|
end
|
|
508
512
|
|
|
509
513
|
step.children.each do |key, child|
|
|
510
|
-
stitch(child, descend(nodes, key), operation, variables, errors)
|
|
514
|
+
stitch(child, descend(nodes, key), operation, variables, context, errors)
|
|
511
515
|
end
|
|
512
516
|
|
|
513
517
|
nodes.each { |(node, _)| strip!(node, step) }
|
|
@@ -517,7 +521,7 @@ module GraphWeaver
|
|
|
517
521
|
# keys before the fetch whose representation carries them. Returns the
|
|
518
522
|
# nodes the holding subgraph didn't recognize: their required fields
|
|
519
523
|
# don't exist, so nothing depending on them can resolve.
|
|
520
|
-
def prefetch(step, nodes, operation, variables, errors)
|
|
524
|
+
def prefetch(step, nodes, operation, variables, context, errors)
|
|
521
525
|
blocked = []
|
|
522
526
|
# the field it feeds was excluded, so this is a fetch a real router
|
|
523
527
|
# never makes — and a test double that runs a resolver production
|
|
@@ -533,7 +537,7 @@ module GraphWeaver
|
|
|
533
537
|
selections = Internal::Planner.injected_selections(group.flat_map(&:paths).uniq)
|
|
534
538
|
roots = selections.map(&:alias)
|
|
535
539
|
|
|
536
|
-
result = entities_fetch(subgraph, step.type_name, selections, representations, operation, variables)
|
|
540
|
+
result = entities_fetch(subgraph, step.type_name, selections, representations, operation, variables, context)
|
|
537
541
|
entities = result.dig("data", "_entities") || []
|
|
538
542
|
Array(result["errors"]).each { |error| errors << rewrite(error, subgraph, nodes) }
|
|
539
543
|
|
|
@@ -621,16 +625,16 @@ module GraphWeaver
|
|
|
621
625
|
Array(path).map { |segment| segment.is_a?(String) ? segment.delete_prefix(PREFIX) : segment }
|
|
622
626
|
end
|
|
623
627
|
|
|
624
|
-
def fetch_step(step, operation, variables)
|
|
628
|
+
def fetch_step(step, operation, variables, context)
|
|
625
629
|
document = GraphQL::Language::Nodes::OperationDefinition.new(
|
|
626
630
|
operation_type: operation.operation_type || "query",
|
|
627
631
|
variables: used_variables(step.selections, operation),
|
|
628
632
|
selections: step.selections,
|
|
629
633
|
)
|
|
630
|
-
run_subgraph(step.subgraph, document, variables)
|
|
634
|
+
run_subgraph(step.subgraph, document, variables, context)
|
|
631
635
|
end
|
|
632
636
|
|
|
633
|
-
def entities_fetch(subgraph, type_name, nodes, representations, operation, variables)
|
|
637
|
+
def entities_fetch(subgraph, type_name, nodes, representations, operation, variables, context)
|
|
634
638
|
entities = GraphQL::Language::Nodes::Field.new(
|
|
635
639
|
name: "_entities",
|
|
636
640
|
arguments: [GraphQL::Language::Nodes::Argument.new(
|
|
@@ -647,7 +651,7 @@ module GraphWeaver
|
|
|
647
651
|
variables: [REPRESENTATIONS_DEFINITION] + used_variables(nodes, operation),
|
|
648
652
|
selections: [entities],
|
|
649
653
|
)
|
|
650
|
-
run_subgraph(subgraph, document, variables.merge(REPRESENTATIONS => representations))
|
|
654
|
+
run_subgraph(subgraph, document, variables.merge(REPRESENTATIONS => representations), context)
|
|
651
655
|
end
|
|
652
656
|
|
|
653
657
|
REPRESENTATIONS = "representations"
|
|
@@ -682,12 +686,12 @@ module GraphWeaver
|
|
|
682
686
|
end
|
|
683
687
|
end
|
|
684
688
|
|
|
685
|
-
def run_subgraph(subgraph, document, variables)
|
|
689
|
+
def run_subgraph(subgraph, document, variables, context)
|
|
686
690
|
declared = document.variables.map(&:name)
|
|
687
|
-
fetch(subgraph, document.to_query_string, variables.slice(*declared), nil)
|
|
691
|
+
fetch(subgraph, document.to_query_string, variables.slice(*declared), nil, context)
|
|
688
692
|
end
|
|
689
693
|
|
|
690
|
-
def fetch(name, query, variables, operation_name)
|
|
694
|
+
def fetch(name, query, variables, operation_name, context)
|
|
691
695
|
faked = @faked.include?(name)
|
|
692
696
|
entry = { subgraph: name, query:, variables: variables.to_h }
|
|
693
697
|
entry[:faked] = true if faked
|
|
@@ -701,14 +705,12 @@ module GraphWeaver
|
|
|
701
705
|
end
|
|
702
706
|
|
|
703
707
|
GraphWeaver::Internal::Log.log(:debug) do
|
|
704
|
-
"router -> #{name} #{tag} variables=#{JSON.generate(GraphWeaver::Internal::Log.filter_variables(variables))}
|
|
705
|
-
" \
|
|
708
|
+
"router -> #{name} #{tag} variables=#{JSON.generate(GraphWeaver::Internal::Log.filter_variables(variables))}\n" \
|
|
706
709
|
"#{GraphWeaver::Internal::Wire.truncate_for_log(query)}"
|
|
707
710
|
end
|
|
708
711
|
|
|
709
712
|
GraphWeaver::Internal::Log.log_timed(:debug, "router -> #{name} #{tag} completed") do
|
|
710
|
-
@subgraphs.fetch(name).execute(query, variables:, operation_name:,
|
|
711
|
-
context: Internal::Util.context!(@context)).to_h
|
|
713
|
+
@subgraphs.fetch(name).execute(query, variables:, operation_name:, context:).to_h
|
|
712
714
|
end
|
|
713
715
|
end
|
|
714
716
|
|
|
@@ -783,7 +785,6 @@ module GraphWeaver
|
|
|
783
785
|
end
|
|
784
786
|
ordered
|
|
785
787
|
end
|
|
786
|
-
|
|
787
788
|
end
|
|
788
789
|
end
|
|
789
790
|
end
|
data/lib/graph_weaver/testing.rb
CHANGED
|
@@ -352,7 +352,6 @@ module GraphWeaver
|
|
|
352
352
|
def runnable(schema)
|
|
353
353
|
schema if schema.is_a?(Class) && schema <= GraphQL::Schema
|
|
354
354
|
end
|
|
355
|
-
|
|
356
355
|
end
|
|
357
356
|
|
|
358
357
|
class << self
|
|
@@ -476,7 +475,7 @@ module GraphWeaver
|
|
|
476
475
|
end
|
|
477
476
|
return if back == sample
|
|
478
477
|
|
|
479
|
-
"#{name}: round-trips lossily — sent #{sample
|
|
478
|
+
"#{name}: round-trips lossily — sent #{Internal::Redact.spell(sample)}, got back #{Internal::Redact.spell(back)}"
|
|
480
479
|
end
|
|
481
480
|
|
|
482
481
|
# The registration's `cast:`, RUN rather than emitted. A cast builds
|
data/lib/graph_weaver/version.rb
CHANGED