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