graph_weaver 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +8 -0
  3. data/Gemfile.lock +153 -4
  4. data/README.md +45 -79
  5. data/docs/alternatives.md +195 -0
  6. data/docs/cassettes.md +61 -50
  7. data/docs/editors.md +32 -47
  8. data/docs/errors.md +360 -103
  9. data/docs/federation.md +692 -473
  10. data/docs/generated_modules.md +441 -314
  11. data/docs/getting_started.md +370 -194
  12. data/docs/i18n.md +171 -0
  13. data/docs/logging.md +197 -50
  14. data/docs/real_world.md +42 -27
  15. data/docs/scalars.md +307 -176
  16. data/docs/testing.md +473 -220
  17. data/docs/transports.md +224 -151
  18. data/docs/upgrading.md +258 -305
  19. data/examples/README.md +38 -0
  20. data/examples/countries.rb +39 -0
  21. data/examples/federation.rb +62 -0
  22. data/examples/github/generate.rb +20 -0
  23. data/examples/github/generated/star_mutation.rb +126 -0
  24. data/examples/github/generated/stargazers_query.rb +232 -0
  25. data/examples/github/generated/starred_query.rb +151 -0
  26. data/examples/github/queries/star.graphql +8 -0
  27. data/examples/github/queries/stargazers.graphql +22 -0
  28. data/examples/github/queries/starred.graphql +11 -0
  29. data/examples/github/run.rb +43 -0
  30. data/examples/github/setup.rb +18 -0
  31. data/examples/rick_and_morty.rb +57 -0
  32. data/graph_weaver.gemspec +19 -3
  33. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  34. data/lib/graph_weaver/client.rb +69 -11
  35. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  36. data/lib/graph_weaver/codegen/emit.rb +98 -29
  37. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  38. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  39. data/lib/graph_weaver/codegen/registry.rb +175 -0
  40. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  41. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  42. data/lib/graph_weaver/codegen.rb +406 -195
  43. data/lib/graph_weaver/coerce.rb +155 -26
  44. data/lib/graph_weaver/context_seam.rb +54 -0
  45. data/lib/graph_weaver/errors.rb +284 -46
  46. data/lib/graph_weaver/federation.rb +129 -27
  47. data/lib/graph_weaver/graph.rb +315 -0
  48. data/lib/graph_weaver/hints.rb +100 -24
  49. data/lib/graph_weaver/in_process.rb +27 -15
  50. data/lib/graph_weaver/input_struct.rb +119 -32
  51. data/lib/graph_weaver/internal/endpoint.rb +80 -0
  52. data/lib/graph_weaver/internal/headers.rb +70 -0
  53. data/lib/graph_weaver/internal/overrides.rb +67 -5
  54. data/lib/graph_weaver/internal/planner.rb +45 -15
  55. data/lib/graph_weaver/internal/refusal.rb +49 -0
  56. data/lib/graph_weaver/internal/schemas.rb +23 -9
  57. data/lib/graph_weaver/internal/selection.rb +34 -0
  58. data/lib/graph_weaver/internal/server_input.rb +251 -0
  59. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  60. data/lib/graph_weaver/internal/unused.rb +287 -0
  61. data/lib/graph_weaver/internal/values.rb +40 -4
  62. data/lib/graph_weaver/internal.rb +249 -14
  63. data/lib/graph_weaver/log_subscriber.rb +74 -0
  64. data/lib/graph_weaver/logging.rb +163 -19
  65. data/lib/graph_weaver/query_module.rb +44 -3
  66. data/lib/graph_weaver/railtie.rb +237 -17
  67. data/lib/graph_weaver/representation.rb +55 -17
  68. data/lib/graph_weaver/result_struct.rb +90 -0
  69. data/lib/graph_weaver/retry.rb +45 -13
  70. data/lib/graph_weaver/rspec.rb +404 -93
  71. data/lib/graph_weaver/schema_loader.rb +266 -56
  72. data/lib/graph_weaver/tasks.rb +380 -89
  73. data/lib/graph_weaver/testing/cassette.rb +34 -10
  74. data/lib/graph_weaver/testing/endpoint.rb +107 -0
  75. data/lib/graph_weaver/testing/failure.rb +69 -12
  76. data/lib/graph_weaver/testing/fake_client.rb +164 -45
  77. data/lib/graph_weaver/testing/router.rb +64 -13
  78. data/lib/graph_weaver/testing.rb +200 -58
  79. data/lib/graph_weaver/transport/faraday.rb +41 -8
  80. data/lib/graph_weaver/transport/http.rb +48 -6
  81. data/lib/graph_weaver/transport.rb +134 -27
  82. data/lib/graph_weaver/version.rb +1 -1
  83. data/lib/graph_weaver.rb +495 -106
  84. metadata +71 -3
  85. data/CHANGELOG.md +0 -2355
