graph_weaver 0.0.1 → 0.2.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.
- checksums.yaml +4 -4
- data/.yardopts +6 -0
- data/CHANGELOG.md +279 -0
- data/Gemfile.lock +75 -17
- data/Makefile +11 -1
- data/NOTES.md +1 -1
- data/PLAN.md +85 -22
- data/README.md +86 -26
- data/docs/cassettes.md +76 -0
- data/docs/errors.md +134 -0
- data/docs/generated_modules.md +229 -0
- data/docs/getting_started.md +134 -0
- data/docs/logging.md +33 -0
- data/docs/real_world.md +53 -0
- data/docs/scalars.md +183 -0
- data/docs/testing.md +103 -0
- data/docs/transports.md +124 -0
- data/graph_weaver.gemspec +10 -1
- data/lib/graph_weaver/client.rb +200 -0
- data/lib/graph_weaver/codegen/emit.rb +286 -0
- data/lib/graph_weaver/codegen/enum_type.rb +149 -0
- data/lib/graph_weaver/codegen/nodes.rb +356 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +256 -0
- data/lib/graph_weaver/codegen.rb +396 -401
- data/lib/graph_weaver/errors.rb +322 -0
- data/lib/graph_weaver/hints.rb +63 -0
- data/lib/graph_weaver/inflect.rb +19 -0
- data/lib/graph_weaver/logging.rb +41 -0
- data/lib/graph_weaver/railtie.rb +29 -0
- data/lib/graph_weaver/response.rb +55 -0
- data/lib/graph_weaver/retry.rb +97 -0
- data/lib/graph_weaver/rspec.rb +59 -0
- data/lib/graph_weaver/schema_loader.rb +208 -8
- data/lib/graph_weaver/selection.rb +68 -0
- data/lib/graph_weaver/tasks.rb +71 -0
- data/lib/graph_weaver/testing/cassette.rb +224 -0
- data/lib/graph_weaver/testing/failure.rb +109 -0
- data/lib/graph_weaver/testing/fake_client.rb +228 -0
- data/lib/graph_weaver/testing/values.rb +98 -0
- data/lib/graph_weaver/testing.rb +106 -0
- data/lib/graph_weaver/transport/faraday.rb +56 -0
- data/lib/graph_weaver/transport/http.rb +87 -0
- data/lib/graph_weaver/transport.rb +120 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +268 -4
- metadata +132 -2
- data/lib/graph_weaver/http_executor.rb +0 -31
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
require_relative "inflect"
|
|
6
|
+
require_relative "logging"
|
|
7
|
+
|
|
8
|
+
module GraphWeaver
|
|
9
|
+
# Base for every error GraphWeaver raises — rescue this to catch them
|
|
10
|
+
# all. #message is the human-friendly side; #to_h is the machine side —
|
|
11
|
+
# a JSON-ready Hash (string keys) for logging, agents, or surfacing
|
|
12
|
+
# structured failures to users. One subclass per failure site —
|
|
13
|
+
# {TransportError} (never reached the server), {ServerError} (non-2xx),
|
|
14
|
+
# {QueryError} (GraphQL-level errors), {TypeError} (response wouldn't
|
|
15
|
+
# cast), {ValidationError} (build time) — each merging its specifics
|
|
16
|
+
# into #to_h.
|
|
17
|
+
class Error < StandardError
|
|
18
|
+
# every GraphWeaver error surfaces on the logger too (see
|
|
19
|
+
# GraphWeaver.logger) — construction here means a raise
|
|
20
|
+
def initialize(*args)
|
|
21
|
+
super
|
|
22
|
+
GraphWeaver.log(:warn) { "#{self.class.name}: #{message}" }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{ "error" => self.class.name, "message" => message }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The request never reached the server: connection refused, DNS failure,
|
|
31
|
+
# TLS handshake, timeout. The original exception is preserved as #cause.
|
|
32
|
+
# Generally retriable.
|
|
33
|
+
class TransportError < Error
|
|
34
|
+
def to_h
|
|
35
|
+
super.merge("cause" => cause&.class&.name)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
# The exception classes the bundled transports reclassify as
|
|
41
|
+
# TransportError — network-level failures where the request never
|
|
42
|
+
# reached the server. A mutable Set: each transport contributes its own
|
|
43
|
+
# on load (net/http adds Timeout/SSL, Faraday adds its ConnectionFailed,
|
|
44
|
+
# …), and you can add more so they get the same handling:
|
|
45
|
+
#
|
|
46
|
+
# GraphWeaver.transport_errors << MyPool::TimeoutError
|
|
47
|
+
# GraphWeaver.register_transport_error(Adapter::ResetError)
|
|
48
|
+
#
|
|
49
|
+
# SystemCallError covers every Errno::* (connection refused/reset, host
|
|
50
|
+
# unreachable); SocketError covers DNS.
|
|
51
|
+
def transport_errors
|
|
52
|
+
@transport_errors ||= Set[SocketError, SystemCallError, IOError]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Add one or more exception classes to the transport-error set.
|
|
56
|
+
def register_transport_error(*classes)
|
|
57
|
+
transport_errors.merge(classes)
|
|
58
|
+
classes
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The request reached the server but it returned a non-2xx status — a 500
|
|
63
|
+
# that exploded, a 502 from a proxy, a 401, etc. Distinct from a GraphQL
|
|
64
|
+
# error: we got an HTTP response, it just wasn't success.
|
|
65
|
+
class ServerError < Error
|
|
66
|
+
attr_reader :status, :body
|
|
67
|
+
|
|
68
|
+
def initialize(status:, body: nil)
|
|
69
|
+
@status = status
|
|
70
|
+
@body = body
|
|
71
|
+
snippet = body.to_s.empty? ? "" : ": #{body.to_s[0, 500]}"
|
|
72
|
+
super("HTTP #{status}#{snippet}")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_h
|
|
76
|
+
super.merge("status" => status)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# One entry from a GraphQL response's top-level `errors` array. A value
|
|
81
|
+
# object (not raised) — the response envelope and QueryError carry these.
|
|
82
|
+
# Match on #code (extensions["code"]) rather than the message string.
|
|
83
|
+
class GraphQLError
|
|
84
|
+
attr_reader :message, :locations, :path, :extensions
|
|
85
|
+
|
|
86
|
+
def initialize(message:, locations: [], path: nil, extensions: {}, type: nil)
|
|
87
|
+
@message = message
|
|
88
|
+
@locations = locations
|
|
89
|
+
@path = path
|
|
90
|
+
@extensions = extensions
|
|
91
|
+
@error_type = type
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.from_h(hash)
|
|
95
|
+
new(
|
|
96
|
+
message: hash["message"] || "(no message)",
|
|
97
|
+
locations: hash["locations"] || [],
|
|
98
|
+
path: hash["path"],
|
|
99
|
+
extensions: hash["extensions"] || {},
|
|
100
|
+
type: hash["type"],
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The machine-readable error code, e.g. "THROTTLED" — the thing to
|
|
105
|
+
# branch on. Read from extensions.code (the Apollo/spec-adjacent
|
|
106
|
+
# convention) or a top-level "type" (GitHub's dialect: NOT_FOUND,
|
|
107
|
+
# FORBIDDEN). nil when the server sent neither.
|
|
108
|
+
def code
|
|
109
|
+
extensions["code"] || @error_type
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Message shapes servers use when they reject the *shape* of a query
|
|
113
|
+
# (unknown field/type/argument). Heuristic by necessity: only Apollo
|
|
114
|
+
# sets a standard code (GRAPHQL_VALIDATION_FAILED); graphql-ruby and
|
|
115
|
+
# GitHub speak in messages.
|
|
116
|
+
VALIDATION_MESSAGE = /doesn't exist|Cannot query field|Unknown (field|type|argument)|isn't defined|undefined (field|type)/i
|
|
117
|
+
|
|
118
|
+
# True when this error looks like the server rejected the query's
|
|
119
|
+
# shape — for a generated module that usually means the schema
|
|
120
|
+
# changed after generation.
|
|
121
|
+
def validation?
|
|
122
|
+
code == "GRAPHQL_VALIDATION_FAILED" || VALIDATION_MESSAGE.match?(message)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The field the error points at, as a stable dotted path with list
|
|
126
|
+
# indices stripped — ["people", 3, "email"] => "people.email". The
|
|
127
|
+
# parseable key for grouping/reporting (the raw #path keeps indices).
|
|
128
|
+
# nil for global errors with no path.
|
|
129
|
+
def field
|
|
130
|
+
return unless path
|
|
131
|
+
|
|
132
|
+
named = path.reject { |seg| seg.is_a?(Integer) || seg.to_s.match?(/\A\d+\z/) }
|
|
133
|
+
named.join(".") unless named.empty?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def to_s
|
|
137
|
+
loc = locations&.first
|
|
138
|
+
at = loc ? " at #{loc["line"]}:#{loc["column"]}" : ""
|
|
139
|
+
where = path ? " (path: #{path.join(".")})" : ""
|
|
140
|
+
tag = code ? " [#{code}]" : ""
|
|
141
|
+
"#{message}#{at}#{where}#{tag}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# JSON-ready: the problematic field (both forms — #field for grouping,
|
|
145
|
+
# #path with indices for exact location), the machine code, and the
|
|
146
|
+
# server's full extensions.
|
|
147
|
+
def to_h
|
|
148
|
+
{
|
|
149
|
+
"message" => message,
|
|
150
|
+
"code" => code,
|
|
151
|
+
"field" => field,
|
|
152
|
+
"path" => path,
|
|
153
|
+
"locations" => locations,
|
|
154
|
+
"extensions" => extensions,
|
|
155
|
+
"validation" => validation?,
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Shared filtering over a collection of GraphQLErrors, for surfacing
|
|
161
|
+
# field-level failures programmatically. Host must define #errors.
|
|
162
|
+
module ErrorFiltering
|
|
163
|
+
include Kernel # for sorbet: hosts are Objects
|
|
164
|
+
|
|
165
|
+
# the host's interface, overridden by its attr_readers
|
|
166
|
+
def errors
|
|
167
|
+
raise NotImplementedError, "#{self.class} must define #errors"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def data
|
|
171
|
+
raise NotImplementedError, "#{self.class} must define #data"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Errors touching a field path — "user.email" or ["user", "email"];
|
|
175
|
+
# prefix match, so deeper errors count too. List indices appear as
|
|
176
|
+
# path segments ("people.0.email").
|
|
177
|
+
def errors_at(path)
|
|
178
|
+
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 }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# True when any error looks like the server rejected the query's
|
|
183
|
+
# shape — the schema has likely changed since the module was
|
|
184
|
+
# generated. Refresh the schema dump and regenerate (rake
|
|
185
|
+
# graph_weaver:schema:refresh && rake graph_weaver:generate).
|
|
186
|
+
def schema_stale?
|
|
187
|
+
errors.any?(&:validation?)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Errors grouped by the field they point at (index-stripped dotted
|
|
191
|
+
# path; nil key for global errors) — iterate with each_error:
|
|
192
|
+
#
|
|
193
|
+
# response.each_error do |field, errors|
|
|
194
|
+
# form.add_error(field, errors.map(&:message))
|
|
195
|
+
# end
|
|
196
|
+
def errors_by_field
|
|
197
|
+
errors.group_by(&:field)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def each_error(&block)
|
|
201
|
+
errors_by_field.each(&block)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The id of the record an error points into, resolved by walking the
|
|
205
|
+
# error's path through the (partial) typed data: an error at
|
|
206
|
+
# ["people", 3, "email"] resolves to people[3].id. nil when the data
|
|
207
|
+
# is missing, the path doesn't walk, or the record has no id field.
|
|
208
|
+
def entity_id(error)
|
|
209
|
+
# untyped by nature: the walk traverses whatever structs this query
|
|
210
|
+
# generated, reassigning across types at each step
|
|
211
|
+
node = T.let(data, T.untyped)
|
|
212
|
+
return unless node && error.path
|
|
213
|
+
|
|
214
|
+
error.path[0..-2].each do |segment|
|
|
215
|
+
node = if segment.is_a?(Integer) || segment.to_s.match?(/\A\d+\z/)
|
|
216
|
+
node.is_a?(Array) ? node[segment.to_i] : nil
|
|
217
|
+
else
|
|
218
|
+
prop = GraphWeaver::Inflect.underscore(segment.to_s).to_sym
|
|
219
|
+
node.respond_to?(prop) ? node.public_send(prop) : nil
|
|
220
|
+
end
|
|
221
|
+
return if node.nil?
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
node.respond_to?(:id) ? node.id : nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The user-facing rollup: errors keyed by field, with the ids of the
|
|
228
|
+
# actual records that failed inlined — "the 3 in people.3.email is
|
|
229
|
+
# useless to a user; people.email plus which people is the answer".
|
|
230
|
+
#
|
|
231
|
+
# { "people.email" => { "messages" => [...], "codes" => [...],
|
|
232
|
+
# "entity_ids" => ["7", "9"], "errors" => [full to_h...] } }
|
|
233
|
+
def report
|
|
234
|
+
errors_by_field.to_h do |field, field_errors|
|
|
235
|
+
[field, {
|
|
236
|
+
"messages" => field_errors.map(&:message),
|
|
237
|
+
"codes" => field_errors.filter_map(&:code).uniq,
|
|
238
|
+
"entity_ids" => field_errors.filter_map { |e| entity_id(e) }.uniq,
|
|
239
|
+
"errors" => field_errors.map(&:to_h),
|
|
240
|
+
}]
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Raised when a GraphQL response carried top-level errors and the caller
|
|
246
|
+
# demanded data (Response#data!, or the one-shot GraphWeaver.execute).
|
|
247
|
+
# Carries the structured errors, any partial data, and top-level
|
|
248
|
+
# extensions (cost/throttle metadata).
|
|
249
|
+
class QueryError < Error
|
|
250
|
+
include ErrorFiltering
|
|
251
|
+
|
|
252
|
+
attr_reader :errors, :data, :extensions
|
|
253
|
+
|
|
254
|
+
def initialize(errors, data: nil, extensions: {})
|
|
255
|
+
@errors = errors
|
|
256
|
+
@data = data
|
|
257
|
+
@extensions = extensions
|
|
258
|
+
super(summary)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# All non-nil error codes — handy for `codes.include?("THROTTLED")`.
|
|
262
|
+
def codes
|
|
263
|
+
errors.map(&:code).compact
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# The machine side: every error with its path/code/extensions, plus
|
|
267
|
+
# the drift verdict — nest this straight into a JSON response.
|
|
268
|
+
def to_h
|
|
269
|
+
super.merge(
|
|
270
|
+
"schema_stale" => schema_stale?,
|
|
271
|
+
"codes" => codes,
|
|
272
|
+
"errors" => errors.map(&:to_h),
|
|
273
|
+
"extensions" => extensions,
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private
|
|
278
|
+
|
|
279
|
+
def summary
|
|
280
|
+
first = errors.first
|
|
281
|
+
more = errors.size > 1 ? " (and #{errors.size - 1} more)" : ""
|
|
282
|
+
drift = schema_stale? ? " — the server rejected the query shape: the schema may have changed since generation; refresh the schema dump and regenerate (rake graph_weaver:schema:refresh && rake graph_weaver:generate)" : ""
|
|
283
|
+
"GraphQL query failed: #{first}#{more}#{drift}"
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Raised when a response can't be cast into the generated structs — the
|
|
288
|
+
# wire data disagreed with the types the schema promised at generation
|
|
289
|
+
# time (a nil where non-null was declared, a malformed scalar, an
|
|
290
|
+
# unknown enum value). #struct names the generated type that failed;
|
|
291
|
+
# #cause carries the original TypeError/KeyError with the offending
|
|
292
|
+
# prop in its message.
|
|
293
|
+
class TypeError < Error
|
|
294
|
+
attr_reader :struct
|
|
295
|
+
|
|
296
|
+
def initialize(struct:, error: nil, message: nil)
|
|
297
|
+
@struct = struct
|
|
298
|
+
super("failed to cast response into #{struct}: #{message || error&.message}")
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def to_h
|
|
302
|
+
super.merge("struct" => struct.to_s, "cause" => cause&.message)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Build-time: the query didn't validate against the schema. Carries the
|
|
307
|
+
# structured validation errors (message + line/column) rather than a
|
|
308
|
+
# joined string. Under the Error umbrella like everything else raised
|
|
309
|
+
# here (through 0.1.0 it was an ArgumentError instead).
|
|
310
|
+
class ValidationError < Error
|
|
311
|
+
attr_reader :errors
|
|
312
|
+
|
|
313
|
+
def initialize(errors)
|
|
314
|
+
@errors = errors
|
|
315
|
+
super("invalid query: #{errors.map { |e| e[:message] }.join("; ")}")
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def to_h
|
|
319
|
+
super.merge("errors" => errors.map { |e| e.transform_keys(&:to_s) })
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require_relative "inflect"
|
|
7
|
+
|
|
8
|
+
module GraphWeaver
|
|
9
|
+
# Included in generated response structs. GraphQL's camelCase fields
|
|
10
|
+
# become snake_case props, and reaching for the wire name is a classic
|
|
11
|
+
# stumble — result.nameWithOwner instead of result.name_with_owner.
|
|
12
|
+
# Catch the miss and point at the prop that does exist ("use ..." when
|
|
13
|
+
# the mapping is exact, "did you mean ...?" for a near-miss typo).
|
|
14
|
+
# (Typed call sites get this hint earlier, from srb tc.)
|
|
15
|
+
module Hints
|
|
16
|
+
include Kernel # for sorbet: hosts are Objects
|
|
17
|
+
|
|
18
|
+
# Guard for generated input-struct .coerce: a hash key that matches
|
|
19
|
+
# no prop raises (spellchecked) instead of silently dropping — a
|
|
20
|
+
# typo'd filter key must not become "match everything" on the wire.
|
|
21
|
+
def self.validate_keys!(struct, hash)
|
|
22
|
+
known = struct.props.keys.map(&:to_s)
|
|
23
|
+
unknown = hash.keys.map(&:to_s) - known
|
|
24
|
+
return if unknown.empty?
|
|
25
|
+
|
|
26
|
+
hints = unknown.map do |key|
|
|
27
|
+
prop = GraphWeaver::Inflect.underscore(key)
|
|
28
|
+
suggestion = if known.include?(prop)
|
|
29
|
+
prop # a wire-cased key — the exact snake_case prop exists
|
|
30
|
+
elsif defined?(DidYouMean::SpellChecker)
|
|
31
|
+
DidYouMean::SpellChecker.new(dictionary: known).correct(prop).first
|
|
32
|
+
end
|
|
33
|
+
suggestion ? "#{key} (did you mean '#{suggestion}'?)" : key
|
|
34
|
+
end
|
|
35
|
+
raise ArgumentError, "unknown key(s) for #{struct}: #{hints.join(", ")}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def method_missing(name, *args, &block)
|
|
39
|
+
if args.empty? && (hint = prop_hint(name.to_s))
|
|
40
|
+
raise NoMethodError, "undefined method '#{name}' for #{self.class} — #{hint}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def prop_hint(name)
|
|
49
|
+
prop = GraphWeaver::Inflect.underscore(name)
|
|
50
|
+
if prop != name && respond_to?(prop)
|
|
51
|
+
return "GraphQL fields generate snake_case props; use '#{prop}'"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
return unless defined?(DidYouMean::SpellChecker)
|
|
55
|
+
|
|
56
|
+
# a guess, not a mapping — spellcheck the (underscored) miss
|
|
57
|
+
# against the props that exist, so typos in either casing land
|
|
58
|
+
props = T.unsafe(self.class).props.keys.map(&:to_s)
|
|
59
|
+
suggestion = DidYouMean::SpellChecker.new(dictionary: props).correct(prop).first
|
|
60
|
+
"did you mean '#{suggestion}'?" if suggestion
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GraphWeaver
|
|
5
|
+
# GraphQL names are plain camelCase/SCREAMING_SNAKE — no acronym edge
|
|
6
|
+
# cases, so minimal inflection beats an activesupport dependency
|
|
7
|
+
module Inflect
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def underscore(name)
|
|
11
|
+
name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def camelize(name)
|
|
15
|
+
# reject: leading underscores split into empty parts (_Service, _and)
|
|
16
|
+
name.split("_").reject(&:empty?).map { |part| "#{part[0].upcase}#{part[1..]}" }.join
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GraphWeaver
|
|
5
|
+
class << self
|
|
6
|
+
# Where GraphWeaver narrates what it's doing — anything
|
|
7
|
+
# stdlib-Logger-compatible (Logger, Rails.logger, semantic_logger...).
|
|
8
|
+
# Silent by default; Rails apps get Rails.logger wired by the railtie.
|
|
9
|
+
#
|
|
10
|
+
# GraphWeaver.logger = Logger.new($stdout, level: Logger::INFO)
|
|
11
|
+
#
|
|
12
|
+
# What logs where:
|
|
13
|
+
# debug — full queries + variables on the wire, responses
|
|
14
|
+
# (status/bytes/ms), connection lifecycle, parsed modules
|
|
15
|
+
# info — schema introspection and cache decisions, generated files
|
|
16
|
+
# written, query modules loaded
|
|
17
|
+
# warn — every GraphWeaver error raised
|
|
18
|
+
#
|
|
19
|
+
# Queries, variables, and responses appear at debug ONLY — they can
|
|
20
|
+
# carry PII. Auth headers never log.
|
|
21
|
+
attr_accessor :logger
|
|
22
|
+
|
|
23
|
+
# Internal: level-gated and lazy — the block only runs when a logger
|
|
24
|
+
# is listening. Messages carry "graph_weaver" as progname.
|
|
25
|
+
def log(level, &block)
|
|
26
|
+
logger&.public_send(level, "graph_weaver", &block)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Internal: run the block, logging "<label> (Nms)" at level — timing
|
|
30
|
+
# skipped entirely when no logger is set. Returns the block's value.
|
|
31
|
+
def log_timed(level, label)
|
|
32
|
+
return yield unless logger
|
|
33
|
+
|
|
34
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
35
|
+
result = yield
|
|
36
|
+
ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
|
|
37
|
+
log(level) { "#{label} (#{ms}ms)" }
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# typed: ignore — Rails::Railtie DSL
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Rails wiring, so the conventional layout needs no ceremony:
|
|
5
|
+
#
|
|
6
|
+
# - rake tasks: Rails.application.load_tasks collects every Railtie's
|
|
7
|
+
# rake_tasks block, so graph_weaver:* tasks appear with no Rakefile
|
|
8
|
+
# edit. (Outside Rails there is no task-discovery hook — add
|
|
9
|
+
# `require "graph_weaver/tasks"` to your Rakefile.)
|
|
10
|
+
# - generated modules: required at boot when generated_path exists,
|
|
11
|
+
# after config/initializers (registrations and GraphWeaver.client=
|
|
12
|
+
# run first — block-built type helpers must exist before the files
|
|
13
|
+
# that include them load). load_generated! stays idempotent, so
|
|
14
|
+
# calling it yourself too is harmless.
|
|
15
|
+
class GraphWeaver::Railtie < Rails::Railtie
|
|
16
|
+
rake_tasks do
|
|
17
|
+
require "graph_weaver/tasks"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Rails.logger, unless the app already chose one (set
|
|
21
|
+
# GraphWeaver.logger = nil in an initializer to silence)
|
|
22
|
+
initializer "graph_weaver.logger" do
|
|
23
|
+
GraphWeaver.logger = Rails.logger if GraphWeaver.logger.nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
initializer "graph_weaver.load_generated", after: :load_config_initializers do
|
|
27
|
+
GraphWeaver.load_generated! if Dir.exist?(GraphWeaver.generated_path)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module GraphWeaver
|
|
9
|
+
# The envelope every generated #execute returns: the typed data (nil on a
|
|
10
|
+
# total failure), the top-level GraphQL errors, and top-level extensions
|
|
11
|
+
# (cost/throttle metadata). Generic over the query's Result type so
|
|
12
|
+
# response.data stays fully typed — the generated code instantiates it as
|
|
13
|
+
# GraphWeaver::Response[SomeQuery::Result]. #data! is the strict accessor:
|
|
14
|
+
# the result, or a raised QueryError.
|
|
15
|
+
class Response
|
|
16
|
+
extend T::Sig
|
|
17
|
+
extend T::Generic
|
|
18
|
+
include ErrorFiltering
|
|
19
|
+
|
|
20
|
+
Data = type_member
|
|
21
|
+
|
|
22
|
+
sig { returns(T.nilable(Data)) }
|
|
23
|
+
attr_reader :data
|
|
24
|
+
|
|
25
|
+
sig { returns(T::Array[GraphWeaver::GraphQLError]) }
|
|
26
|
+
attr_reader :errors
|
|
27
|
+
|
|
28
|
+
sig { returns(T::Hash[String, T.untyped]) }
|
|
29
|
+
attr_reader :extensions
|
|
30
|
+
|
|
31
|
+
sig do
|
|
32
|
+
params(
|
|
33
|
+
data: T.nilable(Data),
|
|
34
|
+
errors: T::Array[GraphWeaver::GraphQLError],
|
|
35
|
+
extensions: T::Hash[String, T.untyped],
|
|
36
|
+
).void
|
|
37
|
+
end
|
|
38
|
+
def initialize(data:, errors: [], extensions: {})
|
|
39
|
+
@data = data
|
|
40
|
+
@errors = errors
|
|
41
|
+
@extensions = extensions
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
sig { returns(T::Boolean) }
|
|
45
|
+
def errors? = !errors.empty?
|
|
46
|
+
|
|
47
|
+
# The typed result, or raise QueryError if the response carried top-level
|
|
48
|
+
# errors (partial data and extensions ride along on the error).
|
|
49
|
+
sig { returns(Data) }
|
|
50
|
+
def data!
|
|
51
|
+
raise GraphWeaver::QueryError.new(errors, data: data, extensions: extensions) unless errors.empty?
|
|
52
|
+
T.must(data)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
# Wraps any client/transport with configurable retries — it satisfies
|
|
7
|
+
# the same execute contract, so it layers over HTTP, Faraday, or
|
|
8
|
+
# anything else:
|
|
9
|
+
#
|
|
10
|
+
# client = GraphWeaver::Retry.new(
|
|
11
|
+
# GraphWeaver::Transport::HTTP.new(url),
|
|
12
|
+
# tries: 5, # total attempts, first included
|
|
13
|
+
# on: [GraphWeaver::TransportError, GraphWeaver::ServerError],
|
|
14
|
+
# backoff: :exponential, # or :linear, or ->(attempt) { seconds }
|
|
15
|
+
# base: 0.5, max: 30, # seconds; delays clamp at max:
|
|
16
|
+
# jitter: true, # randomize each delay by 50-100%
|
|
17
|
+
# retry_codes: ["THROTTLED"], # also retry GraphQL errors by code
|
|
18
|
+
# )
|
|
19
|
+
#
|
|
20
|
+
# What retries, by default:
|
|
21
|
+
# - TransportError: always (the request never arrived)
|
|
22
|
+
# - ServerError: only 5xx — a 4xx is a bug in the request, retrying
|
|
23
|
+
# won't fix it. Override with retry_if: ->(error) { ... }
|
|
24
|
+
# - responses whose GraphQL error codes intersect retry_codes: (off by
|
|
25
|
+
# default — pass the codes your API uses for transient failures)
|
|
26
|
+
#
|
|
27
|
+
# Exhausting tries re-raises the last error (or returns the last
|
|
28
|
+
# code-matched response).
|
|
29
|
+
class GraphWeaver::Retry
|
|
30
|
+
BACKOFFS = {
|
|
31
|
+
exponential: ->(base, attempt) { base * (2**(attempt - 1)) },
|
|
32
|
+
linear: ->(base, attempt) { base * attempt },
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# retry 5xx, not 4xx; everything else listed in on: retries
|
|
36
|
+
DEFAULT_RETRY_IF = lambda do |error|
|
|
37
|
+
!error.is_a?(GraphWeaver::ServerError) || error.status >= 500
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(client, tries: 3, on: [GraphWeaver::TransportError, GraphWeaver::ServerError],
|
|
41
|
+
backoff: :exponential, base: 0.5, max: 30, jitter: true, retry_if: DEFAULT_RETRY_IF,
|
|
42
|
+
retry_codes: [], sleeper: nil)
|
|
43
|
+
raise ArgumentError, "tries: must be >= 1" unless tries >= 1
|
|
44
|
+
|
|
45
|
+
@client = client
|
|
46
|
+
@tries = tries
|
|
47
|
+
@on = on
|
|
48
|
+
@backoff = if backoff.is_a?(Proc)
|
|
49
|
+
->(_base, attempt) { backoff.call(attempt) } # custom: ->(attempt) { seconds }
|
|
50
|
+
else
|
|
51
|
+
BACKOFFS.fetch(backoff) {
|
|
52
|
+
raise ArgumentError, "backoff: must be :exponential, :linear, or a Proc, got #{backoff.inspect}"
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
@base = base
|
|
56
|
+
@max = max
|
|
57
|
+
@jitter = jitter
|
|
58
|
+
@retry_if = retry_if
|
|
59
|
+
@retry_codes = retry_codes
|
|
60
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# surface the wrapped transport's endpoint (schema-dump provenance)
|
|
64
|
+
def url
|
|
65
|
+
@client.url if @client.respond_to?(:url)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def execute(query, variables: {})
|
|
69
|
+
attempt = 0
|
|
70
|
+
|
|
71
|
+
loop do
|
|
72
|
+
attempt += 1
|
|
73
|
+
begin
|
|
74
|
+
response = @client.execute(query, variables:)
|
|
75
|
+
return response unless attempt < @tries && retryable_response?(response)
|
|
76
|
+
rescue *@on => e
|
|
77
|
+
raise if attempt >= @tries || !@retry_if.call(e)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@sleeper.call(delay(attempt))
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def retryable_response?(response)
|
|
87
|
+
return false if @retry_codes.empty?
|
|
88
|
+
|
|
89
|
+
codes = (response.to_h["errors"] || []).filter_map { |error| error.dig("extensions", "code") }
|
|
90
|
+
codes.intersect?(@retry_codes)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def delay(attempt)
|
|
94
|
+
seconds = [@backoff.call(@base, attempt), @max].min.to_f
|
|
95
|
+
@jitter ? seconds * (0.5 + rand * 0.5) : seconds
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "testing"
|
|
5
|
+
|
|
6
|
+
# RSpec integration — require from your spec helper instead of
|
|
7
|
+
# "graph_weaver/testing":
|
|
8
|
+
#
|
|
9
|
+
# require "graph_weaver/rspec"
|
|
10
|
+
#
|
|
11
|
+
# That one line is the whole setup in a conventional app: the schema
|
|
12
|
+
# auto-locates from the committed dump (GraphWeaver.schema_path) and
|
|
13
|
+
# auto_fake defaults on, so every example runs against a schema-correct
|
|
14
|
+
# fake. Configure to override:
|
|
15
|
+
#
|
|
16
|
+
# GraphWeaver::Testing.configure do |config|
|
|
17
|
+
# config.schema = MySchema # instead of the located dump
|
|
18
|
+
# config.auto_fake = false # opt out of per-example fakes
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# What it wires up:
|
|
22
|
+
# - seed: defaults to rspec's --seed, so `rspec --seed 1234` reproduces
|
|
23
|
+
# fake data along with test order
|
|
24
|
+
# - auto_fake: when on (and a schema resolves), each example gets a
|
|
25
|
+
# fresh seeded FakeClient installed as GraphWeaver.client —
|
|
26
|
+
# generated modules run in test mode with zero per-test setup. The
|
|
27
|
+
# prior client is restored after each example.
|
|
28
|
+
#
|
|
29
|
+
# note: modules generated with a baked-in client: constant don't consult
|
|
30
|
+
# GraphWeaver.client — generate without client: to make them fakeable.
|
|
31
|
+
module GraphWeaver
|
|
32
|
+
module Testing
|
|
33
|
+
module RSpecIntegration
|
|
34
|
+
def self.install(rspec_config)
|
|
35
|
+
rspec_config.before(:suite) do
|
|
36
|
+
config = GraphWeaver::Testing.config
|
|
37
|
+
config.seed ||= RSpec.configuration.seed
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
rspec_config.before(:each) do
|
|
41
|
+
config = GraphWeaver::Testing.config
|
|
42
|
+
next unless config.auto_fake && config.schema
|
|
43
|
+
|
|
44
|
+
@__graph_weaver_prior_client = GraphWeaver.client
|
|
45
|
+
GraphWeaver.client = FakeClient.new(schema: config.schema)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
rspec_config.after(:each) do
|
|
49
|
+
config = GraphWeaver::Testing.config
|
|
50
|
+
next unless config.auto_fake && config.schema
|
|
51
|
+
|
|
52
|
+
GraphWeaver.client = @__graph_weaver_prior_client
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
RSpec.configure { |config| GraphWeaver::Testing::RSpecIntegration.install(config) } if defined?(RSpec)
|