graph_weaver 0.2.2 → 0.4.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.
@@ -1,4 +1,4 @@
1
- # typed: true
1
+ # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "sorbet-runtime"
@@ -12,16 +12,20 @@ module GraphWeaver
12
12
  # structured failures to users. One subclass per failure site —
13
13
  # {TransportError} (never reached the server), {ServerError} (non-2xx),
14
14
  # {QueryError} (GraphQL-level errors), {TypeError} (response wouldn't
15
- # cast), {ValidationError} (build time) — each merging its specifics
16
- # into #to_h.
15
+ # cast), {InputError} (bad variables), {ValidationError} (build time) —
16
+ # each merging its specifics into #to_h.
17
17
  class Error < StandardError
18
+ extend T::Sig
19
+
18
20
  # every GraphWeaver error surfaces on the logger too (see
19
21
  # GraphWeaver.logger) — construction here means a raise
22
+ sig { params(args: T.untyped).void }
20
23
  def initialize(*args)
21
24
  super
22
25
  GraphWeaver.log(:warn) { "#{self.class.name}: #{message}" }
23
26
  end
24
27
 
28
+ sig { overridable.returns(T::Hash[String, T.untyped]) }
25
29
  def to_h
26
30
  { "error" => self.class.name, "message" => message }
27
31
  end
@@ -31,12 +35,17 @@ module GraphWeaver
31
35
  # TLS handshake, timeout. The original exception is preserved as #cause.
32
36
  # Generally retriable.
33
37
  class TransportError < Error
38
+ extend T::Sig
39
+
40
+ sig { override.returns(T::Hash[String, T.untyped]) }
34
41
  def to_h
35
42
  super.merge("cause" => cause&.class&.name)
36
43
  end
37
44
  end
38
45
 
39
46
  class << self
47
+ extend T::Sig
48
+
40
49
  # The exception classes the bundled transports reclassify as
41
50
  # TransportError — network-level failures where the request never
42
51
  # reached the server. A mutable Set: each transport contributes its own
@@ -48,11 +57,16 @@ module GraphWeaver
48
57
  #
49
58
  # SystemCallError covers every Errno::* (connection refused/reset, host
50
59
  # unreachable); SocketError covers DNS.
60
+ sig { returns(T::Set[T.class_of(Exception)]) }
51
61
  def transport_errors
52
- @transport_errors ||= Set[SocketError, SystemCallError, IOError]
62
+ @transport_errors ||= T.let(
63
+ Set[SocketError, SystemCallError, IOError],
64
+ T.nilable(T::Set[T.class_of(Exception)]),
65
+ )
53
66
  end
54
67
 
55
68
  # Add one or more exception classes to the transport-error set.
69
+ sig { params(classes: T.class_of(Exception)).returns(T::Array[T.class_of(Exception)]) }
56
70
  def register_transport_error(*classes)
57
71
  transport_errors.merge(classes)
58
72
  classes
@@ -63,8 +77,15 @@ module GraphWeaver
63
77
  # that exploded, a 502 from a proxy, a 401, etc. Distinct from a GraphQL
64
78
  # error: we got an HTTP response, it just wasn't success.
65
79
  class ServerError < Error
66
- attr_reader :status, :body
80
+ extend T::Sig
67
81
 
82
+ sig { returns(Integer) }
83
+ attr_reader :status
84
+
85
+ sig { returns(T.untyped) }
86
+ attr_reader :body
87
+
88
+ sig { params(status: Integer, body: T.untyped).void }
68
89
  def initialize(status:, body: nil)
69
90
  @status = status
70
91
  @body = body
@@ -72,6 +93,7 @@ module GraphWeaver
72
93
  super("HTTP #{status}#{snippet}")
73
94
  end
74
95
 
96
+ sig { override.returns(T::Hash[String, T.untyped]) }
75
97
  def to_h
76
98
  super.merge("status" => status)
77
99
  end
@@ -81,16 +103,38 @@ module GraphWeaver
81
103
  # object (not raised) — the response envelope and QueryError carry these.
