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
@@ -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), {TypeError} (response wouldn't
17
- # cast), {InputError} (bad variables), {ValidationError} (build time),
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
- super.merge("cause" => cause&.class&.name)
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, names downcased — the rate-limit budget
94
- # (x-ratelimit-remaining), the request id your provider wants in a
95
- # support ticket, Retry-After. Empty when the transport had none.
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
- sig { params(status: Integer, body: T.untyped, headers: T::Hash[String, String]).void }
100
- def initialize(status:, body: nil, headers: {})
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
- @headers = headers
104
- snippet = body.to_s.empty? ? "" : ": #{body.to_s[0, 500]}"
105
- super("HTTP #{status}#{snippet}#{" #{hint}" if hint}")
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, which is either a
125
- # delay in seconds or an HTTP-date. nil when absent or unparseable;
126
- # negative dates (already past) clamp to 0. See RFC 9110 §10.2.3.
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
- value = headers["retry-after"]&.strip
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/Hasura the rest.
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[THROTTLED RATE_LIMITED RATE_LIMIT_EXCEEDED TOO_MANY_REQUESTS REQUEST_LIMIT_EXCEEDED].freeze,
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
- prop = GraphWeaver::Inflect.underscore(segment.to_s).to_sym
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.respond_to?(:id) ? node.id : nil
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 TypeError < Error
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
- # Raised when the variables passed to a query or mutation can't be built
514
- # into the generated input structs an unknown or typo'd input key, a
515
- # missing required input field, an out-of-range enum, or a wrong-typed
516
- # field. This is the "the caller's input was invalid" error: rescue it at
517
- # an API boundary to return a 422. #field names the offending input field
518
- # when known, #struct the input type being built; the underlying
519
- # TypeError/KeyError/ArgumentError is preserved as #cause.
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 :field
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 { params(message: String, field: T.nilable(String), struct: T.untyped).void }
534
- def initialize(message, field: nil, struct: nil)
535
- @field = field
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
- # the message often IS a sorbet prop error — drop its frame, as TypeError does
538
- super(message.sub(TypeError::SORBET_CALLER, ""))
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("field" => field, "struct" => struct&.to_s).compact
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 ValidationError < Error
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| ValidationError.split(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"