@@ -27,19 +27,25 @@ module GraphWeaver
27
27
  unknown = hash.keys.map(&:to_s) - known
28
28
  return if unknown.empty?
29
29
 
30
- hints = unknown.map do |key|
31
- prop = GraphWeaver::Inflect.underscore(key)
32
- suggestion = if known.include?(prop)
33
- prop # a wire-cased key — the exact snake_case prop exists
34
- else
35
- GraphWeaver::Internal::Util.did_you_mean(known, prop)
36
- end
37
- suggestion ? "#{key} (did you mean '#{suggestion}'?)" : key
30
+ suggestions = unknown.to_h do |key|
31
+ prop = GraphWeaver::Codegen.prop_name(key)
32
+ # a wire-cased key — the exact snake_case prop exists
33
+ [key, known.include?(prop) ? prop : GraphWeaver::Internal::Util.did_you_mean(known, prop)]
38
34
  end
35
+ hints = suggestions.map { |key, s| s ? "#{key} (did you mean '#{s}'?)" : key }
36
+
37
+ # the message lists every unknown key; #path names the first, because a
38
+ # path that points at two fields points at neither. No coordinate: the
39
+ # input type defines no such field, so the schema has no name for it.
40
+ # No value either — and not only because the key owns no slot to hold
41
+ # one: filter_parameters can only match the key the caller supplied, and
42
+ # a typo is by definition not the key they meant, so `passwrod` dodges
43
+ # the `password` filter in the one error that names it as the suggestion.
44
+ first = unknown.first
39
45
  raise GraphWeaver::InputError.new(
40
46
  "unknown key(s) for #{struct}: #{hints.join(", ")}",
41
- field: unknown.join(", "),
42
- struct: struct,
47
+ kind: :unknown, path: [first],
48
+ details: { suggestion: suggestions[first] }.compact, struct: struct,
43
49
  )
44
50
  end
45
51
 
@@ -54,7 +60,7 @@ module GraphWeaver
54
60
  rescue GraphWeaver::Error
55
61
  raise # a nested struct already named its own field
56
62
  rescue StandardError => e
57
- raise GraphWeaver::TypeError.new(struct:, message: "#{key}: #{e.message}")
63
+ raise GraphWeaver::CastError.new(struct:, message: "#{key}: #{e.message}")
58
64
  end
59
65
 
60
66
  # A wire value the generated enum doesn't have — the response-side twin
@@ -73,7 +79,7 @@ module GraphWeaver
73
79
  end
74
80
 
75
81
  def self.drifted!(type, value, values)
76
- raise KeyError, "#{value.inspect} is not a #{type} — expected one of: " \
82
+ raise KeyError, "#{GraphWeaver::Internal::Redact.shown(value)} is not a #{type} — expected one of: " \
77
83
  "#{values.sort.join(", ")}; a value the server added since you generated " \
78
84
  "needs a regenerate, or register_enum fallback: to absorb them"
79
85
  end
@@ -84,7 +90,13 @@ module GraphWeaver
84
90
  # its raw integer primary key is the case that keeps happening — so
85
91
  # say that GraphQL requires the quotes, and how to take it anyway.
86
92
  def self.cast_message(struct, data, error)
87
- message = error.message.sub(GraphWeaver::TypeError::SORBET_CALLER, "")
93
+ # Shape drift first, because sorbet's account of it is wrong: see
94
+ # drifted_shape. Otherwise sorbet's message is the better one — it names
95
+ # the prop and the value.
96
+ drift = drifted_shape(struct, data)
97
+ return drift if drift
98
+
99
+ message = error.message.sub(GraphWeaver::CastError::SORBET_CALLER, "")
88
100
  keys = unquoted_keys(struct, data)
89
101
  return message if keys.empty?
90
102
 
@@ -93,6 +105,67 @@ module GraphWeaver
93
105
  'register the scalar loosely: GraphWeaver.register_scalar("ID", "T.untyped")'
94
106
  end
95
107
 