82
104
  # Match on #code (extensions["code"]) rather than the message string.
83
105
  class GraphQLError
84
- attr_reader :message, :locations, :path, :extensions
106
+ extend T::Sig
107
+
108
+ sig { returns(String) }
109
+ attr_reader :message
110
+
111
+ sig { returns(T::Array[T.untyped]) }
112
+ attr_reader :locations
85
113
 
114
+ sig { returns(T.nilable(T::Array[T.untyped])) }
115
+ attr_reader :path
116
+
117
+ sig { returns(T::Hash[String, T.untyped]) }
118
+ attr_reader :extensions
119
+
120
+ sig do
121
+ params(
122
+ message: String,
123
+ locations: T::Array[T.untyped],
124
+ path: T.nilable(T::Array[T.untyped]),
125
+ extensions: T::Hash[String, T.untyped],
126
+ type: T.nilable(String),
127
+ ).void
128
+ end
86
129
  def initialize(message:, locations: [], path: nil, extensions: {}, type: nil)
87
130
  @message = message
88
131
  @locations = locations
89
132
  @path = path
90
133
  @extensions = extensions
91
- @error_type = type
134
+ @error_type = T.let(type, T.nilable(String))
92
135
  end
93
136
 
137
+ sig { params(hash: T::Hash[String, T.untyped]).returns(GraphQLError) }
94
138
  def self.from_h(hash)
