graph_weaver 0.5.0 → 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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +537 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +29 -4
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +75 -16
  8. data/docs/federation.md +206 -155
  9. data/docs/generated_modules.md +223 -166
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +35 -5
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +196 -155
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +243 -22
  16. data/graph_weaver.gemspec +16 -2
  17. data/lib/generators/graph_weaver/install_generator.rb +31 -16
  18. data/lib/graph_weaver/client.rb +52 -15
  19. data/lib/graph_weaver/codegen/aliases.rb +15 -8
  20. data/lib/graph_weaver/codegen/emit.rb +107 -42
  21. data/lib/graph_weaver/codegen/enum_type.rb +4 -3
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
  24. data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
  25. data/lib/graph_weaver/codegen.rb +382 -105
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +57 -13
  28. data/lib/graph_weaver/federation.rb +10 -22
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
  38. data/lib/graph_weaver/internal/values.rb +181 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +6 -13
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +63 -18
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +126 -35
  50. data/lib/graph_weaver/tasks.rb +88 -36
  51. data/lib/graph_weaver/testing/cassette.rb +131 -78
  52. data/lib/graph_weaver/testing/coverage.rb +11 -15
  53. data/lib/graph_weaver/testing/failure.rb +14 -8
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +147 -840
  57. data/lib/graph_weaver/testing.rb +40 -83
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +221 -118
  63. metadata +17 -13
  64. data/CLAUDE.md +0 -161
  65. data/DECISIONS.md +0 -309
  66. data/Makefile +0 -23
  67. data/NOTES.md +0 -182
  68. data/PLAN.md +0 -115
  69. data/REVIEW.md +0 -946
  70. data/lib/graph_weaver/schemas.rb +0 -46
  71. data/lib/graph_weaver/selection.rb +0 -120
  72. 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
@@ -189,12 +207,32 @@ module GraphWeaver
189
207
  extensions["code"] || @error_type
190
208
  end
191
209
 