108
+ # A response value whose SHAPE its prop can never hold — an object where a
109
+ # list belongs, a scalar inside a list of objects. sorbet catches these in
110
+ # the CALLER's frame, since it is the child's `data` parameter that fails:
111
+ # the parent brands the error, so the struct named is the parent and the
112
+ # key is named nowhere. Worse, a parent mapping over an object has already
113
+ # let Hash#map turn it into [key, value] pairs, so sorbet reports a list of
114
+ # strings the server never sent.
115
+ #
116
+ # Returns the located message, or nil when nothing is out of shape.
117
+ def self.drifted_shape(struct, data)
118
+ return unless data.is_a?(Hash) && struct.respond_to?(:props)
119
+
120
+ props = struct.props
121
+ data.filter_map do |key, value|
122
+ prop = props[GraphWeaver::Codegen.prop_name(key.to_s).to_sym]
123
+ # :type_object keeps the nilable-ness :type strips — a null where the
124
+ # schema allows one is not drift, and must not be blamed for a sibling's
125
+ prop && shape_drift(T::Utils.coerce(prop[:type_object]), value, key.to_s)
126
+ end.first
127
+ end
128
+ private_class_method :drifted_shape
129
+
130
+ # Only the two shapes a server can get wrong are modelled: a nested struct
131
+ # arrives as an object, a list of them as a list. A scalar prop says
132
+ # nothing here — one that casts already names itself through Hints.field,
133
+ # and T.untyped holds anything.
134
+ def self.shape_drift(type, value, path)
135
+ return if value.nil? && type.valid?(nil)
136
+
137
+ core = type.is_a?(T::Types::Union) ? type.types.find { |t| !t.valid?(nil) } : type
138
+ want =
139
+ if core.is_a?(T::Types::TypedArray) then :list
140
+ elsif core.is_a?(T::Types::Simple) && core.raw_type < T::Struct then :object
141
+ end
142
+ return if want.nil?
143
+
144
+ unless want == :list ? value.is_a?(Array) : value.is_a?(Hash)
145
+ return "#{path}: expected #{want == :list ? "a list" : "an object"}, " \
146
+ "but the server sent #{wire_kind(value)}"
147
+ end
148
+ return unless want == :list
149
+
150
+ value.each_with_index.filter_map { |element, i| shape_drift(core.type, element, "#{path}.#{i}") }.first
151
+ end
152
+ private_class_method :shape_drift
153
+
154
+ # What arrived, named as JSON names it. sorbet reports the Ruby type of
155
+ # whatever the cast had half-built by then, which is a different thing.
156
+ def self.wire_kind(value)
157
+ case value
158
+ when nil then "null"
159
+ when Hash then "an object"
160
+ when Array then "a list"
161
+ when String then "a string"
162
+ when Numeric then "a number"
163
+ when true, false then "a boolean"
164
+ else "a #{value.class}"
165
+ end
166
+ end
167
+ private_class_method :wire_kind
168
+
96
169
  # Response keys whose prop would take a String but whose value is
97
170
  # another JSON scalar. Narrow on purpose: a prop that casts (a Date, an
98
171
  # enum) legitimately arrives as some other type, so only the
@@ -104,7 +177,7 @@ module GraphWeaver
104
177
  data.filter_map do |key, value|
105
178
  next unless value.is_a?(Numeric) || value == true || value == false
106
179
 
107
- prop = props[GraphWeaver::Inflect.underscore(key.to_s).to_sym]
180
+ prop = props[GraphWeaver::Codegen.prop_name(key.to_s).to_sym]
108
181
  next unless prop
109
182
 
110
183
  # :type is a raw Class for a bare-class prop, a T::Types::Base otherwise
@@ -114,26 +187,29 @@ module GraphWeaver
114
187
  end
115
188
  private_class_method :unquoted_keys
116
189
 
190
+ # No matching respond_to_missing?. The hint is an answer about a call that
191
+ # was actually made; respond_to? is a question about the object's shape,
192
+ # and a near miss is not a method this struct has. Answering true broke the
193
+ # standard guard — `obj.pet if obj.respond_to?(:pet)` raised on the very
194
+ # typo the hint exists for — which costs more than `#method(:nmae)` raising
195
+ # Ruby's own bare NameError.
196
+ # Kernel.raise, not bare raise: this module is mixed into every generated
197
+ # struct, so a prop named `raise` would shadow it with a zero-arity reader.
198
+ # Codegen reserves the name; qualifying it here needs no such rule to hold.
117
199
  def method_missing(name, *args, &block)
118
200
  if args.empty? && (hint = prop_hint(name.to_s))
119
- raise NoMethodError, "undefined method '#{name}' for #{self.class} — #{hint}"
201
+ Kernel.raise NoMethodError, "undefined method '#{name}' for #{self.class} — #{hint}"
120
202
  end
121
203
 
122
204
  super
123
205
  end
124
206
 
