graph_weaver 0.5.1 → 0.6.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +409 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +6 -1
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +73 -16
  8. data/docs/federation.md +201 -151
  9. data/docs/generated_modules.md +222 -165
  10. data/docs/getting_started.md +105 -81
  11. data/docs/logging.md +34 -4
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +191 -151
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +210 -11
  16. data/lib/generators/graph_weaver/install_generator.rb +16 -1
  17. data/lib/graph_weaver/client.rb +46 -13
  18. data/lib/graph_weaver/codegen/aliases.rb +5 -4
  19. data/lib/graph_weaver/codegen/emit.rb +96 -39
  20. data/lib/graph_weaver/codegen/enum_type.rb +3 -0
  21. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  22. data/lib/graph_weaver/codegen/scalar_type.rb +82 -79
  23. data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
  24. data/lib/graph_weaver/codegen.rb +284 -84
  25. data/lib/graph_weaver/coerce.rb +113 -0
  26. data/lib/graph_weaver/errors.rb +30 -7
  27. data/lib/graph_weaver/federation.rb +6 -5
  28. data/lib/graph_weaver/hints.rb +76 -2
  29. data/lib/graph_weaver/in_process.rb +11 -8
  30. data/lib/graph_weaver/inflect.rb +2 -0
  31. data/lib/graph_weaver/input_struct.rb +115 -12
  32. data/lib/graph_weaver/internal/overrides.rb +101 -0
  33. data/lib/graph_weaver/internal/planner.rb +868 -0
  34. data/lib/graph_weaver/internal/schemas.rb +50 -0
  35. data/lib/graph_weaver/internal/selection.rb +127 -0
  36. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
  37. data/lib/graph_weaver/internal/values.rb +181 -0
  38. data/lib/graph_weaver/internal.rb +206 -0
  39. data/lib/graph_weaver/logging.rb +108 -20
  40. data/lib/graph_weaver/parsing.rb +5 -4
  41. data/lib/graph_weaver/query_module.rb +2 -0
  42. data/lib/graph_weaver/railtie.rb +113 -14
  43. data/lib/graph_weaver/representation.rb +30 -2
  44. data/lib/graph_weaver/response.rb +15 -0
  45. data/lib/graph_weaver/retry.rb +54 -22
  46. data/lib/graph_weaver/rspec.rb +50 -11
  47. data/lib/graph_weaver/schema_diff.rb +293 -0
  48. data/lib/graph_weaver/schema_loader.rb +96 -29
  49. data/lib/graph_weaver/tasks.rb +78 -29
  50. data/lib/graph_weaver/testing/cassette.rb +49 -65
  51. data/lib/graph_weaver/testing/coverage.rb +5 -4
  52. data/lib/graph_weaver/testing/failure.rb +10 -6
  53. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  54. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  55. data/lib/graph_weaver/testing/router.rb +94 -808
  56. data/lib/graph_weaver/testing.rb +35 -84
  57. data/lib/graph_weaver/transport/faraday.rb +1 -1
  58. data/lib/graph_weaver/transport/http.rb +29 -12
  59. data/lib/graph_weaver/transport.rb +11 -34
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +188 -110
  62. metadata +10 -5
  63. data/lib/graph_weaver/schemas.rb +0 -48
  64. data/lib/graph_weaver/selection.rb +0 -120
  65. data/lib/graph_weaver/testing/values.rb +0 -98
