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
data/lib/graph_weaver/hints.rb
CHANGED
|
@@ -27,19 +27,25 @@ module GraphWeaver
|
|
|
27
27
|
unknown = hash.keys.map(&:to_s) - known
|
|
28
28
|
return if unknown.empty?
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
prop = GraphWeaver::
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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::
|
|
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
|
|
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
|
-
|
|
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::
|
|
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::
|
|
136
|
-
# method_defined
|
|
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
|
|
@@ -36,6 +36,10 @@ class GraphWeaver::InProcess
|
|
|
36
36
|
# the schema queries run against, and the context handed to every one
|
|
37
37
|
attr_reader :schema, :context
|
|
38
38
|
|
|
39
|
+
# settable so Testing::Endpoint can answer a `context:` proc from the
|
|
40
|
+
# request's headers and put it back — the same seam Router#context= is
|
|
41
|
+
attr_writer :context
|
|
42
|
+
|
|
39
43
|
def initialize(schema, context: {})
|
|
40
44
|
unless schema.respond_to?(:execute)
|
|
41
45
|
raise ArgumentError, "expected a graphql-ruby schema class, got #{schema.inspect}"
|
|
@@ -47,35 +51,32 @@ class GraphWeaver::InProcess
|
|
|
47
51
|
|
|
48
52
|
def execute(query, variables: {}, operation_name: nil)
|
|
49
53
|
operation_name ||= GraphWeaver::Internal::Wire.operation_name(query)
|
|
50
|
-
payload = { url: nil, schema:
|
|
54
|
+
payload = { url: nil, schema: schema_label, operation: operation_name, client: self.class }
|
|
51
55
|
|
|
52
56
|
GraphWeaver::Internal::Log.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
|
|
53
|
-
perform(query, variables, operation_name
|
|
57
|
+
perform(query, variables, operation_name)
|
|
54
58
|
end
|
|
55
59
|
end
|
|
56
60
|
|
|
57
61
|
# The query itself. Separate from execute so the instrumenter wraps a
|
|
58
62
|
# call rather than a block this method returns out of.
|
|
59
|
-
private def perform(query, variables, operation_name
|
|
63
|
+
private def perform(query, variables, operation_name)
|
|
60
64
|
# same tag/truncation as the network transports, so one log reads the
|
|
61
65
|
# same whichever side of the seam a query ran on
|
|
62
66
|
tag = GraphWeaver.logger && GraphWeaver::Internal::Wire.log_tag(operation_name)
|
|
63
67
|
|
|
64
68
|
GraphWeaver::Internal::Log.log(:debug) do
|
|
65
|
-
"in-process #{
|
|
69
|
+
"in-process #{schema_label} #{tag} variables=#{GraphWeaver::Internal::Log.variables_for_log(variables)}\n" \
|
|
66
70
|
"#{GraphWeaver::Internal::Wire.truncate_for_log(query)}"
|
|
67
71
|
end
|
|
68
72
|
|
|
69
|
-
result = GraphWeaver::Internal::Log.log_timed(:debug, "in-process #{
|
|
73
|
+
result = GraphWeaver::Internal::Log.log_timed(:debug, "in-process #{schema_label} #{tag} completed") do
|
|
70
74
|
# a copy per query: graphql-ruby writes a resolver's `context[...] =`
|
|
71
75
|
# into the hash it is handed, and one client serves every request
|
|
72
|
-
@schema.execute(query, variables:, operation_name:,
|
|
76
|
+
@schema.execute(query, variables:, operation_name:,
|
|
77
|
+
context: GraphWeaver::Internal::Util.context!(@context).dup)
|
|
73
78
|
end
|
|
74
79
|
|
|
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
80
|
result
|
|
80
81
|
rescue GraphWeaver::Error
|
|
81
82
|
raise
|
|
@@ -88,6 +89,11 @@ class GraphWeaver::InProcess
|
|
|
88
89
|
|
|
89
90
|
# never leak the context (session tokens, current_user) through logs or
|
|
90
91
|
# exceptions — an in-process client inspects as its schema, nothing more
|
|
91
|
-
def inspect = "#<#{self.class.name} schema=#{
|
|
92
|
+
def inspect = "#<#{self.class.name} schema=#{schema_label}>"
|
|
92
93
|
alias to_s inspect
|
|
94
|
+
|
|
95
|
+
# What to call this schema in a log line or an instrumentation payload. A
|
|
96
|
+
# schema built from SDL is an anonymous class, whose #to_s is its object
|
|
97
|
+
# address — a new value every boot, and unbounded cardinality as an APM tag.
|
|
98
|
+
private def schema_label = @schema.name || "anonymous"
|
|
93
99
|
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
|
-
|
|
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
|
|
48
|
-
# the innermost input is the one
|
|
49
|
-
|
|
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
|
-
|
|
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}: #{
|
|
56
|
-
|
|
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}: #{
|
|
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
|
-
|
|
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] =
|
|
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)
|
|
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
|
-
|
|
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
|
|
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? }
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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 #{
|
|
181
|
-
|
|
182
|
-
|
|
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,78 @@
|
|
|
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.filtered?(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. Split rather
|
|
65
|
+
# than decoded and re-encoded: every parameter that stays is printed
|
|
66
|
+
# exactly as it was sent.
|
|
67
|
+
def scrub_query(query)
|
|
68
|
+
query.split("&").map do |pair|
|
|
69
|
+
name, value = pair.split("=", 2)
|
|
70
|
+
next pair if value.nil? || !Redact.filtered?(URI.decode_www_form_component(name))
|
|
71
|
+
|
|
72
|
+
"#{name}=#{GraphWeaver::FILTERED}"
|
|
73
|
+
end.join("&")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GraphWeaver
|
|
5
|
+
module Internal
|
|
6
|
+
# Response headers, as ServerError carries them. HTTP field names are
|
|
7
|
+
# case-insensitive (RFC 9110 §5.1) and the spelling a caller reaches for
|
|
8
|
+
# is the one the server sent ("Retry-After"), while transports store them
|
|
9
|
+
# downcased — so the name is folded on the way in and on the lookups that
|
|
10
|
+
# take one: #[], #fetch, #dig, #key?. Only lookup is forgiving: iteration,
|
|
11
|
+
# #keys and #to_h stay one spelling, which is what a log line and a doc
|
|
12
|
+
# can promise.
|
|
13
|
+
class Headers < Hash
|
|
14
|
+
# tells "no default given" from a default of nil
|
|
15
|
+
MISSING = Object.new
|
|
16
|
+
private_constant :MISSING
|
|
17
|
+
|
|
18
|
+
# A Hash of any casing as one of these; already one, untouched.
|
|
19
|
+
def self.wrap(headers)
|
|
20
|
+
return headers if headers.is_a?(self)
|
|
21
|
+
|
|
22
|
+
headers.each_with_object(new) { |(name, value), out| out.store(fold(name), value) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.fold(name) = name.to_s.downcase
|
|
26
|
+
|
|
27
|
+
def [](name) = super(Headers.fold(name))
|
|
28
|
+
|
|
29
|
+
# spelled out rather than forwarded: sorbet can't splat into Hash#fetch's
|
|
30
|
+
# overloads, and a block beats a default there (Ruby warns if given both)
|
|
31
|
+
def fetch(name, default = MISSING, &block)
|
|
32
|
+
folded = Headers.fold(name)
|
|
33
|
+
return super(folded, &block) if block || default.equal?(MISSING)
|
|
34
|
+
|
|
35
|
+
super(folded, default)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Hash#dig reads the slot directly rather than through #[], so the fold
|
|
39
|
+
# has to happen here; a header value is a String, so `rest` can only error
|
|
40
|
+
def dig(name, *rest)
|
|
41
|
+
value = self[name]
|
|
42
|
+
rest.empty? ? value : value&.dig(*rest)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def key?(name) = super(Headers.fold(name))
|
|
46
|
+
alias_method :has_key?, :key?
|
|
47
|
+
alias_method :include?, :key?
|
|
48
|
+
alias_method :member?, :key?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|