95
139
  new(
96
140
  message: hash["message"] || "(no message)",
@@ -105,6 +149,7 @@ module GraphWeaver
105
149
  # branch on. Read from extensions.code (the Apollo/spec-adjacent
106
150
  # convention) or a top-level "type" (GitHub's dialect: NOT_FOUND,
107
151
  # FORBIDDEN). nil when the server sent neither.
152
+ sig { returns(T.nilable(String)) }
108
153
  def code
109
154
  extensions["code"] || @error_type
110
155
  end
@@ -113,11 +158,15 @@ module GraphWeaver
113
158
  # (unknown field/type/argument). Heuristic by necessity: only Apollo
114
159
  # sets a standard code (GRAPHQL_VALIDATION_FAILED); graphql-ruby and
115
160
  # GitHub speak in messages.
116
- VALIDATION_MESSAGE = /doesn't exist|Cannot query field|Unknown (field|type|argument)|isn't defined|undefined (field|type)/i
161
+ VALIDATION_MESSAGE = T.let(
162
+ /doesn't exist|Cannot query field|Unknown (field|type|argument)|isn't defined|undefined (field|type)/i,
163
+ Regexp,
164
+ )
117
165
 
118
166
  # True when this error looks like the server rejected the query's
119
167
  # shape — for a generated module that usually means the schema
120
168
  # changed after generation.
169
+ sig { returns(T::Boolean) }
121
170
  def validation?
122
171
  code == "GRAPHQL_VALIDATION_FAILED" || VALIDATION_MESSAGE.match?(message)
123
172
  end
@@ -126,17 +175,21 @@ module GraphWeaver
126
175
  # indices stripped — ["people", 3, "email"] => "people.email". The
127
176
  # parseable key for grouping/reporting (the raw #path keeps indices).
128
177
  # nil for global errors with no path.
178
+ sig { returns(T.nilable(String)) }
129
179
  def field
130
- return unless path
180
+ p = path
181
+ return unless p
131
182
 
132
- named = path.reject { |seg| seg.is_a?(Integer) || seg.to_s.match?(/\A\d+\z/) }
183
+ named = p.reject { |seg| seg.is_a?(Integer) || seg.to_s.match?(/\A\d+\z/) }
133
184
  named.join(".") unless named.empty?
134
185
  end
135
186
 
187
+ sig { returns(String) }
136
188
  def to_s
137
- loc = locations&.first
189
+ loc = locations.first
138
190
  at = loc ? " at #{loc["line"]}:#{loc["column"]}" : ""
139
- where = path ? " (path: #{path.join(".")})" : ""
191
+ p = path
192
+ where = p ? " (path: #{p.join(".")})" : ""
140
193
  tag = code ? " [#{code}]" : ""
141
194
  "#{message}#{at}#{where}#{tag}"
142
195
  end
@@ -144,6 +197,7 @@ module GraphWeaver
144
197
  # JSON-ready: the problematic field (both forms — #field for grouping,
145
198
  # #path with indices for exact location), the machine code, and the
146
199
  # server's full extensions.
200
+ sig { returns(T::Hash[String, T.untyped]) }
147
201
  def to_h
148
202
  {
149
203
  "message" => message,
@@ -158,15 +212,18 @@ module GraphWeaver
158
212
  end
159
213
 
160
214
  # Shared filtering over a collection of GraphQLErrors, for surfacing
161
- # field-level failures programmatically. Host must define #errors.
215
+ # field-level failures programmatically. Host must define #errors / #data.
162
216
  module ErrorFiltering
217
+ extend T::Sig
163
218
  include Kernel # for sorbet: hosts are Objects
164
219
 
165
220
  # the host's interface, overridden by its attr_readers
221
+ sig { overridable.returns(T::Array[GraphQLError]) }
166
222
  def errors
167
223
  raise NotImplementedError, "#{self.class} must define #errors"
168
224
  end
169
225
 
226
+ sig { overridable.returns(T.untyped) }
170
227
  def data
171
228
  raise NotImplementedError, "#{self.class} must define #data"
172
229
  end
@@ -174,15 +231,20 @@ module GraphWeaver
174
231
  # Errors touching a field path — "user.email" or ["user", "email"];
175
232
  # prefix match, so deeper errors count too. List indices appear as
176
233
  # path segments ("people.0.email").
234
+ sig { params(path: T.any(String, T::Array[T.untyped])).returns(T::Array[GraphQLError]) }
177
235
  def errors_at(path)
178
236
  want = (path.is_a?(String) ? path.split(".") : path).map(&:to_s)
179
- errors.select { |error| error.path && error.path.map(&:to_s).first(want.size) == want }
237
+ errors.select do |error|
238
+ p = error.path
239
+ p && p.map(&:to_s).first(want.size) == want
240
+ end
180
241
  end
181
242
 
182
243
  # True when any error looks like the server rejected the query's
183
244
  # shape — the schema has likely changed since the module was
184
245
  # generated. Refresh the schema dump and regenerate (rake
185
246
  # graph_weaver:schema:refresh && rake graph_weaver:generate).
247
+ sig { returns(T::Boolean) }
186
248
  def schema_stale?
187
249
  errors.any?(&:validation?)
188
250
  end
@@ -193,25 +255,31 @@ module GraphWeaver
193
255
  # response.each_error do |field, errors|
194
256
  # form.add_error(field, errors.map(&:message))
195
257
  # end
258
+ sig { returns(T::Hash[T.nilable(String), T::Array[GraphQLError]]) }
196
259
  def errors_by_field
197
260
  errors.group_by(&:field)
198
261
  end
199
262
 
263
+ sig do
264
+ params(block: T.proc.params(field: T.nilable(String), errors: T::Array[GraphQLError]).void).void
265
+ end
200
266
  def each_error(&block)
201
- errors_by_field.each(&block)
267
+ errors_by_field.each { |field, errs| block.call(field, errs) }
202
268
  end
203
269
 
204
270
  # The id of the record an error points into, resolved by walking the
205
271
  # error's path through the (partial) typed data: an error at
206
272
  # ["people", 3, "email"] resolves to people[3].id. nil when the data
207
273
  # is missing, the path doesn't walk, or the record has no id field.
274
+ sig { params(error: GraphQLError).returns(T.untyped) }
208
275
  def entity_id(error)
209
276
  # untyped by nature: the walk traverses whatever structs this query
210
277
  # generated, reassigning across types at each step
211
278
  node = T.let(data, T.untyped)
212
- return unless node && error.path
279
+ path = error.path
280
+ return unless node && path
213
281
 
214
- error.path[0..-2].each do |segment|
282
+ (path[0..-2] || []).each do |segment|
215
283
  node = if segment.is_a?(Integer) || segment.to_s.match?(/\A\d+\z/)
216
284
  node.is_a?(Array) ? node[segment.to_i] : nil
217
285
  else
@@ -230,6 +298,7 @@ module GraphWeaver
230
298
  #
231
299
  # { "people.email" => { "messages" => [...], "codes" => [...],
232
300
  # "entity_ids" => ["7", "9"], "errors" => [full to_h...] } }
301
+ sig { returns(T::Hash[T.nilable(String), T::Hash[String, T.untyped]]) }
233
302
  def report
234
303
  errors_by_field.to_h do |field, field_errors|
235
304
  [field, {
@@ -247,10 +316,25 @@ module GraphWeaver
247
316
  # Carries the structured errors, any partial data, and top-level
248
317
  # extensions (cost/throttle metadata).
249
318
  class QueryError < Error
319
+ extend T::Sig
250
320
  include ErrorFiltering
251
321
 
252
- attr_reader :errors, :data, :extensions
322
+ sig { override.returns(T::Array[GraphQLError]) }
323
+ attr_reader :errors
324
+
325
+ sig { override.returns(T.untyped) }
326
+ attr_reader :data
253
327
 
328
+ sig { returns(T::Hash[String, T.untyped]) }
329
+ attr_reader :extensions
330
+
331
+ sig do
332
+ params(
333
+ errors: T::Array[GraphQLError],
334
+ data: T.untyped,
335
+ extensions: T::Hash[String, T.untyped],
336
+ ).void
337
+ end
254
338
  def initialize(errors, data: nil, extensions: {})
255
339
  @errors = errors
256
340
  @data = data
@@ -259,12 +343,14 @@ module GraphWeaver
259
343
  end
260
344
 
261
345
  # All non-nil error codes — handy for `codes.include?("THROTTLED")`.
346
+ sig { returns(T::Array[String]) }
262
347
  def codes
263
- errors.map(&:code).compact
348
+ errors.filter_map(&:code)
264
349
  end
265
350
 
266
351
  # The machine side: every error with its path/code/extensions, plus
267
352
  # the drift verdict — nest this straight into a JSON response.
353
+ sig { override.returns(T::Hash[String, T.untyped]) }
268
354
  def to_h
269
355
  super.merge(
270
356
  "schema_stale" => schema_stale?,
@@ -276,6 +362,7 @@ module GraphWeaver
276
362
 
277
363
  private
278
364
 
365
+ sig { returns(String) }
279
366
  def summary
280
367
  first = errors.first
281
368
  more = errors.size > 1 ? " (and #{errors.size - 1} more)" : ""
@@ -291,30 +378,69 @@ module GraphWeaver
291
378
  # #cause carries the original TypeError/KeyError with the offending
292
379
  # prop in its message.
293
380
  class TypeError < Error
381
+ extend T::Sig
382
+
383
+ sig { returns(T.untyped) }
294
384
  attr_reader :struct
295
385
 
386
+ sig { params(struct: T.untyped, error: T.nilable(Exception), message: T.nilable(String)).void }
296
387
  def initialize(struct:, error: nil, message: nil)
297
388
  @struct = struct
298
389
  super("failed to cast response into #{struct}: #{message || error&.message}")
299
390
  end
300
391
 
392
+ sig { override.returns(T::Hash[String, T.untyped]) }
301
393
  def to_h
302
394
  super.merge("struct" => struct.to_s, "cause" => cause&.message)
303
395
  end
304
396
  end
305
397
 
398
+ # Raised when the variables passed to a query or mutation can't be built
399
+ # into the generated input structs — an unknown or typo'd input key, a
400
+ # missing required input field, an out-of-range enum, or a wrong-typed
401
+ # field. This is the "the caller's input was invalid" error: rescue it at
402
+ # an API boundary to return a 422. #field names the offending input field
403
+ # when known, #struct the input type being built; the underlying
404
+ # TypeError/KeyError/ArgumentError is preserved as #cause.
405
+ class InputError < Error
406
+ extend T::Sig
407
+
408
+ sig { returns(T.nilable(String)) }
409
+ attr_reader :field
410
+
411
+ sig { returns(T.untyped) }
412
+ attr_reader :struct
413
+
414
+ sig { params(message: String, field: T.nilable(String), struct: T.untyped).void }
415
+ def initialize(message, field: nil, struct: nil)
416
+ @field = field
417
+ @struct = struct
418
+ super(message)
419
+ end
420
+
421
+ sig { override.returns(T::Hash[String, T.untyped]) }
422
+ def to_h
423
+ super.merge("field" => field, "struct" => struct&.to_s).compact
424
+ end
425
+ end
426
+
306
427
  # Build-time: the query didn't validate against the schema. Carries the
307
428
  # structured validation errors (message + line/column) rather than a
308
429
  # joined string. Under the Error umbrella like everything else raised
309
430
  # here (through 0.1.0 it was an ArgumentError instead).
310
431
  class ValidationError < Error
432
+ extend T::Sig
433
+
434
+ sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
311
435
  attr_reader :errors
312
436
 
437
+ sig { params(errors: T::Array[T::Hash[Symbol, T.untyped]]).void }
313
438
  def initialize(errors)
314
439
  @errors = errors
315
440
  super("invalid query: #{errors.map { |e| e[:message] }.join("; ")}")
316
441
  end
317
442
 
443
+ sig { override.returns(T::Hash[String, T.untyped]) }
318
444
  def to_h
319
445
  super.merge("errors" => errors.map { |e| e.transform_keys(&:to_s) })
320
446
  end
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "sorbet-runtime"
5
5
 
6
+ require_relative "errors"
6
7
  require_relative "inflect"
7
8
 
8
9
  module GraphWeaver
@@ -32,7 +33,11 @@ module GraphWeaver
32
33
  end
33
34
  suggestion ? "#{key} (did you mean '#{suggestion}'?)" : key
34
35
  end
35
- raise ArgumentError, "unknown key(s) for #{struct}: #{hints.join(", ")}"
36
+ raise GraphWeaver::InputError.new(
37
+ "unknown key(s) for #{struct}: #{hints.join(", ")}",
38
+ field: unknown.join(", "),
39
+ struct: struct,
40
+ )
36
41
  end
37
42
 
38
43
  def method_missing(name, *args, &block)
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "sorbet-runtime"
5
5
 
6
+ require_relative "errors"
6
7
  require_relative "hints"
7
8
 
8
9
  module GraphWeaver
@@ -53,6 +54,13 @@ module GraphWeaver
53
54
  raw = value.key?(field.prop) ? value[field.prop] : value[field.prop.to_s]
54
55
  [field.prop, raw.nil? || field.coercer.nil? ? raw : field.coercer.call(raw)]
55
56
  end)
57
+ rescue GraphWeaver::InputError
58
+ raise # already contextualized by a nested input / enum coercion
59
+ rescue ::TypeError, ::ArgumentError, KeyError => e
60
+ # a wrong-typed field, a missing required field, or an out-of-range
61
+ # enum — surface one branded, structured error for a 422. (`::` so the
62
+ # rescue catches Ruby's TypeError, not GraphWeaver::TypeError.)
63
+ raise GraphWeaver::InputError.new("invalid input for #{self}: #{e.message}", struct: self)
56
64
  end
57
65
  end
58
66
  end
@@ -19,10 +19,10 @@ module GraphWeaver
19
19
 
20
20
  Data = type_member
21
21
 
22
- sig { returns(T.nilable(Data)) }
22
+ sig { override.returns(T.nilable(Data)) }
23
23
  attr_reader :data
24
24
 
25
- sig { returns(T::Array[GraphWeaver::GraphQLError]) }
25
+ sig { override.returns(T::Array[GraphWeaver::GraphQLError]) }
26
26
  attr_reader :errors
27
27
 
28
28
  sig { returns(T::Hash[String, T.untyped]) }
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.2.2"
2
+ VERSION = "0.4.0"
3
3
  end