@@ -0,0 +1,113 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "errors"
5
+
6
+ module GraphWeaver
7
+ # Called by generated code — not semver'd for direct use.
8
+ #
9
+ # Turning a loose value into the Ruby type a generated kwarg promises.
10
+ #
11
+ # Generated `execute` sigs are `.checked(:never)`, so a `params[:first]`
12
+ # String reaches the body instead of being rejected by sorbet-runtime
13
+ # first — and this is what stands where that check used to: it converts
14
+ # what converts unambiguously and refuses the rest.
15
+ #
16
+ # The conversions raise plain Ruby errors (as Kernel#Integer does) and the
17
+ # caller brands them: `.variable` for an execute kwarg, InputStruct.coerce
18
+ # for an input-object field, Hints.field for a response leaf. One table in
19
+ # both directions — `Coerce.float` is also Float's cast from the wire.
20
+ module Coerce
21
+ # Kernel#Integer reads "010" as octal and "0x1f" as hex, and Kernel#Float
22
+ # takes "1_0" — Ruby literal syntax, not wire syntax, and a zero-padded
23
+ # form field is a real input that must not silently mean something else.
24
+ INTEGER = /\A[+-]?\d+\z/
25
+ NUMBER = /\A[+-]?\d+(\.\d+)?([eE][+-]?\d+)?\z/
26
+
27
+ class << self
28
+ def integer(value)
29
+ case value
30
+ when Integer then value
31
+ when Float then whole(value)
32
+ when String then INTEGER.match?(value.strip) ? Integer(value.strip, 10) : unparseable(value, "Int")
33
+ else refuse(value, "Int")
34
+ end
35
+ end
36
+
37
+ def float(value)
38
+ case value
39
+ when Float then value
40
+ when Integer then value.to_f
41
+ when String then NUMBER.match?(value.strip) ? Float(value.strip) : unparseable(value, "Float")
42
+ else refuse(value, "Float")
43
+ end
44
+ end
45
+
46
+ # Ruby has no Kernel#Boolean, and every string rule ("0", "off", "no")
47
+ # is somebody's convention — so refuse rather than pick one.
48
+ def boolean(value)
49
+ return value if value == true || value == false
50
+
51
+ refuse(value, "Boolean", "there is no one right reading of it — convert at the call site")
52
+ end
53
+
54
+ def string(value)
55
+ return value if value.is_a?(String)
56
+
57
+ refuse(value, "String")
58
+ end
59
+
60
+ # The GraphQL spec has ID serialize as a String but accept an integer
61
+ # input, which is `execute(id: user.id)` — the everyday Rails call.
62
+ # String gets no such licence: an Integer where a String belongs is
63
+ # more often a bug than a spelling.
64
+ def id(value)
65
+ case value
66
+ when String then value
67
+ when Integer then value.to_s
68
+ else refuse(value, "ID")
69
+ end
70
+ end
71
+
72
+ # Brands one variable's coercion failure with the variable and the
73
+ # operation: a cast complains about the value alone ("invalid date"),
74
+ # which locates nothing in an app that runs a hundred queries.
75
+ def variable(name, operation, value)
76
+ yield value
77
+ rescue GraphWeaver::InputError => e
78
+ raise GraphWeaver::InputError.new(
79
+ "#{at(name, operation)}: #{Internal::Redact.detail(name, e.message)}", field: name, struct: e.struct,
80
+ )
81
+ rescue StandardError => e
82
+ # a cast complains about the value without quoting it ("invalid date")
83
+ shown = value.inspect
84
+ got = " (got #{shown})" unless e.message.include?(shown)
85
+ raise GraphWeaver::InputError.new(
86
+ "#{at(name, operation)}: #{Internal::Redact.detail(name, "#{e.message}#{got}")}", field: name,
87
+ )
88
+ end
89
+
90
+ private
91
+
92
+ def at(name, operation) = operation ? "$#{name} of #{operation}" : "$#{name}"
93
+
94
+ def whole(value)
95
+ # Integer(2.5) is 2 — a silent loss where refusing costs nothing
96
+ return value.to_i if value.finite? && (value % 1).zero?
97
+
98
+ raise ArgumentError, "#{expected("Int")}, got #{value.inspect} — not a whole number"
99
+ end
100
+
101
+ def unparseable(value, scalar)
102
+ raise ArgumentError, "#{expected(scalar)}, got #{value.inspect}"
103
+ end
104
+
105
+ # ::TypeError — inside GraphWeaver, a bare TypeError is ours
106
+ def refuse(value, scalar, hint = nil)
107
+ raise ::TypeError, "#{expected(scalar)}, got #{value.inspect}#{" — #{hint}" if hint}"
108
+ end
109
+
110
+ def expected(scalar) = "expected #{%w[Int ID].include?(scalar) ? "an" : "a"} #{scalar}"
111
+ end
112
+ end
113
+ end
@@ -12,7 +12,7 @@ module GraphWeaver
12
12
  # all. #message is the human-friendly side; #to_h is the machine side —
13
13
  # a JSON-ready Hash (string keys) for logging, agents, or surfacing
14
14
  # structured failures to users. One subclass per failure site —
15
- # {TransportError} (never reached the server), {ServerError} (non-2xx),
15
+ # {TransportError} (no response came back), {ServerError} (non-2xx),
16
16
  # {QueryError} (GraphQL-level errors), {TypeError} (response wouldn't
17
17
  # cast), {InputError} (bad variables), {ValidationError} (build time),
18
18
  # {ConfigurationError} (setup judged against your schema) — each merging
@@ -25,7 +25,7 @@ module GraphWeaver
25
25
  sig { params(args: T.untyped).void }
26
26
  def initialize(*args)
27
27
  super
