graph_weaver 0.6.1 → 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 +1447 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +20 -6
- 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 +497 -104
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +28 -0
- data/docs/scalars.md +190 -26
- data/docs/testing.md +457 -58
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +328 -3
- 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 +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +404 -197
- 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 +40 -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 +474 -106
- metadata +56 -1
data/lib/graph_weaver/errors.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "sorbet-runtime"
|
|
|
5
5
|
require "time" # Time.httpdate, for Retry-After
|
|
6
6
|
|
|
7
7
|
require_relative "inflect"
|
|
8
|
+
require_relative "internal/headers"
|
|
8
9
|
require_relative "logging"
|
|
9
10
|
|
|
10
11
|
module GraphWeaver
|
|
@@ -13,8 +14,8 @@ module GraphWeaver
|
|
|
13
14
|
# a JSON-ready Hash (string keys) for logging, agents, or surfacing
|
|
14
15
|
# structured failures to users. One subclass per failure site —
|
|
15
16
|
# {TransportError} (no response came back), {ServerError} (non-2xx),
|
|
16
|
-
# {QueryError} (GraphQL-level errors), {
|
|
17
|
-
# cast), {InputError} (bad variables), {
|
|
17
|
+
# {QueryError} (GraphQL-level errors), {CastError} (response wouldn't
|
|
18
|
+
# cast), {InputError} (bad variables), {QueryValidationError} (build time),
|
|
18
19
|
# {ConfigurationError} (setup judged against your schema) — each merging
|
|
19
20
|
# its specifics into #to_h.
|
|
20
21
|
class Error < StandardError
|
|
@@ -25,9 +26,15 @@ module GraphWeaver
|
|
|
25
26
|
sig { params(args: T.untyped).void }
|
|
26
27
|
def initialize(*args)
|
|
27
28
|
super
|
|
28
|
-
GraphWeaver::Internal::Log.log(:warn) { "#{self.class.name}: #{message}" }
|
|
29
|
+
GraphWeaver::Internal::Log.log(:warn) { "#{self.class.name}: #{message}" } if raised?
|
|
29
30
|
end
|
|
30
31
|
|
|
32
|
+
# Every error here is raised where it is built — except an InputError read
|
|
33
|
+
# back off a response, which is a value. A warn line there would claim a
|
|
34
|
+
# raise that never happened.
|
|
35
|
+
sig { overridable.returns(T::Boolean) }
|
|
36
|
+
private def raised? = true
|
|
37
|
+
|
|
31
38
|
sig { overridable.returns(T::Hash[String, T.untyped]) }
|
|
32
39
|
def to_h
|
|
33
40
|
{ "error" => self.class.name, "message" => message }
|
|
@@ -42,9 +49,23 @@ module GraphWeaver
|
|
|
42
49
|
class TransportError < Error
|
|
43
50
|
extend T::Sig
|
|
44
51
|
|
|
52
|
+
# The endpoint the request never reached, as it is safe to say — userinfo
|
|
53
|
+
# and secret query parameters folded out (see Internal::Endpoint). nil
|
|
54
|
+
# when there is no endpoint: a `Testing::Failure` client, a fake.
|
|
55
|
+
sig { returns(T.nilable(String)) }
|
|
56
|
+
attr_reader :url
|
|
57
|
+
|
|
58
|
+
sig { params(message: T.untyped, url: T.nilable(String)).void }
|
|
59
|
+
def initialize(message = nil, url: nil)
|
|
60
|
+
@url = url
|
|
61
|
+
super("#{message}#{" — POST #{url}" if url}")
|
|
62
|
+
end
|
|
63
|
+
|
|
45
64
|
sig { override.returns(T::Hash[String, T.untyped]) }
|
|
46
65
|
def to_h
|
|
47
|
-
|
|
66
|
+
# url only when there is one — a fake client has no endpoint, and a key
|
|
67
|
+
# spelled nil reads as "we lost it"
|
|
68
|
+
super.merge("cause" => cause&.class&.name).merge({ "url" => url }.compact)
|
|
48
69
|
end
|
|
49
70
|
end
|
|
50
71
|
|
|
@@ -90,19 +111,29 @@ module GraphWeaver
|
|
|
90
111
|
sig { returns(T.untyped) }
|
|
91
112
|
attr_reader :body
|
|
92
113
|
|
|
93
|
-
# The response headers
|
|
94
|
-
#
|
|
95
|
-
#
|
|
114
|
+
# The response headers — the rate-limit budget (x-ratelimit-remaining),
|
|
115
|
+
# the request id your provider wants in a support ticket, Retry-After.
|
|
116
|
+
# Looked up in any casing, iterated downcased. Empty when the transport
|
|
117
|
+
# had none.
|
|
96
118
|
sig { returns(T::Hash[String, String]) }
|
|
97
119
|
attr_reader :headers
|
|
98
120
|
|
|
99
|
-
|
|
100
|
-
|
|
121
|
+
# The endpoint that answered, as it is safe to say — userinfo and secret
|
|
122
|
+
# query parameters folded out (see Internal::Endpoint). nil when there is
|
|
123
|
+
# no endpoint: an in-process schema, a `Testing::Failure` client.
|
|
124
|
+
sig { returns(T.nilable(String)) }
|
|
125
|
+
attr_reader :url
|
|
126
|
+
|
|
127
|
+
sig do
|
|
128
|
+
params(status: Integer, body: T.untyped, headers: T::Hash[String, String], url: T.nilable(String)).void
|
|
129
|
+
end
|
|
130
|
+
def initialize(status:, body: nil, headers: {}, url: nil)
|
|
101
131
|
@status = status
|
|
102
132
|
@body = body
|
|
103
|
-
@
|
|
133
|
+
@url = url
|
|
134
|
+
@headers = T.let(GraphWeaver::Internal::Headers.wrap(headers), T::Hash[String, String])
|
|
104
135
|
snippet = body.to_s.empty? ? "" : ": #{body.to_s[0, 500]}"
|
|
105
|
-
super("HTTP #{status}#{snippet}#{" — #{hint}" if hint}")
|
|
136
|
+
super("HTTP #{status}#{snippet}#{" — #{hint}" if hint}#{" — POST #{url}" if url}")
|
|
106
137
|
end
|
|
107
138
|
|
|
108
139
|
# What to do about this status, where the status says it. A redirect is
|
|
@@ -148,7 +179,7 @@ module GraphWeaver
|
|
|
148
179
|
def to_h
|
|
149
180
|
# the raw headers stay off the machine side — Set-Cookie and
|
|
150
181
|
# friends don't belong in a log line; read #headers for those
|
|
151
|
-
super.merge("status" => status, "retry_after" => retry_after).compact
|
|
182
|
+
super.merge("status" => status, "retry_after" => retry_after, "url" => url).compact
|
|
152
183
|
end
|
|
153
184
|
end
|
|
154
185
|
|
|
@@ -247,10 +278,14 @@ module GraphWeaver
|
|
|
247
278
|
|
|
248
279
|
# The codes servers use to say "you're going too fast". No standard
|
|
249
280
|
# exists, so this is the union of what the big graphs actually send:
|
|
250
|
-
# Shopify THROTTLED, GitHub RATE_LIMITED, Apollo
|
|
281
|
+
# Shopify THROTTLED, GitHub RATE_LIMITED, Apollo Router
|
|
282
|
+
# REQUEST_RATE_LIMITED (measured against v2.17.0), Hasura the rest.
|
|
251
283
|
# Pass it to Retry (retry_codes:) rather than hand-writing strings.
|
|
252
284
|
THROTTLE_CODES = T.let(
|
|
253
|
-
%w[
|
|
285
|
+
%w[
|
|
286
|
+
THROTTLED RATE_LIMITED RATE_LIMIT_EXCEEDED TOO_MANY_REQUESTS
|
|
287
|
+
REQUEST_LIMIT_EXCEEDED REQUEST_RATE_LIMITED
|
|
288
|
+
].freeze,
|
|
254
289
|
T::Array[String],
|
|
255
290
|
)
|
|
256
291
|
|
|
@@ -262,6 +297,31 @@ module GraphWeaver
|
|
|
262
297
|
THROTTLE_CODES.include?(code)
|
|
263
298
|
end
|
|
264
299
|
|
|
300
|
+
# Codes that say "this error is about the input you sent", and what each
|
|
301
|
+
# one means. `BAD_USER_INPUT` is the ecosystem's coarse bucket (Apollo's,
|
|
302
|
+
# and what docs/errors.md asks a server to stamp); the rest are
|
|
303
|
+
# graphql-ruby's own rule names, measured against 2.6.10. Nothing else is
|
|
304
|
+
# claimed: a `validates:` failure reaches the wire as a bare sentence
|
|
305
|
+
# with no extensions at all, indistinguishable from "the database is
|
|
306
|
+
# down", and attaching that to a form field is worse than missing it.
|
|
307
|
+
INPUT_CODES = T.let({
|
|
308
|
+
"BAD_USER_INPUT" => :refused,
|
|
309
|
+
"argumentLiteralsIncompatible" => :type_mismatch,
|
|
310
|
+
"variableMismatch" => :type_mismatch,
|
|
311
|
+
"missingRequiredInputObjectAttribute" => :missing,
|
|
312
|
+
"argumentNotAccepted" => :unknown,
|
|
313
|
+
}.freeze, T::Hash[String, Symbol])
|
|
314
|
+
|
|
315
|
+
# This error as input problems — [] when it isn't about the input at all.
|
|
316
|
+
# Plural because one variable-coercion error genuinely carries N of them,
|
|
317
|
+
# and dropping all but the first is the quiet loss this library refuses.
|
|
318
|
+
# See docs/errors.md ("What your server can send") for the three shapes
|
|
319
|
+
# read here, and docs/i18n.md for the vocabulary.
|
|
320
|
+
sig { returns(T::Array[InputError]) }
|
|
321
|
+
def input_errors
|
|
322
|
+
@input_errors ||= T.let(GraphWeaver::Internal::ServerInput.read(self), T.nilable(T::Array[InputError]))
|
|
323
|
+
end
|
|
324
|
+
|
|
265
325
|
# The field the error points at, as a stable dotted path with list
|
|
266
326
|
# indices stripped — ["people", 3, "email"] => "people.email". The
|
|
267
327
|
# parseable key for grouping/reporting (the raw #path keeps indices).
|
|
@@ -349,6 +409,20 @@ module GraphWeaver
|
|
|
349
409
|
errors.any?(&:throttled?)
|
|
350
410
|
end
|
|
351
411
|
|
|
412
|
+
# Every error here that is about the input we sent, as InputError values
|
|
413
|
+
# — the same object a client-side refusal raises, so one renderer serves
|
|
414
|
+
# both halves. [] when the server rejected nothing about the input, or
|
|
415
|
+
# said nothing that identifies it as input (see GraphQLError::INPUT_CODES).
|
|
416
|
+
#
|
|
417
|
+
# #field is nil where the server stated no input path, so a form needs
|
|
418
|
+
# the :base branch:
|
|
419
|
+
#
|
|
420
|
+
# response.input_errors.each { |e| form.errors.add(e.field&.underscore || :base, e.message) }
|
|
421
|
+
sig { returns(T::Array[InputError]) }
|
|
422
|
+
def input_errors
|
|
423
|
+
errors.flat_map(&:input_errors)
|
|
424
|
+
end
|
|
425
|
+
|
|
352
426
|
# Errors grouped by the field they point at (index-stripped dotted
|
|
353
427
|
# path; nil key for global errors) — iterate with each_error:
|
|
354
428
|
#
|
|
@@ -383,13 +457,34 @@ module GraphWeaver
|
|
|
383
457
|
node = if segment.is_a?(Integer) || segment.to_s.match?(/\A\d+\z/)
|
|
384
458
|
node.is_a?(Array) ? node[segment.to_i] : nil
|
|
385
459
|
else
|
|
386
|
-
|
|
387
|
-
node.respond_to?(prop) ? node.public_send(prop) : nil
|
|
460
|
+
field_value(node, segment)
|
|
388
461
|
end
|
|
389
462
|
return if node.nil?
|
|
390
463
|
end
|
|
391
464
|
|
|
392
|
-
node
|
|
465
|
+
field_value(node, "id")
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# The value of the field a path segment names, or nil when this struct
|
|
469
|
+
# hasn't got one. Read off the struct's own props, never `respond_to?` —
|
|
470
|
+
# the path comes from the SERVER, and every Object method answers that:
|
|
471
|
+
# a segment named `freeze` froze the caller's result and then reported an
|
|
472
|
+
# id for a field that doesn't exist, `display` printed the struct to
|
|
473
|
+
# stdout, and `tap`/`send`/`method` raised out of error handling.
|
|
474
|
+
#
|
|
475
|
+
# Two candidates, because a prop that would shadow a method the struct
|
|
476
|
+
# answers is emitted with a trailing underscore (`class` -> `class_`).
|
|
477
|
+
# Asking the struct rather than re-deriving the emitter's reserved list
|
|
478
|
+
# also survives the skew: a file generated by an older version was
|
|
479
|
+
# renamed by that version's list, not this one's.
|
|
480
|
+
sig { params(node: T.untyped, segment: T.untyped).returns(T.untyped) }
|
|
481
|
+
private def field_value(node, segment)
|
|
482
|
+
props = node.class.props if node.class.respond_to?(:props)
|
|
483
|
+
return unless props.is_a?(Hash)
|
|
484
|
+
|
|
485
|
+
name = GraphWeaver::Inflect.underscore(segment.to_s)
|
|
486
|
+
prop = [name.to_sym, :"#{name}_"].find { |candidate| props.key?(candidate) }
|
|
487
|
+
node.public_send(prop) if prop
|
|
393
488
|
end
|
|
394
489
|
|
|
395
490
|
# The user-facing rollup: errors keyed by field, with the ids of the
|
|
@@ -486,7 +581,7 @@ module GraphWeaver
|
|
|
486
581
|
# unknown enum value). #struct names the generated type that failed;
|
|
487
582
|
# #cause carries the original TypeError/KeyError with the offending
|
|
488
583
|
# prop in its message.
|
|
489
|
-
class
|
|
584
|
+
class CastError < Error
|
|
490
585
|
extend T::Sig
|
|
491
586
|
|
|
492
587
|
sig { returns(T.untyped) }
|
|
@@ -510,18 +605,74 @@ module GraphWeaver
|
|
|
510
605
|
end
|
|
511
606
|
end
|
|
512
607
|
|
|
513
|
-
#
|
|
514
|
-
#
|
|
515
|
-
#
|
|
516
|
-
#
|
|
517
|
-
#
|
|
518
|
-
#
|
|
519
|
-
#
|
|
608
|
+
# The caller's input was invalid — an unknown or typo'd input key, a
|
|
609
|
+
# missing required input field, an out-of-range enum, a wrong-typed field.
|
|
610
|
+
# Raised before the request leaves, so rescue it at an API boundary to
|
|
611
|
+
# return a 422; it is ALSO the value a server's rejection becomes
|
|
612
|
+
# (GraphQLError#input_errors, Response#input_errors), because "which input
|
|
613
|
+
# was wrong, and how" is one question whichever side answered it.
|
|
614
|
+
#
|
|
615
|
+
# The machine side is #kind (one of KINDS), #path (rooted at the variable),
|
|
616
|
+
# #coordinate (the schema's name for the slot), #value and #details — see
|
|
617
|
+
# docs/i18n.md for translating them. #message is the developer's English
|
|
618
|
+
# line and is not API. The underlying TypeError/KeyError/ArgumentError is
|
|
619
|
+
# preserved as #cause.
|
|
520
620
|
class InputError < Error
|
|
521
621
|
extend T::Sig
|
|
522
622
|
|
|
623
|
+
# The closed vocabulary #kind draws from — one key an app can translate,
|
|
624
|
+
# rather than a sentence it has to parse. Additive only: a new kind
|
|
625
|
+
# arrives in a MINOR release and :refused is the honest home for
|
|
626
|
+
# everything that fits none of them. docs/i18n.md has what each means.
|
|
627
|
+
KINDS = T.let(
|
|
628
|
+
%i[type_mismatch unparseable not_a_member missing unknown out_of_range invalid_format refused].to_set.freeze,
|
|
629
|
+
T::Set[Symbol],
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
# The detail keys a kind may carry. Closed, so `I18n.t(..., **details)`
|
|
633
|
+
# never gets a key the app's locale file has no slot for — and so a
|
|
634
|
+
# server can't smuggle arbitrary data in under extensions.input. None of
|
|
635
|
+
# them may be an I18n::RESERVED_KEYS name (`:format` was, which raised
|
|
636
|
+
# I18n::ReservedInterpolationKey on that very splat) — held by a spec.
|
|
637
|
+
DETAILS = T.let(%i[type members min max pattern suggestion].freeze, T::Array[Symbol])
|
|
638
|
+
|
|
639
|
+
# The most of any one value this error will hold or spell — per String, at
|
|
640
|
+
# every depth, and per sentence a server wrote. Past it the rest is
|
|
641
|
+
# dropped for "…(N more bytes)". An InputError is built for whatever a
|
|
642
|
+
# caller sent and whatever a server echoed back, either of which can be
|
|
643
|
+
# megabytes, and every raised one writes a warn line as well as landing in
|
|
644
|
+
# #to_h. A kilobyte is far more than a diagnosis needs and far less than a
|
|
645
|
+
# log line can't take.
|
|
646
|
+
VALUE_LIMIT = 1024
|
|
647
|
+
|
|
648
|
+
sig { returns(Symbol) }
|
|
649
|
+
attr_reader :kind
|
|
650
|
+
|
|
651
|
+
# Rooted at the variable and down through input fields and list indices:
|
|
652
|
+
# ["where", "_and", 0, "_not", "species"]. Every named segment is the
|
|
653
|
+
# SCHEMA's spelling, whichever side refused — a server can produce no
|
|
654
|
+
# other, so one rule covers both halves. Empty when nothing named a slot.
|
|
655
|
+
# #field is its last named segment — the one a form highlights.
|
|
656
|
+
sig { returns(T::Array[T.untyped]) }
|
|
657
|
+
attr_reader :path
|
|
658
|
+
|
|
659
|
+
# The GraphQL schema coordinate for the slot — "PetFilter.species". nil
|
|
660
|
+
# where there isn't one: a variable ($count names no schema element), a
|
|
661
|
+
# key the input type doesn't define, or a server that didn't say.
|
|
523
662
|
sig { returns(T.nilable(String)) }
|
|
524
|
-
attr_reader :
|
|
663
|
+
attr_reader :coordinate
|
|
664
|
+
|
|
665
|
+
# The rejected value, through filter_parameters exactly as the message
|
|
666
|
+
# is. nil where it was never known (a missing field has no value, and an
|
|
667
|
+
# unknown key owns no slot to hold one). Always JSON-representable — see
|
|
668
|
+
# json_safe.
|
|
669
|
+
sig { returns(T.untyped) }
|
|
670
|
+
attr_reader :value
|
|
671
|
+
|
|
672
|
+
# Kind-specific facts, never pre-formatted: members stays an Array,
|
|
673
|
+
# because joining it is a language decision. Keys are drawn from DETAILS.
|
|
674
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
675
|
+
attr_reader :details
|
|
525
676
|
|
|
526
677
|
# The generated input struct class where generation produced one, and
|
|
527
678
|
# the GraphQL type name where it didn't — a federation representation
|
|
@@ -530,18 +681,93 @@ module GraphWeaver
|
|
|
530
681
|
sig { returns(T.untyped) }
|
|
531
682
|
attr_reader :struct
|
|
532
683
|
|
|
533
|
-
sig
|
|
534
|
-
|
|
535
|
-
|
|
684
|
+
sig do
|
|
685
|
+
params(
|
|
686
|
+
message: String,
|
|
687
|
+
kind: Symbol,
|
|
688
|
+
path: T::Array[T.untyped],
|
|
689
|
+
coordinate: T.nilable(String),
|
|
690
|
+
value: T.untyped,
|
|
691
|
+
details: T::Hash[Symbol, T.untyped],
|
|
692
|
+
struct: T.untyped,
|
|
693
|
+
raised: T::Boolean,
|
|
694
|
+
).void
|
|
695
|
+
end
|
|
696
|
+
def initialize(message, kind: :refused, path: [], coordinate: nil, value: nil,
|
|
697
|
+
details: {}, struct: nil, raised: true)
|
|
698
|
+
unless KINDS.include?(kind)
|
|
699
|
+
raise ArgumentError, "kind: #{kind.inspect} is not an input kind — one of #{KINDS.to_a.join(", ")}"
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
@kind = kind
|
|
703
|
+
@path = T.let(path.dup, T::Array[T.untyped])
|
|
704
|
+
@coordinate = coordinate
|
|
705
|
+
@value = T.let(json_safe(value), T.untyped)
|
|
706
|
+
@details = T.let(json_safe(details), T::Hash[Symbol, T.untyped])
|
|
536
707
|
@struct = struct
|
|
537
|
-
|
|
538
|
-
|
|
708
|
+
@raised = raised
|
|
709
|
+
# the message often IS a sorbet prop error — drop its frame, as CastError does
|
|
710
|
+
super(message.sub(CastError::SORBET_CALLER, ""))
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# `render json: e.to_h` is the documented idiom, and JSON has no spelling
|
|
714
|
+
# for NaN or Infinity — so the crash landed inside the app's error handler,
|
|
715
|
+
# losing the diagnosis and turning a 422 into a 500. The values that get
|
|
716
|
+
# there are precisely the ones Coerce.finite/whole exist to refuse, plus
|
|
717
|
+
# whatever a lenient parser read off a response, so a non-finite Float
|
|
718
|
+
# travels as its to_s and everything else passes through untouched.
|
|
719
|
+
sig { params(value: T.untyped).returns(T.untyped) }
|
|
720
|
+
private def json_safe(value)
|
|
721
|
+
case value
|
|
722
|
+
when Float then value.finite? ? value : value.to_s
|
|
723
|
+
when String then GraphWeaver::Internal::Redact.cap(value)
|
|
724
|
+
when Array then value.map { |element| json_safe(element) }
|
|
725
|
+
when Hash then value.transform_values { |element| json_safe(element) }
|
|
726
|
+
else value
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
# The input field the value actually landed on — #path's last *named*
|
|
731
|
+
# segment, which is the coordinate a form can act on. A trailing list
|
|
732
|
+
# index is a position rather than a field, so `["ids", 2]` is still the
|
|
733
|
+
# `ids` field. nil when nothing named a slot.
|
|
734
|
+
sig { returns(T.nilable(String)) }
|
|
735
|
+
def field = T.cast(path.reverse.find { |segment| segment.is_a?(String) }, T.nilable(String))
|
|
736
|
+
|
|
737
|
+
# The same refusal one level out: prepend the segment that led here.
|
|
738
|
+
# Every enclosing layer — a list index, an input field, the variable —
|
|
739
|
+
# adds its own on the way out, so the innermost refusal (which wrote the
|
|
740
|
+
# message) ends up holding the whole route. Mutates and returns self:
|
|
741
|
+
# the failure happened once, and a fresh error per layer would write a
|
|
742
|
+
# warn line per layer for it.
|
|
743
|
+
#
|
|
744
|
+
# `prop:` is the same field in Ruby spelling, where it differs: the
|
|
745
|
+
# segment is the schema's name, but filter_parameters is a list the app
|
|
746
|
+
# writes in Ruby, so `api_key` must still match what `apiKey` holds.
|
|
747
|
+
sig { params(segment: T.any(String, Integer), prop: T.any(String, Integer, Symbol)).returns(InputError) }
|
|
748
|
+
def within(segment, prop: segment)
|
|
749
|
+
@path.unshift(segment)
|
|
750
|
+
# a list element has no key of its own — the list's key is the first it
|
|
751
|
+
# meets, and it decides whether the value may be shown
|
|
752
|
+
@value = GraphWeaver::Internal::Redact.value(prop, @value) if segment.is_a?(String)
|
|
753
|
+
self
|
|
539
754
|
end
|
|
540
755
|
|
|
541
756
|
sig { override.returns(T::Hash[String, T.untyped]) }
|
|
542
757
|
def to_h
|
|
543
|
-
super.merge(
|
|
758
|
+
super.merge(
|
|
759
|
+
"kind" => kind.to_s,
|
|
760
|
+
"path" => path,
|
|
761
|
+
"coordinate" => coordinate,
|
|
762
|
+
"field" => field,
|
|
763
|
+
"value" => value,
|
|
764
|
+
"details" => details.transform_keys(&:to_s),
|
|
765
|
+
"struct" => struct&.to_s,
|
|
766
|
+
).compact
|
|
544
767
|
end
|
|
768
|
+
|
|
769
|
+
sig { override.returns(T::Boolean) }
|
|
770
|
+
private def raised? = @raised
|
|
545
771
|
end
|
|
546
772
|
|
|
547
773
|
# The setup doesn't add up — judged against your schema, not against the
|
|
@@ -557,7 +783,7 @@ module GraphWeaver
|
|
|
557
783
|
# structured validation errors (message + line/column) rather than a
|
|
558
784
|
# joined string. Under the Error umbrella like everything else raised
|
|
559
785
|
# here (through 0.1.0 it was an ArgumentError instead).
|
|
560
|
-
class
|
|
786
|
+
class QueryValidationError < Error
|
|
561
787
|
extend T::Sig
|
|
562
788
|
|
|
563
789
|
sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
|
|
@@ -595,7 +821,7 @@ module GraphWeaver
|
|
|
595
821
|
# line — thirty typos on one joined line is a wall nobody reads.
|
|
596
822
|
sig { params(errors: T::Array[T::Hash[Symbol, T.untyped]]).returns(String) }
|
|
597
823
|
def render(errors)
|
|
598
|
-
entries = errors.map { |error|
|
|
824
|
+
entries = errors.map { |error| QueryValidationError.split(error) }
|
|
599
825
|
paths = entries.map(&:first).compact.uniq
|
|
600
826
|
hoisted = paths.one?
|
|
601
827
|
|
|
@@ -607,3 +833,7 @@ module GraphWeaver
|
|
|
607
833
|
end
|
|
608
834
|
end
|
|
609
835
|
end
|
|
836
|
+
|
|
837
|
+
# reads a server rejection back into InputError values (GraphQLError#input_errors);
|
|
838
|
+
# below the classes, since it needs both of them defined
|
|
839
|
+
require_relative "internal/server_input"
|