125
- # keeps #method and #respond_to? agreeing with method_missing — without
126
- # it `struct.method(:nmae)` raises a bare NameError while `struct.nmae`
127
- # gets the hint
128
- def respond_to_missing?(name, include_private = false)
129
- !!prop_hint(name.to_s) || super
130
- end
131
-
132
207
  private
133
208
 
134
209
  def prop_hint(name)
135
- prop = GraphWeaver::Inflect.underscore(name)
136
- # method_defined?, not respond_to? respond_to_missing? lands back here
210
+ prop = GraphWeaver::Codegen.prop_name(name)
211
+ # method_defined? rather than respond_to?, which a host's own
212
+ # respond_to_missing? could answer for a method it doesn't define
137
213
  if prop != name && T.unsafe(self.class).method_defined?(prop)
138
214
  return "GraphQL fields generate snake_case props; use '#{prop}'"
139
215
  end
@@ -5,6 +5,7 @@ require "json"
5
5
 
6
6
  require_relative "errors"
7
7
  require_relative "internal"
8
+ require_relative "context_seam"
8
9
  require_relative "parsing"
9
10
  require_relative "transport"
10
11
 
@@ -32,9 +33,13 @@ require_relative "transport"
32
33
  # is usually the whole reason you're running in-process.
33
34
  class GraphWeaver::InProcess
34
35
  include GraphWeaver::Parsing
36
+ # #context/#context= plus the lock over them: Testing::Endpoint answers a
37
+ # `context:` proc from one request's headers by writing this field, so the
38
+ # field's owner owns the lock
39
+ include GraphWeaver::ContextSeam
35
40
 
36
- # the schema queries run against, and the context handed to every one
37
- attr_reader :schema, :context
41
+ # the schema queries run against
42
+ attr_reader :schema
38
43
 
39
44
  def initialize(schema, context: {})
40
45
  unless schema.respond_to?(:execute)
@@ -42,40 +47,38 @@ class GraphWeaver::InProcess
42
47
  end
43
48
 
44
49
  @schema = schema
45
- @context = context
50
+ init_context_seam(context)
46
51
  end
47
52
 
48
53
  def execute(query, variables: {}, operation_name: nil)
49
54
  operation_name ||= GraphWeaver::Internal::Wire.operation_name(query)
50
- payload = { url: nil, schema: @schema.to_s, operation: operation_name }
55
+ payload = { url: nil, schema: schema_label, operation: operation_name, client: self.class,
56
+ kind: GraphWeaver::Internal::Wire.kind(query) }
51
57
 
52
58
  GraphWeaver::Internal::Log.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
53
- perform(query, variables, operation_name, payload)
59
+ perform(query, variables, operation_name)
54
60
  end
55
61
  end
56
62
 
57
63
  # The query itself. Separate from execute so the instrumenter wraps a
58
64
  # call rather than a block this method returns out of.
59
- private def perform(query, variables, operation_name, payload)
65
+ private def perform(query, variables, operation_name)
60
66
  # same tag/truncation as the network transports, so one log reads the
61
67
  # same whichever side of the seam a query ran on
62
68
  tag = GraphWeaver.logger && GraphWeaver::Internal::Wire.log_tag(operation_name)
63
69
 
64
70
  GraphWeaver::Internal::Log.log(:debug) do
65
- "in-process #{@schema} #{tag} variables=#{JSON.generate(GraphWeaver::Internal::Log.filter_variables(variables))}\n" \
71
+ "in-process #{schema_label} #{tag} variables=#{GraphWeaver::Internal::Log.variables_for_log(variables)}\n" \
66
72
  "#{GraphWeaver::Internal::Wire.truncate_for_log(query)}"
67
73
  end
68
74
 
69
- result = GraphWeaver::Internal::Log.log_timed(:debug, "in-process #{@schema} #{tag} completed") do
75
+ result = GraphWeaver::Internal::Log.log_timed(:debug, "in-process #{schema_label} #{tag} completed") do
70
76
  # a copy per query: graphql-ruby writes a resolver's `context[...] =`
71
77
  # into the hash it is handed, and one client serves every request
72
- @schema.execute(query, variables:, operation_name:, context: @context.dup)
78
+ @schema.execute(query, variables:, operation_name:,
79
+ context: GraphWeaver::Internal::Util.context!(@context).dup)
73
80
  end
74
81
 
75
- # the same key the network transports set, so one instrumenter
76
- # subscriber reads both sides of the seam without branching — a
77
- # resolver raise rides the ServerError(500) the hook already sees
78
- payload[:status] = 200
79
82
  result
80
83
  rescue GraphWeaver::Error
81
84
  raise