28
- GraphWeaver.log(:warn) { "#{self.class.name}: #{message}" }
28
+ GraphWeaver::Internal::Log.log(:warn) { "#{self.class.name}: #{message}" }
29
29
  end
30
30
 
31
31
  sig { overridable.returns(T::Hash[String, T.untyped]) }
@@ -34,9 +34,11 @@ module GraphWeaver
34
34
  end
35
35
  end
36
36
 
37
- # The request never reached the server: connection refused, DNS failure,
38
- # TLS handshake, timeout. The original exception is preserved as #cause.
39
- # Generally retriable.
37
+ # No response came back: connection refused, DNS failure, TLS handshake,
38
+ # timeout, a socket that died mid-body. The original exception is preserved
39
+ # as #cause. Retriable — though a *read* timeout says nothing about whether
40
+ # the server applied the request, which is why Retry gives a mutation one
41
+ # attempt.
40
42
  class TransportError < Error
41
43
  extend T::Sig
42
44
 
@@ -100,7 +102,23 @@ module GraphWeaver
100
102
  @body = body
101
103
  @headers = headers
102
104
  snippet = body.to_s.empty? ? "" : ": #{body.to_s[0, 500]}"
103
- super("HTTP #{status}#{snippet}")
105
+ super("HTTP #{status}#{snippet}#{" — #{hint}" if hint}")
106
+ end
107
+
108
+ # What to do about this status, where the status says it. A redirect is
109
+ # not followed — replaying a POST, with its Authorization header, at a
110
+ # host the server named is not ours to decide — so the destination has to
111
+ # reach whoever configured the url.
112
+ REDIRECTS = [301, 302, 303, 307, 308].freeze
113
+
114
+ sig { returns(T.nilable(String)) }
115
+ def hint
116
+ if REDIRECTS.include?(status)
117
+ location = headers["location"]
118
+ "redirects are not followed#{" — point the client at #{location}" if location}"
119
+ elsif [401, 403].include?(status)
120
+ "the server rejected the credentials — check auth: (the token, and its scopes)"
121
+ end
104
122
  end
105
123
 
106
124
  # Seconds to wait per the server's Retry-After, which is either a
@@ -505,6 +523,10 @@ module GraphWeaver
505
523
  sig { returns(T.nilable(String)) }
506
524
  attr_reader :field
507
525
 
526
+ # The generated input struct class where generation produced one, and
527
+ # the GraphQL type name where it didn't — a federation representation
528
+ # builds a plain Hash, so an entity has only its name there. Branch on
529
+ # #to_h's "struct" instead, which is `to_s` either way.
508
530
  sig { returns(T.untyped) }
509
531
  attr_reader :struct
510
532
 
@@ -512,7 +534,8 @@ module GraphWeaver
512
534
  def initialize(message, field: nil, struct: nil)
513
535
  @field = field
514
536
  @struct = struct
515
- super(message)
537
+ # the message often IS a sorbet prop error — drop its frame, as TypeError does
538
+ super(message.sub(TypeError::SORBET_CALLER, ""))
516
539
  end
517
540
 
518
541
  sig { override.returns(T::Hash[String, T.untyped]) }
@@ -4,7 +4,7 @@
4
4
  require "graphql"
5
5
 
6
6
  require_relative "schema_loader"
7
- require_relative "schemas"
7
+ require_relative "internal/schemas"
8
8
 
9
9
  module GraphWeaver
10
10
  # Federation checks that need no network — the supergraph you committed,
@@ -17,7 +17,7 @@ module GraphWeaver
17
17
  # A committed supergraph is a snapshot of a composition. Change a
18
18
  # subgraph and skip the recompose and it quietly describes a graph that
19
19
  # no longer exists — the failure this catches, locally and before merge,
20
- # where {SchemaLoader.stale?} needs the server and answers a different
20
+ # where {SchemaLoader.diff} needs the server and answers a different
21
21
  # question (has the *server* drifted from my dump).
22
22
  #
23
23
  # Both directions, because they mean opposite things:
@@ -39,7 +39,7 @@ module GraphWeaver
39
39
  # count.
40
40
  #
41
41
  # A supergraph is routinely only **partly local** — the rest served by
42
- # another process, or answered with fabricated data ({Testing::Subgraphs}
42
+ # another process, or answered with fabricated data ({Internal::Subgraphs}
43
43
  # `=> :fake`). Neither can be compared against anything, so the report
44
44
  # names three states rather than two: checked, not here, and faked. A
45
45
  # clean result that didn't say what it couldn't see would be actively
@@ -49,6 +49,7 @@ module GraphWeaver
49
49
  # declares one — so a root can't tell subgraphs apart, and a schema
50
50
  # is recognized by the other types it defines.
51
51
  ROOTS = %w[Query Mutation Subscription].freeze
52
+ private_constant :ROOTS
52
53
 
53
54
  # { "Product.weight" => ["products"] } — the supergraph says these
54
55
  # subgraphs resolve it, and no schema of theirs here defines it
@@ -82,7 +83,7 @@ module GraphWeaver
82
83
  # loader asks it, which a one-line supergraph doesn't fool
83
84
  @source = GraphWeaver::SchemaLoader.sdl_content?(source) ? "the supergraph" : source
84
85
  @given = @table.named_subgraphs(subgraphs)
85
- @schemas = schemas || GraphWeaver::Schemas.loaded
86
+ @schemas = schemas || GraphWeaver::Internal::Schemas.loaded
86
87
  @stale = {}
87
88
  @uncomposed = {}
88
89
  @skipped = {}
@@ -181,7 +182,7 @@ module GraphWeaver
181
182
  next unless @table.owners(type_name, field_name).include?(name)
182
183
 
183
184
  coordinate = "#{type_name}.#{field_name}"
184
- next if fitting.any? { |schema| GraphWeaver::Schemas.defines?(schema, coordinate) }
185
+ next if fitting.any? { |schema| GraphWeaver::Internal::Schemas.defines?(schema, coordinate) }
185
186
 
186
187
  (@stale[coordinate] ||= []) << name
187
188
  end
@@ -4,9 +4,12 @@
4
4
  require "sorbet-runtime"
5
5
 
6
6
  require_relative "errors"
7
+ require_relative "internal"
7
8
  require_relative "inflect"
8
9
 
9
10
  module GraphWeaver
11
+ # Called by generated code — not semver'd for direct use.
12
+ #
10
13
  # Included in generated response structs. GraphQL's camelCase fields
11
14
  # become snake_case props, and reaching for the wire name is a classic
12
15
  # stumble — result.nameWithOwner instead of result.name_with_owner.
@@ -29,7 +32,7 @@ module GraphWeaver
29
32
  suggestion = if known.include?(prop)
30
33
  prop # a wire-cased key — the exact snake_case prop exists
31
34
  else
32
- GraphWeaver.did_you_mean(known, prop)
35
+ GraphWeaver::Internal::Util.did_you_mean(known, prop)
33
36
  end
34
37
  suggestion ? "#{key} (did you mean '#{suggestion}'?)" : key
35
38
  end
@@ -40,6 +43,77 @@ module GraphWeaver
40
43
  )
