graph_weaver 0.6.0 → 0.7.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 +1470 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +21 -7
- data/docs/alternatives.md +201 -0
- data/docs/cassettes.md +17 -1
- data/docs/errors.md +382 -17
- data/docs/federation.md +469 -63
- data/docs/generated_modules.md +231 -15
- data/docs/getting_started.md +498 -105
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +32 -4
- data/docs/scalars.md +286 -57
- data/docs/testing.md +458 -59
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +330 -5
- data/graph_weaver.gemspec +7 -0
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +47 -10
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +218 -59
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +408 -206
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/errors.rb +264 -34
- data/lib/graph_weaver/federation.rb +119 -26
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +17 -11
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +78 -0
- data/lib/graph_weaver/internal/headers.rb +51 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +43 -4
- data/lib/graph_weaver/internal.rb +183 -1
- data/lib/graph_weaver/log_subscriber.rb +66 -0
- data/lib/graph_weaver/logging.rb +136 -12
- data/lib/graph_weaver/query_module.rb +36 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +33 -5
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +221 -49
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +6 -5
- data/lib/graph_weaver/testing/endpoint.rb +106 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +133 -44
- data/lib/graph_weaver/testing/router.rb +58 -11
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +46 -4
- data/lib/graph_weaver/transport.rb +109 -26
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +490 -116
- metadata +56 -1
|
@@ -10,6 +10,10 @@ module GraphWeaver
|
|
|
10
10
|
# suite-wide ones while the block that set them is still on the stack,
|
|
11
11
|
# and a fake built with its own checks them then.
|
|
12
12
|
module Overrides
|
|
13
|
+
# The key a Hash `list_size:` says its fallback under — every list it
|
|
14
|
+
# doesn't name.
|
|
15
|
+
LIST_SIZE_DEFAULT = "default"
|
|
16
|
+
|
|
13
17
|
class << self
|
|
14
18
|
# A pin key names something in the schema: a type ("Money",
|
|
15
19
|
# "Person"), a "Type.field" coordinate, or a bare field name
|
|
@@ -22,6 +26,44 @@ module GraphWeaver
|
|
|
22
26
|
end
|
|
23
27
|
end
|
|
24
28
|
|
|
29
|
+
# Whether `key` reads as a reference into the schema rather than a
|
|
30
|
+
# plain word — what tells a pin from an option at a fake's door, where
|
|
31
|
+
# both arrive as the same keywords. A "Type.field" coordinate can only
|
|
32
|
+
# be a pin; a bare word is one when the schema knows it. Whether the
|
|
33
|
+
# reference RESOLVES is validate!'s question, so a coordinate naming
|
|
34
|
+
# no type is still a pin and gets that refusal rather than "unknown
|
|
35
|
+
# option".
|
|
36
|
+
#
|
|
37
|
+
# Casing can't decide it: Hasura's types are lowercase, and
|
|
38
|
+
# `pokemon_v2_pokemon` read as a misspelled option.
|
|
39
|
+
def schema_reference?(schema, key)
|
|
40
|
+
key = key.to_s
|
|
41
|
+
return true if key.include?(".") || key.start_with?("__")
|
|
42
|
+
|
|
43
|
+
!schema.get_type(key).nil? || field_names(schema).include?(key)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Every name a pin may be keyed by — the dictionary a refusal guesses
|
|
47
|
+
# from when a key is neither a pin nor an option.
|
|
48
|
+
def pin_names(schema) = schema.types.keys + field_names(schema)
|
|
49
|
+
|
|
50
|
+
# A Hash `list_size:` sizes one list at a time, keyed the way a pin is
|
|
51
|
+
# minus the bare type name: a type says nothing about how long any one
|
|
52
|
+
# of its fields is.
|
|
53
|
+
def validate_list_size!(schema, list_size)
|
|
54
|
+
return unless list_size.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
list_size.each do |key, value|
|
|
57
|
+
unless value.is_a?(Integer) || value.is_a?(Range)
|
|
58
|
+
raise GraphWeaver::Error, "list_size: #{key.to_s.inspect} must be an Integer or a " \
|
|
59
|
+
"Range — how long an unbounded list is — got #{value.inspect}"
|
|
60
|
+
end
|
|
61
|
+
next if key.to_s == LIST_SIZE_DEFAULT
|
|
62
|
+
|
|
63
|
+
validate_field_key!(schema, key.to_s, "list_size: key")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
25
67
|
# A pin that's a proc is handed the seeded Random when it takes one
|
|
26
68
|
# and called bare when it doesn't, so a varying pin still reproduces
|
|
27
69
|
# under `rspec --seed`.
|
|
@@ -55,16 +97,36 @@ module GraphWeaver
|
|
|
55
97
|
|
|
56
98
|
# a leading capital names a type, as it does wherever a pin is written
|
|
57
99
|
dictionary = type_name.match?(/\A[A-Z]/) ? schema.types.keys : known
|
|
58
|
-
bad!(key, "matches no type or field in this schema", dictionary, type_name)
|
|
100
|
+
bad!("override key", key, "matches no type or field in this schema", dictionary, type_name)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
coordinate!(schema, "override key", key, type_name, field_name)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A key naming one FIELD — a "Type.field" coordinate, or a bare field
|
|
107
|
+
# name matching that field on any type.
|
|
108
|
+
def validate_field_key!(schema, key, label)
|
|
109
|
+
type_name, field_name = key.split(".", 2)
|
|
110
|
+
return if (field_name || type_name).start_with?("__")
|
|
111
|
+
|
|
112
|
+
if field_name.nil?
|
|
113
|
+
known = field_names(schema)
|
|
114
|
+
return if known.include?(type_name)
|
|
115
|
+
|
|
116
|
+
bad!(label, key, "matches no field in this schema", known, type_name)
|
|
59
117
|
end
|
|
60
118
|
|
|
119
|
+
coordinate!(schema, label, key, type_name, field_name)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def coordinate!(schema, label, key, type_name, field_name)
|
|
61
123
|
type = schema.get_type(type_name)
|
|
62
124
|
unless type.respond_to?(:fields)
|
|
63
|
-
bad!(key, "names no object type in this schema", schema.types.keys, type_name)
|
|
125
|
+
bad!(label, key, "names no object type in this schema", schema.types.keys, type_name)
|
|
64
126
|
end
|
|
65
127
|
return if type.fields.key?(field_name)
|
|
66
128
|
|
|
67
|
-
bad!(key, "is not a field of #{type_name}", type.fields.keys, field_name)
|
|
129
|
+
bad!(label, key, "is not a field of #{type_name}", type.fields.keys, field_name)
|
|
68
130
|
end
|
|
69
131
|
|
|
70
132
|
# A type pin says what every value of that type is, and the fake only
|
|
@@ -84,10 +146,10 @@ module GraphWeaver
|
|
|
84
146
|
"#{type.graphql_name}, and a pin fabricates a scalar, enum or object: #{advice}"
|
|
85
147
|
end
|
|
86
148
|
|
|
87
|
-
def bad!(key, problem, dictionary, term)
|
|
149
|
+
def bad!(label, key, problem, dictionary, term)
|
|
88
150
|
suggestion = Util.did_you_mean(dictionary, term)
|
|
89
151
|
hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
|
|
90
|
-
raise GraphWeaver::Error, "
|
|
152
|
+
raise GraphWeaver::Error, "#{label} #{key.inspect} #{problem}#{hint}"
|
|
91
153
|
end
|
|
92
154
|
|
|
93
155
|
# Every output field name in the schema — walked only when a bare key
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "graphql"
|
|
5
5
|
|
|
6
|
+
require_relative "selection"
|
|
6
7
|
require_relative "subgraphs"
|
|
7
8
|
|
|
8
9
|
module GraphWeaver
|
|
@@ -155,7 +156,20 @@ module GraphWeaver
|
|
|
155
156
|
.map { |error| Wire.graphql_error(error.message, "GRAPHQL_VALIDATION_FAILED") }
|
|
156
157
|
end
|
|
157
158
|
|
|
159
|
+
# @defer/@stream send the rest of the answer in later payloads over a
|
|
160
|
+
# multipart body; this router answers in one. Public and document-wide
|
|
161
|
+
# because the caller has to ask it BEFORE validation — the composed API
|
|
162
|
+
# schema usually doesn't declare the directives, so graphql-ruby's
|
|
163
|
+
# "Directive @defer is not defined" fires first and shadows this.
|
|
164
|
+
def refuse_incremental!(document)
|
|
165
|
+
node = Selection.incremental_directive(document) or return
|
|
166
|
+
|
|
167
|
+
refuse :incremental_delivery,
|
|
168
|
+
"this operation carries @#{node.name}, and the answer would arrive in more than one payload"
|
|
169
|
+
end
|
|
170
|
+
|
|
158
171
|
def plan(document, operation_name: nil)
|
|
172
|
+
refuse_incremental!(document)
|
|
159
173
|
operation = pick_operation(document, operation_name)
|
|
160
174
|
refuse(:operation_type, "this document is a subscription") if
|
|
161
175
|
operation.operation_type == "subscription"
|
|
@@ -178,7 +192,7 @@ module GraphWeaver
|
|
|
178
192
|
return plan
|
|
179
193
|
end
|
|
180
194
|
|
|
181
|
-
|
|
195
|
+
check_reachable!(root.graphql_name, selections, fragments)
|
|
182
196
|
|
|
183
197
|
entry = single_subgraph(root.graphql_name, selections, fragments)
|
|
184
198
|
if entry
|
|
@@ -223,13 +237,14 @@ module GraphWeaver
|
|
|
223
237
|
|
|
224
238
|
private
|
|
225
239
|
|
|
226
|
-
# Every
|
|
227
|
-
#
|
|
228
|
-
#
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
|
|
240
|
+
# Every position this operation reaches, refused if the supergraph says
|
|
241
|
+
# something about it no plan can honour — whatever plan it gets. It runs
|
|
242
|
+
# here, above the verbatim shortcut, because that is the only place
|
|
243
|
+
# every plan passes: a check that lives in {#plan_step} silently skips
|
|
244
|
+
# the query one subgraph answers whole. Asked per query rather than at
|
|
245
|
+
# construction — one such directive shouldn't cost you the queries that
|
|
246
|
+
# never touch it.
|
|
247
|
+
def check_reachable!(type_name, selections, fragments, depth = 0)
|
|
233
248
|
return if depth > MAX_DEPTH
|
|
234
249
|
|
|
235
250
|
selections.each do |node|
|
|
@@ -237,18 +252,20 @@ module GraphWeaver
|
|
|
237
252
|
when GraphQL::Language::Nodes::Field
|
|
238
253
|
next if node.name.start_with?("__")
|
|
239
254
|
|
|
255
|
+
contextual!(type_name, node)
|
|
256
|
+
progressive_override!(type_name, node)
|
|
240
257
|
child = raw_child_type(type_name, node.name) or next
|
|
241
258
|
interface_object!(child, "#{type_name}.#{node.name} returns #{child}")
|
|
242
|
-
|
|
259
|
+
check_reachable!(child, node.selections, fragments, depth + 1)
|
|
243
260
|
when GraphQL::Language::Nodes::InlineFragment
|
|
244
261
|
condition = node.type&.name || type_name
|
|
245
262
|
interface_object!(condition, "this operation selects ... on #{condition}")
|
|
246
|
-
|
|
263
|
+
check_reachable!(condition, node.selections, fragments, depth + 1)
|
|
247
264
|
when GraphQL::Language::Nodes::FragmentSpread
|
|
248
265
|
fragment = fragments[node.name] or next
|
|
249
266
|
condition = fragment.type.name
|
|
250
267
|
interface_object!(condition, "...#{node.name} is on #{condition}")
|
|
251
|
-
|
|
268
|
+
check_reachable!(condition, fragment.selections, fragments, depth + 1)
|
|
252
269
|
end
|
|
253
270
|
end
|
|
254
271
|
end
|
|
@@ -352,7 +369,6 @@ module GraphWeaver
|
|
|
352
369
|
|
|
353
370
|
here = step(subgraph, type_name)
|
|
354
371
|
selections.each do |node|
|
|
355
|
-
contextual!(type_name, node)
|
|
356
372
|
# a subtree that never leaves this subgraph goes over as written:
|
|
357
373
|
# the boundary rules govern stitching, so they have no business
|
|
358
374
|
# applying to a query that was never going to cross one
|
|
@@ -388,9 +404,9 @@ module GraphWeaver
|
|
|
388
404
|
|
|
389
405
|
# A @fromContext argument is filled by the GATEWAY, out of a selection on
|
|
390
406
|
# an ancestor — so a fetch this planner writes leaves it unset and the
|
|
391
|
-
# field resolves from nothing.
|
|
392
|
-
#
|
|
393
|
-
#
|
|
407
|
+
# field resolves from nothing. A subgraph's own resolver never fills one
|
|
408
|
+
# either, so handing it the subtree whole doesn't help: this is asked of
|
|
409
|
+
# every field the operation reaches, on every path.
|
|
394
410
|
def contextual!(type_name, node)
|
|
395
411
|
names = @table.field(type_name, node.name)&.contextual
|
|
396
412
|
return if names.nil? || names.empty?
|
|
@@ -400,6 +416,20 @@ module GraphWeaver
|
|
|
400
416
|
"on its own"
|
|
401
417
|
end
|
|
402
418
|
|
|
419
|
+
# A progressive @override(label:) leaves BOTH subgraphs resolving the
|
|
420
|
+
# field — the label is the rollout rule the gateway evaluates per
|
|
421
|
+
# request to pick between them. A plain @override drops the losing copy
|
|
422
|
+
# at composition, so there is nothing to decide and nothing to refuse;
|
|
423
|
+
# a labelled one is a coin only the gateway can toss.
|
|
424
|
+
def progressive_override!(type_name, node)
|
|
425
|
+
field = @table.field(type_name, node.name)
|
|
426
|
+
label = field&.override_label or return
|
|
427
|
+
|
|
428
|
+
refuse :progressive_override, "#{type_name}.#{node.name} is mid-rollout under " \
|
|
429
|
+
"@override(label: #{label.inspect}) — #{field.graphs.join(" and ")} both resolve it, " \
|
|
430
|
+
"and a local router can't evaluate a rollout percentage"
|
|
431
|
+
end
|
|
432
|
+
|
|
403
433
|
# The keys a fetch injects are stripped from the answer, so a caller's
|
|
404
434
|
# alias spelling one is stripped with it — silently, since the two are
|
|
405
435
|
# then indistinguishable. Asked of what this fetch actually injects
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GraphWeaver
|
|
5
|
+
module Internal
|
|
6
|
+
# The verdict a coercion refusal already reached, riding on the plain
|
|
7
|
+
# ::TypeError / ::ArgumentError Coerce raises rather than on a class of
|
|
8
|
+
# its own — so `rescue ::TypeError` still catches it and Coerce keeps
|
|
9
|
+
# complaining the way Kernel#Integer does.
|
|
10
|
+
#
|
|
11
|
+
# The layers that brand a failure (InputStruct.field, Coerce.variable)
|
|
12
|
+
# read it off the exception instead of deriving a kind from the message.
|
|
13
|
+
# A message is prose; reading a verdict back out of one would be a guess,
|
|
14
|
+
# and a wrong `kind` is worse than none — the app will have translated it
|
|
15
|
+
# into a confident sentence.
|
|
16
|
+
module Refusal
|
|
17
|
+
attr_accessor :graph_weaver_kind, :graph_weaver_details
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Tag an exception with its verdict; returns it, ready to raise.
|
|
21
|
+
def brand(error, kind, **details)
|
|
22
|
+
error.extend(Refusal)
|
|
23
|
+
error.graph_weaver_kind = kind
|
|
24
|
+
error.graph_weaver_details = details
|
|
25
|
+
error
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# What a raised exception says went wrong, as an InputError kind.
|
|
29
|
+
# Coerce's own refusals carry their verdict; an app's `cast:` or
|
|
30
|
+
# `serialize:` raises whatever it likes, and Ruby's own convention
|
|
31
|
+
# splits the two it raises for a value it can't use: TypeError means
|
|
32
|
+
# the CLASS was wrong, ArgumentError the CONTENT — which is the
|
|
33
|
+
# difference between "send something else" and "fix the text".
|
|
34
|
+
# Anything else is :refused rather than a guess at what it meant.
|
|
35
|
+
def kind_of(error)
|
|
36
|
+
return error.graph_weaver_kind if error.is_a?(Refusal)
|
|
37
|
+
|
|
38
|
+
case error
|
|
39
|
+
when ::TypeError then :type_mismatch
|
|
40
|
+
when ::ArgumentError then :unparseable
|
|
41
|
+
else :refused
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def details_of(error) = error.is_a?(Refusal) ? error.graph_weaver_details : {}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -27,20 +27,34 @@ module GraphWeaver
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
# Does this schema carry the coordinate — "Type", or "Type.field"?
|
|
30
|
-
def defines?(schema, coordinate)
|
|
31
|
-
type_name, field_name = coordinate.split(".", 2)
|
|
32
|
-
type = schema.get_type(type_name) or return false
|
|
33
|
-
return true unless field_name
|
|
34
|
-
|
|
35
|
-
return type.fields.key?(field_name) if type.respond_to?(:fields)
|
|
36
|
-
# an input object's members are arguments, not fields
|
|
37
|
-
return type.arguments.key?(field_name) if type.respond_to?(:arguments)
|
|
30
|
+
def defines?(schema, coordinate) = !member(schema, coordinate).nil?
|
|
38
31
|
|
|
39
|
-
|
|
32
|
+
# The type this schema gives "Type.field", printed the way SDL prints
|
|
33
|
+
# it — so it compares directly against a supergraph's own spelling.
|
|
34
|
+
# Presence is the cheaper question and answers a different one: a
|
|
35
|
+
# field that is still *there* can have been retyped underneath the
|
|
36
|
+
# composition, which reads as a match until the types are compared.
|
|
37
|
+
def signature(schema, coordinate)
|
|
38
|
+
member = member(schema, coordinate)
|
|
39
|
+
member.type.to_type_signature if member.respond_to?(:type)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
private
|
|
43
43
|
|
|
44
|
+
# The field (or, on an input object, the argument — its members are
|
|
45
|
+
# arguments, not fields) at "Type.field", or the type itself for a
|
|
46
|
+
# bare coordinate.
|
|
47
|
+
def member(schema, coordinate)
|
|
48
|
+
type_name, field_name = coordinate.split(".", 2)
|
|
49
|
+
type = schema.get_type(type_name) or return
|
|
50
|
+
return type unless field_name
|
|
51
|
+
|
|
52
|
+
return type.fields[field_name] if type.respond_to?(:fields)
|
|
53
|
+
return type.arguments[field_name] if type.respond_to?(:arguments)
|
|
54
|
+
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
44
58
|
def descendants(klass)
|
|
45
59
|
klass.subclasses.flat_map { |subclass| [subclass] + descendants(subclass) }
|
|
46
60
|
end
|
|
@@ -12,6 +12,40 @@ module GraphWeaver
|
|
|
12
12
|
module Selection
|
|
13
13
|
include Kernel # for sorbet: hosts are Objects
|
|
14
14
|
|
|
15
|
+
# The directives that ask for the answer in instalments. Neither the
|
|
16
|
+
# generated code nor the local router reads a multipart body, and both
|
|
17
|
+
# have to say so BY NAME: whether the schema in hand declares @defer is
|
|
18
|
+
# graphql-ruby's business, so "Directive @defer is not defined" is an
|
|
19
|
+
# accident that happens to refuse — and it stops happening the day a
|
|
20
|
+
# supergraph @links the defer spec.
|
|
21
|
+
INCREMENTAL = %w[defer stream].freeze
|
|
22
|
+
|
|
23
|
+
# The first @defer/@stream node anywhere in a parsed document, or nil.
|
|
24
|
+
# A module function rather than part of the walk below: the callers ask
|
|
25
|
+
# before they have a schema, an operation, or a host to walk with.
|
|
26
|
+
def self.incremental_directive(node)
|
|
27
|
+
if node.respond_to?(:directives)
|
|
28
|
+
# a spread carries directives and no selections (`...Frag @defer`),
|
|
29
|
+
# so this is asked of every node rather than only of the ones below
|
|
30
|
+
applied = node.directives.find { |d| INCREMENTAL.include?(d.name) }
|
|
31
|
+
return applied if applied
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
children = if node.is_a?(GraphQL::Language::Nodes::Document)
|
|
35
|
+
node.definitions
|
|
36
|
+
elsif node.respond_to?(:selections)
|
|
37
|
+
node.selections
|
|
38
|
+
else
|
|
39
|
+
[]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
children.each do |child|
|
|
43
|
+
nested = incremental_directive(child)
|
|
44
|
+
return nested if nested
|
|
45
|
+
end
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
15
49
|
# Every method here becomes an instance method of its host (Codegen,
|
|
16
50
|
# FakeClient, the cassette Anonymizer) — private so the walk stays the
|
|
17
51
|
# host's own business rather than part of its API.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GraphWeaver
|
|
5
|
+
module Internal
|
|
6
|
+
# A server's rejection of the input, read back into InputError values —
|
|
7
|
+
# the same value object the client-side refusal is, so an app renders one
|
|
8
|
+
# form the same way whichever side said no.
|
|
9
|
+
#
|
|
10
|
+
# Four shapes, most specific first: the `extensions.input` convention
|
|
11
|
+
# (docs/errors.md), graphql-ruby's variable-coercion `problems` array, a
|
|
12
|
+
# recognized `extensions.code`, and Hasura's argument path. Everything
|
|
13
|
+
# else is nobody's input error and stays out — and an explanation with no
|
|
14
|
+
# table entry becomes `:refused` carrying the server's own sentence,
|
|
15
|
+
# because a wrong `kind` is worse than no kind: the app will have
|
|
16
|
+
# translated it into a confident sentence.
|
|
17
|
+
#
|
|
18
|
+
# One rule for #path throughout: it is the INPUT path the server stated,
|
|
19
|
+
# or empty. A GraphQL error's own path names a selection rather than an
|
|
20
|
+
# input slot, so it is never stood in for one.
|
|
21
|
+
module ServerInput
|
|
22
|
+
# graphql-ruby names the variable only in the error's message; the
|
|
23
|
+
# problems underneath are relative to it (measured against 2.6.10).
|
|
24
|
+
VARIABLE = /\AVariable \$([A-Za-z_]\w*) /
|
|
25
|
+
|
|
26
|
+
# The explanations graphql-ruby writes for a variable that wouldn't
|
|
27
|
+
# coerce. A closed table, not a parser: anything else is :refused.
|
|
28
|
+
COERCE = /\ACould not coerce value .* to (\S+)\z/
|
|
29
|
+
NOT_A_MEMBER = /\AExpected .* to be one of: (.*)\z/
|
|
30
|
+
NOT_NULL = /\AExpected value to not be null\z/
|
|
31
|
+
NOT_DEFINED = /\AField is not defined on (\S+)\z/
|
|
32
|
+
|
|
33
|
+
# Hasura states no input code — it stamps one code on a whole class of
|
|
34
|
+
# rejections and says what the error is about in `extensions.path`, a
|
|
35
|
+
# dotted string rather than an array. So the path is the test, not the
|
|
36
|
+
# code: only one that reaches a field's arguments is about the input.
|
|
37
|
+
# "$", "$.query" and "$.selectionSet.<field>" are the query itself — a
|
|
38
|
+
# .graphql file that doesn't parse, or names an argument the schema
|
|
39
|
+
# hasn't got, is nothing a form can highlight.
|
|
40
|
+
HASURA_CODES = %w[validation-failed parse-failed].freeze
|
|
41
|
+
# lazily, so an argument of its own named `args` doesn't win the split
|
|
42
|
+
HASURA_ARGUMENT = /\A\$\.selectionSet\..+?\.args\.(.+)\z/
|
|
43
|
+
HASURA_SEGMENT = /\A([_A-Za-z]\w*)((?:\[\d+\])*)\z/
|
|
44
|
+
|
|
45
|
+
# The explanations Hasura writes that name a kind on their own (measured
|
|
46
|
+
# against Hasura v2, one curl per entry — spec/input_errors_spec.rb
|
|
47
|
+
# holds the verbatim JSON). Its scalar family, "expected <description>
|
|
48
|
+
# for type 'T', but found <json type>", is deliberately absent: one
|
|
49
|
+
# sentence covers both `limit: -5` (out of range) and `limit: "lots"`
|
|
50
|
+
# (wrong type), and telling them apart means parsing the English
|
|
51
|
+
# description rather than reading a table.
|
|
52
|
+
HASURA_NOT_A_MEMBER = /\Aexpected one of the values \[(.*)\] for type '[^']*', but found /
|
|
53
|
+
HASURA_NOT_DEFINED = /\Afield '([^']*)' not found in type: '([^']*)'\z/
|
|
54
|
+
HASURA_NULL = /\Aunexpected null value for type '[^']*'\z/
|
|
55
|
+
QUOTED = /'([^']*)'/
|
|
56
|
+
|
|
57
|
+
# InputError::DETAILS closes the key set; this closes the types, because
|
|
58
|
+
# a right key with the wrong type under it is the same smuggling. An app
|
|
59
|
+
# is entitled to errors.rb's promise that members stays an Array —
|
|
60
|
+
# details[:members].join(", ") must not raise on what a server sent.
|
|
61
|
+
# (spec/input_errors_spec.rb holds these keys to DETAILS.)
|
|
62
|
+
DETAIL_TYPES = {
|
|
63
|
+
"type" => String, "members" => Array, "min" => Numeric,
|
|
64
|
+
"max" => Numeric, "pattern" => String, "suggestion" => String,
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
private_constant :VARIABLE, :COERCE, :NOT_A_MEMBER, :NOT_NULL, :NOT_DEFINED,
|
|
68
|
+
:HASURA_CODES, :HASURA_ARGUMENT, :HASURA_SEGMENT, :QUOTED,
|
|
69
|
+
:HASURA_NOT_A_MEMBER, :HASURA_NOT_DEFINED, :HASURA_NULL
|
|
70
|
+
|
|
71
|
+
class << self
|
|
72
|
+
def read(error)
|
|
73
|
+
extensions = error.extensions
|
|
74
|
+
stated = extensions["input"]
|
|
75
|
+
return [convention(error.message, stated, [], nil)] if stated.is_a?(Hash)
|
|
76
|
+
return problems(error, extensions) if extensions["problems"].is_a?(Array)
|
|
77
|
+
|
|
78
|
+
kind = GraphWeaver::GraphQLError::INPUT_CODES[error.code.to_s]
|
|
79
|
+
return [coded(error, kind)] if kind
|
|
80
|
+
|
|
81
|
+
hasura(error, extensions)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# The convention: taken verbatim, after checking `kind` is one this
|
|
87
|
+
# version knows and `details` carries only keys a kind can mean.
|
|
88
|
+
def convention(message, stated, path, value)
|
|
89
|
+
kind = stated["kind"].to_s.to_sym
|
|
90
|
+
kind = :refused unless GraphWeaver::InputError::KINDS.include?(kind)
|
|
91
|
+
coordinate = stated["coordinate"]
|
|
92
|
+
|
|
93
|
+
build(
|
|
94
|
+
message,
|
|
95
|
+
kind:,
|
|
96
|
+
path: input_path(stated["path"]) || path,
|
|
97
|
+
coordinate: (coordinate if coordinate.is_a?(String)),
|
|
98
|
+
value: stated.key?("value") ? stated["value"] : value,
|
|
99
|
+
details: details_of(stated),
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A path a server stated for the INPUT, or nil: field names and list
|
|
104
|
+
# indices, nothing else. There is no floor under it — a GraphQL error's
|
|
105
|
+
# own path names a selection ("createOrder"), and standing that in gives
|
|
106
|
+
# #field a plausible-looking name for a slot the input hasn't got.
|
|
107
|
+
def input_path(stated)
|
|
108
|
+
return unless stated.is_a?(Array)
|
|
109
|
+
|
|
110
|
+
stated if stated.all? { |segment| segment.is_a?(String) || segment.is_a?(Integer) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# the details a server stated that a kind can actually mean, both key
|
|
114
|
+
# and type — anything else is dropped rather than passed through
|
|
115
|
+
def details_of(stated)
|
|
116
|
+
DETAIL_TYPES.each_with_object({}) do |(key, type), out|
|
|
117
|
+
value = stated[key]
|
|
118
|
+
out[key.to_sym] = value if value.is_a?(type)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# One InputError per problem — a single coercion error routinely
|
|
123
|
+
# carries several, and they are about different fields.
|
|
124
|
+
def problems(error, extensions)
|
|
125
|
+
root = (match = error.message.match(VARIABLE)) ? [match[1]] : []
|
|
126
|
+
|
|
127
|
+
extensions["problems"].filter_map do |problem|
|
|
128
|
+
next unless problem.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
within = Array(problem["path"])
|
|
131
|
+
path = root + within
|
|
132
|
+
value = dig(extensions["value"], within)
|
|
133
|
+
message = problem["explanation"].to_s
|
|
134
|
+
stated = problem.dig("extensions", "input")
|
|
135
|
+
next convention(message, stated, path, value) if stated.is_a?(Hash)
|
|
136
|
+
|
|
137
|
+
explained(message, path, value, within)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def explained(message, path, value, within)
|
|
142
|
+
case message
|
|
143
|
+
when COERCE
|
|
144
|
+
# text that didn't parse, vs a thing that was never that type
|
|
145
|
+
build(message, kind: value.is_a?(String) ? :unparseable : :type_mismatch,
|
|
146
|
+
path:, value:, details: { type: $1 })
|
|
147
|
+
when NOT_A_MEMBER
|
|
148
|
+
build(message, kind: :not_a_member, path:, value:, details: { members: $1.split(", ") })
|
|
149
|
+
when NOT_NULL
|
|
150
|
+
build(message, kind: :missing, path:, value:)
|
|
151
|
+
when NOT_DEFINED
|
|
152
|
+
# the one explanation that names the input type, so the one that
|
|
153
|
+
# can give a coordinate — but only from the problem's OWN path.
|
|
154
|
+
# #path is the variable plus that, so its last segment is the
|
|
155
|
+
# variable name when the problem states none, and "RangeInput.range"
|
|
156
|
+
# is a slot the schema doesn't have.
|
|
157
|
+
type = $1
|
|
158
|
+
field = within.last
|
|
159
|
+
build(message, kind: :unknown, path:, value:,
|
|
160
|
+
coordinate: ("#{type}.#{field}" if field.is_a?(String)))
|
|
161
|
+
else
|
|
162
|
+
build(message, kind: :refused, path:, value:)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Hasura: the argument is in extensions.path or this is not about the
|
|
167
|
+
# input. No value either — Hasura never echoes back what it rejected.
|
|
168
|
+
def hasura(error, extensions)
|
|
169
|
+
return [] unless HASURA_CODES.include?(error.code.to_s)
|
|
170
|
+
|
|
171
|
+
stated = extensions["path"]
|
|
172
|
+
match = stated.is_a?(String) ? stated.match(HASURA_ARGUMENT) : nil
|
|
173
|
+
path = hasura_path(match[1]) if match
|
|
174
|
+
path ? [hasura_explained(error.message, path)] : []
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# "order_by[0].name" => ["order_by", 0, "name"]. nil rather than a
|
|
178
|
+
# partial read: a path this can't spell points a form at a field the
|
|
179
|
+
# server never named.
|
|
180
|
+
def hasura_path(stated)
|
|
181
|
+
stated.split(".").flat_map do |segment|
|
|
182
|
+
match = segment.match(HASURA_SEGMENT) or return nil
|
|
183
|
+
[match[1], *match[2].scan(/\d+/).map(&:to_i)]
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def hasura_explained(message, path)
|
|
188
|
+
case message
|
|
189
|
+
when HASURA_NOT_A_MEMBER
|
|
190
|
+
build(message, kind: :not_a_member, path:, details: { members: $1.scan(QUOTED).flatten })
|
|
191
|
+
when HASURA_NOT_DEFINED
|
|
192
|
+
build(message, kind: :unknown, path:, coordinate: "#{$2}.#{$1}")
|
|
193
|
+
when HASURA_NULL
|
|
194
|
+
# :missing is "wasn't supplied, or was null" (docs/i18n.md)
|
|
195
|
+
build(message, kind: :missing, path:)
|
|
196
|
+
else
|
|
197
|
+
build(message, kind: :refused, path:)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# A recognized validation code. `argumentName` is the input coordinate;
|
|
202
|
+
# a code that names none names no slot, and the error's own path is a
|
|
203
|
+
# QUERY path ("query", "rangeThing", …) rather than an input one.
|
|
204
|
+
def coded(error, kind)
|
|
205
|
+
extensions = error.extensions
|
|
206
|
+
argument = extensions["argumentName"]
|
|
207
|
+
# inputObjectType is stated outright; argumentNotAccepted says which
|
|
208
|
+
# kind of thing `name` is instead. A field argument has no schema
|
|
209
|
+
# coordinate here — nothing names the field's parent type.
|
|
210
|
+
type = extensions["inputObjectType"] ||
|
|
211
|
+
(extensions["name"] if extensions["typeName"] == "InputObject")
|
|
212
|
+
|
|
213
|
+
build(
|
|
214
|
+
error.message,
|
|
215
|
+
kind:,
|
|
216
|
+
path: argument.is_a?(String) ? [argument] : [],
|
|
217
|
+
coordinate: ("#{type}.#{argument}" if type.is_a?(String) && argument.is_a?(String)),
|
|
218
|
+
value: extensions["value"],
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# raised: false — this is a value read off a response, and the warn
|
|
223
|
+
# line Error#initialize writes would claim a raise that never happened.
|
|
224
|
+
#
|
|
225
|
+
# The message goes through the same filter the client side puts its own
|
|
226
|
+
# messages through: a server quotes the value it rejected as a matter of
|
|
227
|
+
# course ('Could not coerce value "hunter2" to Int'), so redacting only
|
|
228
|
+
# #value would leave half the promise kept. It is capped for the same
|
|
229
|
+
# reason — the sentence is the server's, and so is its length.
|
|
230
|
+
def build(message, kind:, path:, value: nil, coordinate: nil, details: {})
|
|
231
|
+
redact = GraphWeaver::Internal::Redact
|
|
232
|
+
GraphWeaver::InputError.new(
|
|
233
|
+
redact.cap(redact.detail(path.last, message)), kind:, path:, coordinate:, details:, raised: false,
|
|
234
|
+
value: redact.value(path.last, value),
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# the problem's path walked into the variable the server echoed back
|
|
239
|
+
def dig(value, path)
|
|
240
|
+
path.reduce(value) do |node, segment|
|
|
241
|
+
case node
|
|
242
|
+
when Hash then node[segment.to_s]
|
|
243
|
+
when Array then segment.is_a?(Integer) ? node[segment] : nil
|
|
244
|
+
else return nil
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|