@@ -83,11 +86,20 @@ class GraphWeaver::InProcess
83
86
  # a resolver blew up. The same failure over HTTP arrives as a 500, so
84
87
  # raise what HTTP would — code that rescues GraphWeaver::Error, or
85
88
  # branches on ServerError#status, behaves the same either side.
86
- raise GraphWeaver::ServerError.new(status: 500, body: "#{e.class}: #{e.message}")
89
+ # detail:, not body: there was no response, so there are no bytes to
90
+ # hold, and the diagnosis is this process's own exception
91
+ raise GraphWeaver::ServerError.new(
92
+ status: 500, detail: "#{e.class}: #{GraphWeaver::Internal::Redact.cap(e.message)}",
93
+ )
87
94
  end
88
95
 
89
96
  # never leak the context (session tokens, current_user) through logs or
90
97
  # exceptions — an in-process client inspects as its schema, nothing more
91
- def inspect = "#<#{self.class.name} schema=#{@schema}>"
98
+ def inspect = "#<#{self.class.name} schema=#{schema_label}>"
92
99
  alias to_s inspect
100
+
101
+ # What to call this schema in a log line or an instrumentation payload. A
102
+ # schema built from SDL is an anonymous class, whose #to_s is its object
103
+ # address — a new value every boot, and unbounded cardinality as an APM tag.
104
+ private def schema_label = @schema.name || "anonymous"
93
105
  end
@@ -20,8 +20,10 @@ module GraphWeaver
20
20
  include Kernel # for sorbet: hosts are T::Structs
21
21
 
22
22
  # serializer/coercer are code-as-data from the generated file; nil
23
- # means identity (the wire value passes through untouched)
24
- Field = Data.define(:prop, :wire, :required, :serializer, :coercer)
23
+ # means identity (the wire value passes through untouched). coordinate
24
+ # is the schema's name for the slot ("PetFilter.species"), so a refusal
25
+ # can say where it happened without reflecting at runtime.
26
+ Field = Data.define(:prop, :wire, :required, :serializer, :coercer, :coordinate)
25
27
 
26
28
  # An enum reaching the library as input — an execute kwarg or an input
27
29
  # field — as the member or its wire value. Generated code calls these
@@ -34,6 +36,26 @@ module GraphWeaver
34
36
  type.try_deserialize(value) || invalid_enum!(type, value, type.values.map(&:serialize))
35
37
  end
36
38
 
39
+ # A list element's index, prepended when something inside it refused —
40
+ # `where._and.0._not.species` needs the 0 to name one form field.
41
+ #
42
+ # A nested input's coercer raises an InputError that already holds a path;
43
+ # every LEAF coercer (Coerce.*, .enum) raises a branded plain error instead,
44
+ # so without the second branch the index was dropped for every list of
45
+ # leaves — and an element that wasn't a list at all reached the caller as a
46
+ # raw NoMethodError from the inner `.map`.
47
+ # `value` is the element itself, so a refused leaf carries what was refused
48
+ def self.element(index, value = nil)
49
+ yield
50
+ rescue GraphWeaver::InputError => e
51
+ raise e.within(index)
52
+ rescue StandardError => e
53
+ raise GraphWeaver::InputError.new(
54
+ e.message, kind: GraphWeaver::Internal::Refusal.kind_of(e), path: [index], value:,
55
+ details: GraphWeaver::Internal::Refusal.details_of(e),
56
+ )
57
+ end
58
+
37
59
  # the same, for an enum mapped onto an app-owned T::Enum (register_enum),
38
60
  # where the wire table rather than the type knows the accepted values
39
61
  def self.mapped_enum(type, table, value)
@@ -44,28 +66,44 @@ module GraphWeaver
44
66
 
45
67
  # Names the input field a coercion refused — a scalar's coercer, an
46
68
  # enum's, or a nested input's — since the complaint underneath is about
47
- # the value alone. A nested error that already named a field keeps it:
48
- # the innermost input is the one that actually held the bad value.
49
- def self.field(struct, prop)
69
+ # the value alone. A nested error that already named a field keeps its
70
+ # sentence, and only grows a path segment: the innermost input is the one
71
+ # that actually held the bad value.
72
+ def self.field(struct, field, raw)
73
+ prop = field.prop
50
74
  yield
51
75
  rescue GraphWeaver::InputError => e
52
- raise if e.field && !GraphWeaver::Internal::Redact.filtered?(prop)
76
+ redact = GraphWeaver::Internal::Redact
77
+ raise e.within(field.wire, prop:) if e.field && !redact.filtered?(prop)
53
78
 