41
44
  end
42
45
 
46
+ # Wraps one field's cast in generated from_h so a failure says which
47
+ # field. A scalar's cast is arbitrary Ruby — Float(), Date.iso8601, an
48
+ # app's Money.parse — and its complaint is about the value alone
49
+ # ("invalid date"), which locates nothing on a struct holding four
50
+ # dates. Sorbet's own prop errors already name the prop, so this is
51
+ # only on the leaves that cast.
52
+ def self.field(struct, key)
53
+ yield
54
+ rescue GraphWeaver::Error
55
+ raise # a nested struct already named its own field
56
+ rescue StandardError => e
57
+ raise GraphWeaver::TypeError.new(struct:, message: "#{key}: #{e.message}")
58
+ end
59
+
60
+ # A wire value the generated enum doesn't have — the response-side twin
61
+ # of InputStruct.enum. Almost always drift (the server grew a value since
62
+ # you generated) rather than a bad value, and T::Enum's own KeyError says
63
+ # neither that nor which values exist. Raised bare so the enclosing
64
+ # Hints.field brands it with the field.
65
+ def self.enum(type, value)
66
+ type.try_deserialize(value) || drifted!(type, value, type.values.map(&:serialize))
67
+ end
68
+
69
+ # the same, for an enum mapped onto an app-owned T::Enum, where the wire
70
+ # table rather than the type knows the accepted values
71
+ def self.mapped_enum(type, table, value)
72
+ table.fetch(value) { drifted!(type, value, table.keys) }
73
+ end
74
+
75
+ def self.drifted!(type, value, values)
76
+ raise KeyError, "#{value.inspect} is not a #{type} — expected one of: " \
77
+ "#{values.sort.join(", ")}; a value the server added since you generated " \
78
+ "needs a regenerate, or register_enum fallback: to absorb them"
79
+ end
80
+ private_class_method :drifted!
81
+
82
+ # The message for a response that wouldn't cast. sorbet names the prop
83
+ # and the value but not whose bug it is, and an ID the server sent as
84
+ # its raw integer primary key is the case that keeps happening — so
85
+ # say that GraphQL requires the quotes, and how to take it anyway.
86
+ def self.cast_message(struct, data, error)
87
+ message = error.message.sub(GraphWeaver::TypeError::SORBET_CALLER, "")
88
+ keys = unquoted_keys(struct, data)
89
+ return message if keys.empty?
90
+
91
+ "#{message} — the server sent #{keys.join(", ")} unquoted; GraphQL serializes ID and " \
92
+ "String as JSON strings, so that is the server being out of spec. To take it anyway, " \
93
+ 'register the scalar loosely: GraphWeaver.register_scalar("ID", "T.untyped")'
94
+ end
95
+
96
+ # Response keys whose prop would take a String but whose value is
97
+ # another JSON scalar. Narrow on purpose: a prop that casts (a Date, an
98
+ # enum) legitimately arrives as some other type, so only the
99
+ # pass-through String ones say anything.
100
+ def self.unquoted_keys(struct, data)
101
+ return [] unless data.is_a?(Hash) && struct.respond_to?(:props)
102
+
103
+ props = struct.props
104
+ data.filter_map do |key, value|
105
+ next unless value.is_a?(Numeric) || value == true || value == false
106
+
107
+ prop = props[GraphWeaver::Inflect.underscore(key.to_s).to_sym]
108
+ next unless prop
109
+
110
+ # :type is a raw Class for a bare-class prop, a T::Types::Base otherwise
111
+ type = T::Utils.coerce(prop[:type])
112
+ key.inspect if type.valid?("") && !type.valid?(value)
113
+ end
114
+ end
115
+ private_class_method :unquoted_keys
116
+
43
117
  def method_missing(name, *args, &block)
