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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1470 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +21 -7
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +498 -105
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +32 -4
  15. data/docs/scalars.md +286 -57
  16. data/docs/testing.md +458 -59
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +330 -5
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +218 -59
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +408 -206
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +43 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +490 -116
  70. 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, "override key #{key.inspect} #{problem}#{hint}"
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
- check_interface_objects!(root.graphql_name, selections, fragments) if @interface_objects.any?
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 type these selections reach, refused if one of them is an
227
- # @interfaceObject: a subgraph resolves the whole interface there, so
228
- # the supergraph records no per-field routing for it and every fetch
229
- # planned against it would be a guess. Asked per query rather than at
230
- # construction one such directive shouldn't cost you the queries
231
- # that never touch the type.
232
- def check_interface_objects!(type_name, selections, fragments, depth = 0)
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
- check_interface_objects!(child, node.selections, fragments, depth + 1)
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
- check_interface_objects!(condition, node.selections, fragments, depth + 1)
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
- check_interface_objects!(condition, fragment.selections, fragments, depth + 1)
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. Asked only of a field the planner routes
392
- # itself: a subtree handed to one subgraph whole carries its own context,
393
- # which is why the verbatim path never reaches here.
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
- false
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