54
79
  raise GraphWeaver::InputError.new(
55
- "#{prop}: #{GraphWeaver::Internal::Redact.detail(prop, e.message)}",
56
- field: e.field || prop.to_s, struct: e.struct || struct,
80
+ "#{prop}: #{redact.detail(prop, e.message)}",
81
+ kind: e.kind, path: [field.wire, *e.path], coordinate: e.coordinate || field.coordinate,
82
+ # #value is the value AT #path: this layer owns it only when nothing
83
+ # inner named a field (so a missing one stays valueless, as it is)
84
+ value: redact.value(prop, e.path.empty? ? raw : e.value),
85
+ details: e.details, struct: e.struct || struct,
57
86
  )
58
87
  rescue StandardError => e
88
+ redact = GraphWeaver::Internal::Redact
59
89
  raise GraphWeaver::InputError.new(
60
- "#{prop}: #{GraphWeaver::Internal::Redact.detail(prop, e.message)}", field: prop.to_s, struct:,
90
+ "#{prop}: #{redact.detail(prop, e.message)}",
91
+ kind: GraphWeaver::Internal::Refusal.kind_of(e), path: [field.wire],
92
+ coordinate: field.coordinate, value: redact.value(prop, raw),
93
+ details: GraphWeaver::Internal::Refusal.details_of(e), struct:,
61
94
  )
62
95
  end
63
96
 
64
97
  # Raised bare, like Hints.drifted!: the enclosing .field or Coerce.variable
65
98
  # knows the key, and so is the only layer that can decide whether this
66
- # value may be named.
99
+ # value may be named. The verdict rides along, since nothing outside here
100
+ # can tell an out-of-range enum from any other KeyError.
67
101
  def self.invalid_enum!(type, value, values)
68
- raise KeyError, "#{value.inspect} is not a valid #{type} — expected one of: #{values.sort.join(", ")}"
102
+ shown = GraphWeaver::Internal::Redact.shown(value)
103
+ raise GraphWeaver::Internal::Refusal.brand(
104
+ KeyError.new("#{shown} is not a valid #{type} — expected one of: #{values.sort.join(", ")}"),
105
+ :not_a_member, members: values.sort,
106
+ )
69
107
  end
70
108
  private_class_method :invalid_enum!
71
109
 
@@ -87,24 +125,51 @@ module GraphWeaver
87
125
  value = public_send(field.prop)
88
126
  next if value.nil? && !field.required && !given&.include?(field.prop)
89
127
 
90
- out[field.wire] = field.serializer && !value.nil? ? field.serializer.call(value) : value
128
+ out[field.wire] =
129
+ begin
130
+ field.serializer && !value.nil? ? field.serializer.call(value) : value
131
+ rescue GraphWeaver::InputError => e
132
+ # a nested input's own refusal (@oneOf, a custom serialize:) —
133
+ # every layer prepends the segment that led to it. Kernel.raise,
134
+ # since this module is mixed into the struct and a prop named
135
+ # `raise` would shadow a bare one with a zero-arity reader.
136
+ Kernel.raise e.within(field.wire, prop: field.prop)
137
+ end
91
138
  end
92
139
 
93
140
  # @oneOf declares "exactly one of these, and not null", but every field
94
141
  # is nullable, so nothing before here can enforce it — not the struct's
95
142
  # types, not the server until the round trip
96
- if self.class.const_defined?(:ONE_OF, false) && (wire.size != 1 || wire.values.first.nil?)
97
- supplied_names = wire.empty? ? "none" : wire.keys.sort.join(", ")
98
- raise GraphWeaver::InputError.new(
99
- "#{self.class} is @oneOf — supply exactly one field, non-null, got #{supplied_names}",
100
- struct: self.class,
101
- )
102
- end
143
+ one_of!(wire) if self.class.const_defined?(:ONE_OF, false)
103
144
 
104
145
  wire
105
146
  end
106
147
  alias_method :to_h, :serialize
107
148
 
149
+ # Two different mistakes, and "supply exactly one field" is the wrong
150
+ # sentence for the second: the caller who wrote `{ id: nil }` supplied
151
+ # exactly one field. That one is a missing value, so it says so and names
152
+ # the slot — a form has something to highlight, which the count case
153
+ # (nothing, or several) has no single field to give.
154
+ private def one_of!(wire)
155
+ if wire.size == 1 && wire.values.first.nil?
156
+ name = wire.keys.first
157
+ field = self.class.const_get(:FIELDS).find { |candidate| candidate.wire == name }
158
+ Kernel.raise GraphWeaver::InputError.new(
159
+ "#{self.class} is @oneOf and #{name} was null — supply a value for it, or a different field",
160
+ kind: :missing, path: [field.wire], coordinate: field.coordinate, struct: self.class,
161
+ )
162
+ end
163
+
164
+ return if wire.size == 1
165
+
166
+ Kernel.raise GraphWeaver::InputError.new(
167
+ "#{self.class} is @oneOf — supply exactly one field, non-null, got " \
168
+ "#{wire.empty? ? "none" : wire.keys.sort.join(", ")}",
169
+ struct: self.class,
170
+ )
171
+ end
172
+
108
173
  module ClassMethods