44
118
  if args.empty? && (hint = prop_hint(name.to_s))
45
119
  raise NoMethodError, "undefined method '#{name}' for #{self.class} — #{hint}"
@@ -67,7 +141,7 @@ module GraphWeaver
67
141
  # a guess, not a mapping — spellcheck the (underscored) miss
68
142
  # against the props that exist, so typos in either casing land
69
143
  props = T.unsafe(self.class).props.keys.map(&:to_s)
70
- suggestion = GraphWeaver.did_you_mean(props, prop)
144
+ suggestion = GraphWeaver::Internal::Util.did_you_mean(props, prop)
71
145
  "did you mean '#{suggestion}'?" if suggestion
72
146
  end
73
147
  end
@@ -4,6 +4,7 @@
4
4
  require "json"
5
5
 
6
6
  require_relative "errors"
7
+ require_relative "internal"
7
8
  require_relative "parsing"
8
9
  require_relative "transport"
9
10
 
@@ -45,10 +46,10 @@ class GraphWeaver::InProcess
45
46
  end
46
47
 
47
48
  def execute(query, variables: {}, operation_name: nil)
48
- operation_name ||= GraphWeaver::Transport.operation_name(query)
49
+ operation_name ||= GraphWeaver::Internal::Wire.operation_name(query)
49
50
  payload = { url: nil, schema: @schema.to_s, operation: operation_name }
50
51
 
51
- GraphWeaver.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
52
+ GraphWeaver::Internal::Log.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
52
53
  perform(query, variables, operation_name, payload)
53
54
  end
54
55
  end
@@ -58,15 +59,17 @@ class GraphWeaver::InProcess
58
59
  private def perform(query, variables, operation_name, payload)
59
60
  # same tag/truncation as the network transports, so one log reads the
60
61
  # same whichever side of the seam a query ran on
61
- tag = GraphWeaver.logger && GraphWeaver::Transport.log_tag(operation_name)
62
+ tag = GraphWeaver.logger && GraphWeaver::Internal::Wire.log_tag(operation_name)
62
63
 
63
- GraphWeaver.log(:debug) do
64
- "in-process #{@schema} #{tag} variables=#{JSON.generate(variables)}\n" \
65
- "#{GraphWeaver::Transport.truncate_for_log(query)}"
64
+ GraphWeaver::Internal::Log.log(:debug) do
65
+ "in-process #{@schema} #{tag} variables=#{JSON.generate(GraphWeaver::Internal::Log.filter_variables(variables))}\n" \
66
+ "#{GraphWeaver::Internal::Wire.truncate_for_log(query)}"
66
67
  end
67
68
 