192
- # Message shapes servers use when they reject the *shape* of a query
193
- # (unknown field/type/argument). Heuristic by necessity: only Apollo
194
- # sets a standard code (GRAPHQL_VALIDATION_FAILED); graphql-ruby and
195
- # GitHub speak in messages.
210
+ # Codes a server sets when it rejects the *shape* of a query. Apollo has
211
+ # one flat code; graphql-ruby names the rule that fired, and it is the
212
+ # in-process client this library ships, so its drift-shaped rules are
213
+ # listed rather than guessed at from prose.
214
+ VALIDATION_CODES = T.let(%w[
215
+ GRAPHQL_VALIDATION_FAILED
216
+ undefinedField undefinedType undefinedDirective
217
+ argumentNotAccepted argumentType argumentLiteralsIncompatible
218
+ missingRequiredArguments missingRequiredInputObjectAttribute
219
+ cannotSpreadFragment fragmentOnNonCompositeType
220
+ variableMismatch variableRequiresValidType variableNotDefined
221
+ selectionMismatch invalidOneOfInputObject
222
+ ].to_set.freeze, T::Set[String])
223
+
224
+ # For servers that send no code at all. Variable coercion reports through
225
+ # the message in both dialects, and a required input field appearing is
226
+ # unambiguous here: a generated input struct enforces its own required
227
+ # fields, so the app cannot produce that error itself.
196
228
  VALIDATION_MESSAGE = T.let(
197
- /doesn't exist|Cannot query field|Unknown (field|type|argument)|isn't defined|undefined (field|type)/i,
229
+ Regexp.union(
230
+ /doesn't exist/i, /Cannot query field/i, /Unknown (field|type|argument)/i,
231
+ /is ?n[o']t defined/i, /undefined (field|type)/i, /No such type/i,
232
+ /can't be spread inside/i, /is missing required arguments/i,
233
+ /doesn't accept argument/i, /Field is not defined on/i,
234
+ /was provided invalid value for .+ \(Expected value to not be null\)/i,
235
+ ),
198
236
  Regexp,
199
237
  )
200
238
 
@@ -203,7 +241,8 @@ module GraphWeaver
203
241
  # changed after generation.
204
242
  sig { returns(T::Boolean) }
205
243
  def validation?
206
- code == "GRAPHQL_VALIDATION_FAILED" || VALIDATION_MESSAGE.match?(message)
244
+ # to_s: a nil code is never a member, and sorbet can't narrow a call
245
+ VALIDATION_CODES.include?(code.to_s) || VALIDATION_MESSAGE.match?(message)
207
246
  end
208
247
 
209
248
  # The codes servers use to say "you're going too fast". No standard
@@ -484,6 +523,10 @@ module GraphWeaver
484
523
  sig { returns(T.nilable(String)) }
485
524
  attr_reader :field
486
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.
487
530
  sig { returns(T.untyped) }
488
531
  attr_reader :struct
489
532
 
@@ -491,7 +534,8 @@ module GraphWeaver
491
534
  def initialize(message, field: nil, struct: nil)
492
535
  @field = field
493
536
  @struct = struct
494
- super(message)
537
+ # the message often IS a sorbet prop error — drop its frame, as TypeError does
538
+ super(message.sub(TypeError::SORBET_CALLER, ""))
495
539
  end
496
540
 
497
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
@@ -78,10 +79,11 @@ module GraphWeaver
78
79
  def initialize(supergraph: nil, subgraphs: nil, schemas: nil)
79
80
  source = (supergraph || GraphWeaver::SchemaLoader.locate_path).to_s
80
81
  @table = GraphWeaver::SchemaLoader.routing_table(source)
81
- # SDL passed as content has no name to print
82
- @source = source.include?("\n") ? "the supergraph" : source
83
- @given = named(subgraphs)
84
- @schemas = schemas || GraphWeaver::Schemas.loaded
82
+ # SDL passed as content has no name to print — asked the way the
83
+ # loader asks it, which a one-line supergraph doesn't fool
84
+ @source = GraphWeaver::SchemaLoader.sdl_content?(source) ? "the supergraph" : source
85
+ @given = @table.named_subgraphs(subgraphs)
86
+ @schemas = schemas || GraphWeaver::Internal::Schemas.loaded
85
87
  @stale = {}
86
88
  @uncomposed = {}
87
89
  @skipped = {}
@@ -129,20 +131,6 @@ module GraphWeaver
129
131
  STALE = "stale — the supergraph carries these, no schema here defines them (recompose):"
130
132
  UNCOMPOSED = "not composed in — a schema here defines these, the supergraph doesn't carry them:"
131
133
 
132
- # `subgraphs:` with string keys, refusing a name this supergraph
133
- # doesn't have — the same check Testing::Subgraphs makes, and for the
134
- # same reason: a typo'd key would silently check nothing
135
- def named(given)
136
- map = (given || {}).to_h { |name, schema| [name.to_s, schema] }
137
- unknown = map.keys - @table.subgraphs
138
- if unknown.any?
139
- raise GraphWeaver::ConfigurationError, "subgraphs: names #{unknown.join(", ")}, which " \
140
- "this supergraph doesn't have (its subgraphs are #{@table.subgraphs.join(", ")})"
141
- end
142
-
143
- map
144
- end
145
-
146
134
  def compare
147
135
  @table.subgraphs.each do |name|
148
136
  next unless (fitting = comparable(name))
@@ -194,7 +182,7 @@ module GraphWeaver
194
182
  next unless @table.owners(type_name, field_name).include?(name)
195
183
 
196
184
  coordinate = "#{type_name}.#{field_name}"
197
- next if fitting.any? { |schema| GraphWeaver::Schemas.defines?(schema, coordinate) }
185
+ next if fitting.any? { |schema| GraphWeaver::Internal::Schemas.defines?(schema, coordinate) }
198
186
 
199
187
  (@stale[coordinate] ||= []) << name
200
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