109
174
  include Kernel
110
175
 
@@ -119,7 +184,12 @@ module GraphWeaver
119
184
  # list was expected) is bad input — surface a branded 422, not a raw
120
185
  # NoMethodError from validate_keys!'s `.keys`
121
186
  unless value.is_a?(Hash)
122
- raise GraphWeaver::InputError.new("expected a Hash or #{self}, got #{value.class}", struct: self)
187
+ # the message names the Ruby you may pass; #details is what an app
188
+ # translates for a user, so it speaks the schema's vocabulary
189
+ raise GraphWeaver::InputError.new(
190
+ "expected a Hash or #{self}, got #{value.class}",
191
+ kind: :type_mismatch, details: { type: graphql_name }, struct: self,
192
+ )
123
193
  end
124
194
 
125
195
  # a typo'd key must not silently drop off the wire
@@ -133,17 +203,20 @@ module GraphWeaver
133
203
 
134
204
  # a coercer is arbitrary Ruby — Coerce.integer, Date.iso8601, a
135
205
  # nested .coerce — and its complaint is about the value alone
136
- [field.prop, GraphWeaver::InputStruct.field(self, field.prop) { field.coercer.call(raw) }]
206
+ [field.prop, GraphWeaver::InputStruct.field(self, field, raw) { field.coercer.call(raw) }]
137
207
  end
138
208
 
139
209
  # FIELDS knows which are required, so say what is missing — sorbet's
140
210
  # own complaint describes the symptom ("Can't set .name to nil") and
141
211
  # names only the first one it reaches
142
- missing = fields.select { |field| field.required && supplied[field.prop].nil? }.map(&:prop)
212
+ missing = fields.select { |field| field.required && supplied[field.prop].nil? }
143
213
  unless missing.empty?
214
+ # the message lists every one; #path names the first, because a
215
+ # path that points at two fields points at neither
144
216
  raise GraphWeaver::InputError.new(
145
- "missing required key(s) for #{self}: #{missing.join(", ")}",
146
- field: missing.join(", "), struct: self,
217
+ "missing required key(s) for #{self}: #{missing.map(&:prop).join(", ")}",
218
+ kind: :missing, path: [missing.first.wire],
219
+ coordinate: missing.first.coordinate, struct: self,
147
220
  )
148
221
  end
149
222
 
@@ -152,14 +225,19 @@ module GraphWeaver
152
225
  raise # already contextualized by a nested input / enum coercion
153
226
  rescue ::TypeError, ::ArgumentError, KeyError => e
154
227
  # a wrong-typed field, a missing required field, or an out-of-range
155
- # enum — surface one branded, structured error for a 422. (`::` so the
156
- # rescue catches Ruby's TypeError, not GraphWeaver::TypeError.)
228
+ # enum — surface one branded, structured error for a 422.
157
229
  raise mistyped(supplied) ||
158
- GraphWeaver::InputError.new("invalid input for #{self}: #{e.message}", struct: self)
230
+ GraphWeaver::InputError.new(
231
+ "invalid input for #{self}: #{e.message}",
232
+ kind: GraphWeaver::Internal::Refusal.kind_of(e),
233
+ details: GraphWeaver::Internal::Refusal.details_of(e), struct: self,
234
+ )
159
235
  end
160
236
 
161
237
  private
162
238
 
239
+ def graphql_name = T.unsafe(self).const_get(:GRAPHQL_NAME)
240
+
163
241
  # The prop whose value its own type refuses, reported the way every
164
242
  # other input failure is. Only sorbet stands between a field with no
165
243
  # coercer and the struct, and it names the prop and the value inside
@@ -173,13 +251,22 @@ module GraphWeaver
173
251
  # :type_object carries the nilable-ness the prop was declared with;
174
252
  # :type is that unwrapped, which is the half worth naming — an
175
253
  # absent optional field is nil and legal, and a missing required