68
- result = GraphWeaver.log_timed(:debug, "in-process #{@schema} #{tag} completed") do
69
- @schema.execute(query, variables:, operation_name:, context: @context)
69
+ result = GraphWeaver::Internal::Log.log_timed(:debug, "in-process #{@schema} #{tag} completed") do
70
+ # a copy per query: graphql-ruby writes a resolver's `context[...] =`
71
+ # into the hash it is handed, and one client serves every request
72
+ @schema.execute(query, variables:, operation_name:, context: @context.dup)
70
73
  end
71
74
 
72
75
  # the same key the network transports set, so one instrumenter
@@ -2,6 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module GraphWeaver
5
+ # Called by generated code — not semver'd for direct use.
6
+ #
5
7
  # GraphQL names are plain camelCase/SCREAMING_SNAKE — no acronym edge
6
8
  # cases, so minimal inflection beats an activesupport dependency
7
9
  module Inflect
@@ -7,6 +7,8 @@ require_relative "errors"
7
7
  require_relative "hints"
8
8
 
9
9
  module GraphWeaver
10
+ # Called by generated code — not semver'd for direct use.
11
+ #
10
12
  # Runtime for generated input structs. Each struct declares its typed
11
13
  # consts plus a compact FIELDS table — (prop, wire name, requiredness,
12
14
  # serializer, coercer) per field, with the conversions emitted as
@@ -19,27 +21,82 @@ module GraphWeaver
19
21
 
20
22
  # serializer/coercer are code-as-data from the generated file; nil
21
23
  # means identity (the wire value passes through untouched)
22
- Field = Struct.new(:prop, :wire, :required, :serializer, :coercer)
24
+ Field = Data.define(:prop, :wire, :required, :serializer, :coercer)
25
+
26
+ # An enum reaching the library as input — an execute kwarg or an input
27
+ # field — as the member or its wire value. Generated code calls these
28
+ # rather than T::Enum.deserialize / the wire table directly: both raise a
29
+ # bare KeyError naming an anonymous module and none of the values they
30
+ # would have taken.
31
+ def self.enum(type, value)
32
+ return value if value.is_a?(type)
33
+
34
+ type.try_deserialize(value) || invalid_enum!(type, value, type.values.map(&:serialize))
35
+ end
36
+
37
+ # the same, for an enum mapped onto an app-owned T::Enum (register_enum),
38
+ # where the wire table rather than the type knows the accepted values
39
+ def self.mapped_enum(type, table, value)
40
+ return value if value.is_a?(type)
41
+
42
+ table.fetch(value) { invalid_enum!(type, value, table.keys) }
43
+ end
44
+
45
+ # Names the input field a coercion refused — a scalar's coercer, an
46
+ # enum's, or a nested input's — since the complaint underneath is about
47
+ # the value alone. A nested error that already named a field keeps it:
48
+ # the innermost input is the one that actually held the bad value.
49
+ def self.field(struct, prop)
50
+ yield
51
+ rescue GraphWeaver::InputError => e
52
+ raise if e.field && !GraphWeaver::Internal::Redact.filtered?(prop)
53
+
54
+ raise GraphWeaver::InputError.new(
55
+ "#{prop}: #{GraphWeaver::Internal::Redact.detail(prop, e.message)}",
56
+ field: e.field || prop.to_s, struct: e.struct || struct,
57
+ )
58
+ rescue StandardError => e
59
+ raise GraphWeaver::InputError.new(
60
+ "#{prop}: #{GraphWeaver::Internal::Redact.detail(prop, e.message)}", field: prop.to_s, struct:,
61
+ )
62
+ end
63
+
64
+ # Raised bare, like Hints.drifted!: the enclosing .field or Coerce.variable
65
+ # knows the key, and so is the only layer that can decide whether this
66
+ # value may be named.
67
+ def self.invalid_enum!(type, value, values)
68
+ raise KeyError, "#{value.inspect} is not a valid #{type} — expected one of: #{values.sort.join(", ")}"
69
+ end
70
+ private_class_method :invalid_enum!
23
71
 
24
72
  def self.included(base)
25
73
  base.extend(ClassMethods)
26
74
  end
27
75
 
28
- # the wire hash optional fields left nil stay off the wire
76
+ # Which props the caller actually named, when we know. GraphQL tells an
77
+ # absent input field from an explicit null, and a Hash can say which it
78
+ # meant — so .coerce records it. A struct built with .new can't: every
79
+ # unset prop is nil either way, and nil there stays "leave it out".
80
+ attr_accessor :supplied
81
+
82
+ # the wire hash — an optional field stays off it unless the caller
83
+ # supplied the nil
29
84
  def serialize
85
+ given = supplied
30
86
  wire = self.class.const_get(:FIELDS).each_with_object({}) do |field, out|
