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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +40 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +474 -106
  70. metadata +56 -1
@@ -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), {TypeError} (response wouldn't
17
- # cast), {InputError} (bad variables), {ValidationError} (build time),
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
- super.merge("cause" => cause&.class&.name)
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, 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.
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
- sig { params(status: Integer, body: T.untyped, headers: T::Hash[String, String]).void }
100
- def initialize(status:, body: nil, headers: {})
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
- @headers = headers
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/Hasura the rest.
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[THROTTLED RATE_LIMITED RATE_LIMIT_EXCEEDED TOO_MANY_REQUESTS REQUEST_LIMIT_EXCEEDED].freeze,
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
- prop = GraphWeaver::Inflect.underscore(segment.to_s).to_sym
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.respond_to?(:id) ? node.id : nil
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 TypeError < Error
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
- # 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.
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 :field
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 { params(message: String, field: T.nilable(String), struct: T.untyped).void }
534
- def initialize(message, field: nil, struct: nil)
535
- @field = field
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
- # the message often IS a sorbet prop error — drop its frame, as TypeError does
538
- super(message.sub(TypeError::SORBET_CALLER, ""))
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("field" => field, "struct" => struct&.to_s).compact
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 ValidationError < Error
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| ValidationError.split(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"