176
- # one was reported by name before we got here
177
- next if T::Utils.coerce(info[:type_object]).valid?(value)
254
+ # one was reported by name before we got here.
255
+ #
256
+ # recursively_valid?, which is the predicate the SETTER enforces:
257
+ # #valid? stops at the outermost type, so `[1, 2, 3]` for a
258
+ # T::Array[Float] (a type-string registration, so no coercer ran)
259
+ # passed here while the setter refused it — and the refusal came
260
+ # out blaming the list that held the struct, in sorbet's words.
261
+ next if T::Utils.coerce(info[:type_object]).recursively_valid?(value)
178
262
 
263
+ type = T::Utils.coerce(info[:type]).to_s
179
264
  return GraphWeaver::InputError.new(
180
- "#{prop}: expected #{T::Utils.coerce(info[:type])}, " \
181
- "got #{GraphWeaver::Internal::Redact.detail(prop, value.inspect)}",
182
- field: prop.to_s, struct: self,
265
+ "#{prop}: expected #{type}, got #{GraphWeaver::Internal::Redact.shown(value, prop)}",
266
+ kind: :type_mismatch, path: [prop.to_s],
267
+ coordinate: T.unsafe(self).const_get(:FIELDS).find { |f| f.prop == prop }&.coordinate,
268
+ value: GraphWeaver::Internal::Redact.value(prop, value),
269
+ details: { type: }, struct: self,
183
270
  )
184
271
  end
185
272
  nil
@@ -0,0 +1,80 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "uri"
5
+
6
+ module GraphWeaver
7
+ module Internal
8
+ # The endpoint as the gem is willing to SAY it — in a log line, an
9
+ # exception, an APM payload. Every one of those outlives the request, and a
10
+ # url can carry a credential two ways: its userinfo, and a query parameter
11
+ # (`?access_token=…`). Transport#url stays the real endpoint — that is
12
+ # where requests go, and what `graphql: :wire` stubs on.
13
+ module Endpoint
14
+ class << self
15
+ def safe(url)
16
+ uri = URI(url.to_s)
17
+ # the overwhelmingly common case: a url with nothing to hide, said
18
+ # back exactly as it was configured
19
+ return url.to_s unless uri.userinfo || uri.query
20
+
21
+ query = uri.query
22
+ userinfo = uri.userinfo
23
+ uri.query = nil
24
+ uri.fragment = nil # never sent to a server; nothing to report
25
+
26
+ # URI#userinfo= is a no-op for nil, so the swap happens on the text —
27
+ # "//<userinfo>@" appears once, right after the scheme
28
+ text = uri.to_s
29
+ text = text.sub("//#{userinfo}@", "//#{GraphWeaver::FILTERED}@") if userinfo
30
+ text = "#{text}?#{scrub_query(query)}" if query
31
+ text
32
+ rescue URI::Error
33
+ # a url we can't take apart is one we can't promise to have scrubbed
34
+ GraphWeaver::FILTERED
35
+ end
36
+
37
+ # The endpoint with its credentials REMOVED rather than marked — for a
38
+ # place the url is read back from, such as a dump's provenance, where
39
+ # a "[FILTERED]" would be parsed as a host. Nothing is lost that
40
+ # belonged there: re-introspection authenticates from auth_env.
41
+ def bare(url)
42
+ uri = URI(url.to_s)
43
+ return url.to_s unless uri.userinfo || uri.query
44
+
45
+ uri.user = nil
46
+ uri.password = nil
47
+ uri.fragment = nil
48
+ uri.query = uri.query && drop_secrets(uri.query)
49
+ uri.to_s
50
+ end
51
+
52
+ private
53
+
54
+ def drop_secrets(query)
55
+ kept = query.split("&").reject do |pair|
56
+ name, value = pair.split("=", 2)
57
+ value && Redact.credential?(URI.decode_www_form_component(name))
58
+ end
59
+ kept.join("&") unless kept.empty?
60
+ end
61
+
62
+ # Which query parameters are secret is the same question
63
+ # GraphWeaver.filter_parameters already answers for variables, so a
64
+ # scrubbed log reads the same either side of the seam — widened by the
65
+ # default names, which apply here even when the app has emptied its
66
+ # list (see Redact.credential?). Split rather than decoded and
67
+ # re-encoded: every parameter that stays is printed exactly as it was
68
+ # sent.
69
+ def scrub_query(query)
70
+ query.split("&").map do |pair|
71
+ name, value = pair.split("=", 2)
72
+ next pair if value.nil? || !Redact.credential?(URI.decode_www_form_component(name))
73
+
74
+ "#{name}=#{GraphWeaver::FILTERED}"
75
+ end.join("&")
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end