31
87
  value = public_send(field.prop)
32
- next if value.nil? && !field.required
88
+ next if value.nil? && !field.required && !given&.include?(field.prop)
33
89
 
34
90
  out[field.wire] = field.serializer && !value.nil? ? field.serializer.call(value) : value
35
91
  end
36
92
 
37
- # @oneOf declares "exactly one of these", but every field is nullable, so
38
- # nothing before here can enforce it — not the struct's types, not the
39
- # server until the round trip
40
- if wire.size != 1 && self.class.const_defined?(:ONE_OF, false)
93
+ # @oneOf declares "exactly one of these, and not null", but every field
94
+ # is nullable, so nothing before here can enforce it — not the struct's
95
+ # types, not the server until the round trip
96
+ if self.class.const_defined?(:ONE_OF, false) && (wire.size != 1 || wire.values.first.nil?)
97
+ supplied_names = wire.empty? ? "none" : wire.keys.sort.join(", ")
41
98
  raise GraphWeaver::InputError.new(
42
- "#{self.class} is @oneOf — supply exactly one field, got #{wire.empty? ? "none" : wire.keys.sort.join(", ")}",
99
+ "#{self.class} is @oneOf — supply exactly one field, non-null, got #{supplied_names}",
43
100
  struct: self.class,
44
101
  )
45
102
  end
@@ -69,17 +126,63 @@ module GraphWeaver
69
126
  GraphWeaver::Hints.validate_keys!(self, value)
70
127
 
71
128
  fields = T.unsafe(self).const_get(:FIELDS)
72
- T.unsafe(self).new(**fields.to_h do |field|
129
+ given = fields.select { |field| value.key?(field.prop) || value.key?(field.prop.to_s) }.map(&:prop)
130
+ supplied = fields.to_h do |field|
73
131
  raw = value.key?(field.prop) ? value[field.prop] : value[field.prop.to_s]
74
- [field.prop, raw.nil? || field.coercer.nil? ? raw : field.coercer.call(raw)]
75
- end)
132
+ next [field.prop, raw] if raw.nil? || field.coercer.nil?
133
+
134
+ # a coercer is arbitrary Ruby — Coerce.integer, Date.iso8601, a
135
+ # nested .coerce — and its complaint is about the value alone
136
+ [field.prop, GraphWeaver::InputStruct.field(self, field.prop) { field.coercer.call(raw) }]
137
+ end
138
+
139
+ # FIELDS knows which are required, so say what is missing — sorbet's
140
+ # own complaint describes the symptom ("Can't set .name to nil") and
141
+ # names only the first one it reaches
142
+ missing = fields.select { |field| field.required && supplied[field.prop].nil? }.map(&:prop)
143
+ unless missing.empty?
144
+ raise GraphWeaver::InputError.new(
145
+ "missing required key(s) for #{self}: #{missing.join(", ")}",
146
+ field: missing.join(", "), struct: self,
147
+ )
148
+ end
149
+
150
+ T.unsafe(self).new(**supplied).tap { |struct| struct.supplied = given }
76
151
  rescue GraphWeaver::InputError
77
152
  raise # already contextualized by a nested input / enum coercion
78
153
  rescue ::TypeError, ::ArgumentError, KeyError => e
79
154
  # a wrong-typed field, a missing required field, or an out-of-range
80
155
  # enum — surface one branded, structured error for a 422. (`::` so the
81
156
  # rescue catches Ruby's TypeError, not GraphWeaver::TypeError.)
82
- raise GraphWeaver::InputError.new("invalid input for #{self}: #{e.message}", struct: self)
157
+ raise mistyped(supplied) ||
158
+ GraphWeaver::InputError.new("invalid input for #{self}: #{e.message}", struct: self)
159
+ end
160
+
161
+ private
162
+
163
+ # The prop whose value its own type refuses, reported the way every
164
+ # other input failure is. Only sorbet stands between a field with no
165
+ # coercer and the struct, and it names the prop and the value inside
166
+ # one free-text sentence — which reads as the library's own bug, and
167
+ # which no filter can see into.
168
+ def mistyped(supplied)
169
+ return unless supplied
170
+
171
+ T.unsafe(self).props.each do |prop, info|
172
+ value = supplied[prop]
173
+ # :type_object carries the nilable-ness the prop was declared with;
174
+ # :type is that unwrapped, which is the half worth naming — an
175
+ # absent optional field is nil and legal, and a missing required
176
+ # one was reported by name before we got here
177
+ next if T::Utils.coerce(info[:type_object]).valid?(value)
178
+
179
+ return GraphWeaver::InputError.new(
180
+ "#{prop}: expected #{T::Utils.coerce(info[:type])}, " \
181
+ "got #{GraphWeaver::Internal::Redact.detail(prop, value.inspect)}",
182
+ field: prop.to_s, struct: self,
183
+ )
184
+ end
185
+ nil
83
186
  end
84
187
  end
85
188
  end
@@ -0,0 +1,101 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../internal"
5
+
6
+ module GraphWeaver
7
+ module Internal
8
+ # What a pin is: the key it may be written under, and how its value is
9
+ # read. Both doors need the answer — Testing.configure checks the
10
+ # suite-wide ones while the block that set them is still on the stack,
11
+ # and a fake built with its own checks them then.
12
+ module Overrides
13
+ class << self
14
+ # A pin key names something in the schema: a type ("Money",
15
+ # "Person"), a "Type.field" coordinate, or a bare field name
16
+ # matching that field on any type. Anything else is a typo that
17
+ # would silently fabricate random data instead of pinning a value.
18
+ def validate!(schema, overrides)
19
+ overrides.each do |key, value|
20
+ validate_arity!(key, value)
21
+ validate_key!(schema, key.to_s)
22
+ end
23
+ end
24
+
25
+ # A pin that's a proc is handed the seeded Random when it takes one
26
+ # and called bare when it doesn't, so a varying pin still reproduces
27
+ # under `rspec --seed`.
28
+ def resolve(value, rng)
29
+ return value unless value.is_a?(Proc)
30
+
31
+ value.arity.zero? ? value.call : value.call(rng)
32
+ end
33
+
34
+ private
35
+
36
+ # A proc taking anything else can't be called at fabrication time,
37
+ # and the ArgumentError it would raise there names no pin.
38
+ def validate_arity!(key, value)
39
+ return unless value.is_a?(Proc) && value.arity > 1
40
+
41
+ raise GraphWeaver::Error, "the pin for #{key.inspect} takes no arguments, or one — the " \
42
+ "seeded Random (-> (rng) { ... }); this one takes #{value.arity}"
43
+ end
44
+
45
+ def validate_key!(schema, key)
46
+ type_name, field_name = key.split(".", 2)
47
+ # introspection fields (__typename) are real but absent from #fields
48
+ return if (field_name || type_name).start_with?("__")
49
+
50
+ if field_name.nil?
51
+ return if (type = schema.get_type(type_name)) && pinnable!(schema, key, type)
52
+
53
+ known = field_names(schema)
54
+ return if known.include?(type_name)
55
+
56
+ # a leading capital names a type, as it does wherever a pin is written
57
+ dictionary = type_name.match?(/\A[A-Z]/) ? schema.types.keys : known
58
+ bad!(key, "matches no type or field in this schema", dictionary, type_name)
59
+ end
60
+
61
+ type = schema.get_type(type_name)
62
+ unless type.respond_to?(:fields)
63
+ bad!(key, "names no object type in this schema", schema.types.keys, type_name)
64
+ end
65
+ return if type.fields.key?(field_name)
66
+
67
+ bad!(key, "is not a field of #{type_name}", type.fields.keys, field_name)
68
+ end
69
+
70
+ # A type pin says what every value of that type is, and the fake only
71
+ # ever holds a concrete one: at an interface or union the walk has
72
+ # already picked a member, so a pin keyed on the abstract name would
73
+ # match nothing and leave the example green.
74
+ def pinnable!(schema, key, type)
75
+ return true if %w[SCALAR ENUM OBJECT].include?(type.kind.name)
76
+
77
+ advice = if type.kind.abstract?
78
+ members = schema.possible_types(type).map { |member| member.graphql_name.inspect }.sort
79
+ "pin the concrete type — #{members.join(", ")}"
80
+ else
81
+ "only output types are fabricated"
82
+ end
83
+ raise GraphWeaver::Error, "override key #{key.inspect} names #{type.kind.name.downcase.tr("_", " ")} " \
84
+ "#{type.graphql_name}, and a pin fabricates a scalar, enum or object: #{advice}"
85
+ end
86
+
87
+ def bad!(key, problem, dictionary, term)
88
+ suggestion = Util.did_you_mean(dictionary, term)
89
+ hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
90
+ raise GraphWeaver::Error, "override key #{key.inspect} #{problem}#{hint}"
91
+ end
92
+
93
+ # Every output field name in the schema — walked only when a bare key
94
+ # asks for it.
95
+ def field_names(schema)
96
+ schema.types.each_value.flat_map { |type| type.respond_to?(:fields) ? type.fields.keys : [] }.uniq
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end