graph_weaver 0.0.1 → 0.1.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/CHANGELOG.md +66 -0
- data/Gemfile.lock +45 -17
- data/Makefile +6 -1
- data/PLAN.md +52 -22
- data/README.md +62 -17
- data/docs/errors.md +106 -0
- data/docs/generated_modules.md +119 -0
- data/docs/real_world.md +46 -0
- data/docs/scalars.md +69 -0
- data/docs/testing.md +93 -0
- data/graph_weaver.gemspec +3 -0
- data/lib/graph_weaver/codegen/emit.rb +237 -0
- data/lib/graph_weaver/codegen/nodes.rb +256 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +238 -0
- data/lib/graph_weaver/codegen.rb +156 -395
- data/lib/graph_weaver/errors.rb +310 -0
- data/lib/graph_weaver/faraday_executor.rb +61 -0
- data/lib/graph_weaver/http_executor.rb +16 -3
- data/lib/graph_weaver/inflect.rb +18 -0
- data/lib/graph_weaver/response.rb +55 -0
- data/lib/graph_weaver/rspec.rb +54 -0
- data/lib/graph_weaver/schema_loader.rb +73 -9
- data/lib/graph_weaver/selection.rb +68 -0
- data/lib/graph_weaver/testing/cassette.rb +203 -0
- data/lib/graph_weaver/testing/failure.rb +109 -0
- data/lib/graph_weaver/testing/fake_executor.rb +228 -0
- data/lib/graph_weaver/testing/rspec.rb +5 -0
- data/lib/graph_weaver/testing/values.rb +98 -0
- data/lib/graph_weaver/testing.rb +90 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +97 -3
- metadata +63 -1
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "set"
|
|
5
|
+
require "sorbet-runtime"
|
|
6
|
+
require_relative "inflect"
|
|
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. Subclasses merge in their specifics.
|
|
13
|
+
class Error < StandardError
|
|
14
|
+
def to_h
|
|
15
|
+
{ "error" => self.class.name, "message" => message }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The request never reached the server: connection refused, DNS failure,
|
|
20
|
+
# TLS handshake, timeout. The original exception is preserved as #cause.
|
|
21
|
+
# Generally retriable.
|
|
22
|
+
class TransportError < Error
|
|
23
|
+
def to_h
|
|
24
|
+
super.merge("cause" => cause&.class&.name)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# The exception classes the bundled executors reclassify as
|
|
30
|
+
# TransportError — network-level failures where the request never
|
|
31
|
+
# reached the server. A mutable Set: each transport contributes its own
|
|
32
|
+
# on load (net/http adds Timeout/SSL, Faraday adds its ConnectionFailed,
|
|
33
|
+
# …), and you can add more so they get the same handling:
|
|
34
|
+
#
|
|
35
|
+
# GraphWeaver.transport_errors << MyPool::TimeoutError
|
|
36
|
+
# GraphWeaver.register_transport_error(Adapter::ResetError)
|
|
37
|
+
#
|
|
38
|
+
# SystemCallError covers every Errno::* (connection refused/reset, host
|
|
39
|
+
# unreachable); SocketError covers DNS.
|
|
40
|
+
def transport_errors
|
|
41
|
+
@transport_errors ||= Set[SocketError, SystemCallError, IOError]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Add one or more exception classes to the transport-error set.
|
|
45
|
+
def register_transport_error(*classes)
|
|
46
|
+
transport_errors.merge(classes)
|
|
47
|
+
classes
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The request reached the server but it returned a non-2xx status — a 500
|
|
52
|
+
# that exploded, a 502 from a proxy, a 401, etc. Distinct from a GraphQL
|
|
53
|
+
# error: we got an HTTP response, it just wasn't success.
|
|
54
|
+
class ServerError < Error
|
|
55
|
+
attr_reader :status, :body
|
|
56
|
+
|
|
57
|
+
def initialize(status:, body: nil)
|
|
58
|
+
@status = status
|
|
59
|
+
@body = body
|
|
60
|
+
snippet = body.to_s.empty? ? "" : ": #{body.to_s[0, 500]}"
|
|
61
|
+
super("HTTP #{status}#{snippet}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def to_h
|
|
65
|
+
super.merge("status" => status)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# One entry from a GraphQL response's top-level `errors` array. A value
|
|
70
|
+
# object (not raised) — the response envelope and QueryError carry these.
|
|
71
|
+
# Match on #code (extensions["code"]) rather than the message string.
|
|
72
|
+
class GraphQLError
|
|
73
|
+
attr_reader :message, :locations, :path, :extensions
|
|
74
|
+
|
|
75
|
+
def initialize(message:, locations: [], path: nil, extensions: {})
|
|
76
|
+
@message = message
|
|
77
|
+
@locations = locations
|
|
78
|
+
@path = path
|
|
79
|
+
@extensions = extensions
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.from_h(hash)
|
|
83
|
+
new(
|
|
84
|
+
message: hash["message"] || "(no message)",
|
|
85
|
+
locations: hash["locations"] || [],
|
|
86
|
+
path: hash["path"],
|
|
87
|
+
extensions: hash["extensions"] || {},
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The machine-readable error code, e.g. "THROTTLED" — the thing to
|
|
92
|
+
# branch on. nil when the server didn't set extensions.code.
|
|
93
|
+
def code
|
|
94
|
+
extensions["code"]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Message shapes servers use when they reject the *shape* of a query
|
|
98
|
+
# (unknown field/type/argument). Heuristic by necessity: only Apollo
|
|
99
|
+
# sets a standard code (GRAPHQL_VALIDATION_FAILED); graphql-ruby and
|
|
100
|
+
# GitHub speak in messages.
|
|
101
|
+
VALIDATION_MESSAGE = /doesn't exist|Cannot query field|Unknown (field|type|argument)|isn't defined|undefined (field|type)/i
|
|
102
|
+
|
|
103
|
+
# True when this error looks like the server rejected the query's
|
|
104
|
+
# shape — for a generated module that usually means the schema
|
|
105
|
+
# changed after generation.
|
|
106
|
+
def validation?
|
|
107
|
+
code == "GRAPHQL_VALIDATION_FAILED" || VALIDATION_MESSAGE.match?(message)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The field the error points at, as a stable dotted path with list
|
|
111
|
+
# indices stripped — ["people", 3, "email"] => "people.email". The
|
|
112
|
+
# parseable key for grouping/reporting (the raw #path keeps indices).
|
|
113
|
+
# nil for global errors with no path.
|
|
114
|
+
def field
|
|
115
|
+
return unless path
|
|
116
|
+
|
|
117
|
+
named = path.reject { |seg| seg.is_a?(Integer) || seg.to_s.match?(/\A\d+\z/) }
|
|
118
|
+
named.join(".") unless named.empty?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def to_s
|
|
122
|
+
loc = locations&.first
|
|
123
|
+
at = loc ? " at #{loc["line"]}:#{loc["column"]}" : ""
|
|
124
|
+
where = path ? " (path: #{path.join(".")})" : ""
|
|
125
|
+
tag = code ? " [#{code}]" : ""
|
|
126
|
+
"#{message}#{at}#{where}#{tag}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# JSON-ready: the problematic field (both forms — #field for grouping,
|
|
130
|
+
# #path with indices for exact location), the machine code, and the
|
|
131
|
+
# server's full extensions.
|
|
132
|
+
def to_h
|
|
133
|
+
{
|
|
134
|
+
"message" => message,
|
|
135
|
+
"code" => code,
|
|
136
|
+
"field" => field,
|
|
137
|
+
"path" => path,
|
|
138
|
+
"locations" => locations,
|
|
139
|
+
"extensions" => extensions,
|
|
140
|
+
"validation" => validation?,
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Shared filtering over a collection of GraphQLErrors, for surfacing
|
|
146
|
+
# field-level failures programmatically. Host must define #errors.
|
|
147
|
+
module ErrorFiltering
|
|
148
|
+
include Kernel # for sorbet: hosts are Objects
|
|
149
|
+
|
|
150
|
+
# the host's interface, overridden by its attr_readers
|
|
151
|
+
def errors
|
|
152
|
+
raise NotImplementedError, "#{self.class} must define #errors"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def data
|
|
156
|
+
raise NotImplementedError, "#{self.class} must define #data"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Errors touching a field path — "user.email" or ["user", "email"];
|
|
160
|
+
# prefix match, so deeper errors count too. List indices appear as
|
|
161
|
+
# path segments ("people.0.email").
|
|
162
|
+
def errors_at(path)
|
|
163
|
+
want = (path.is_a?(String) ? path.split(".") : path).map(&:to_s)
|
|
164
|
+
errors.select { |error| error.path && error.path.map(&:to_s).first(want.size) == want }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# True when any error looks like the server rejected the query's
|
|
168
|
+
# shape — the schema has likely changed since the module was
|
|
169
|
+
# generated. Regenerate (bin/generate) and/or refresh the schema
|
|
170
|
+
# cache (delete the cache: file, or wait out its ttl).
|
|
171
|
+
def schema_stale?
|
|
172
|
+
errors.any?(&:validation?)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Errors grouped by the field they point at (index-stripped dotted
|
|
176
|
+
# path; nil key for global errors) — iterate with each_error:
|
|
177
|
+
#
|
|
178
|
+
# response.each_error do |field, errors|
|
|
179
|
+
# form.add_error(field, errors.map(&:message))
|
|
180
|
+
# end
|
|
181
|
+
def errors_by_field
|
|
182
|
+
errors.group_by(&:field)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def each_error(&block)
|
|
186
|
+
errors_by_field.each(&block)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The id of the record an error points into, resolved by walking the
|
|
190
|
+
# error's path through the (partial) typed data: an error at
|
|
191
|
+
# ["people", 3, "email"] resolves to people[3].id. nil when the data
|
|
192
|
+
# is missing, the path doesn't walk, or the record has no id field.
|
|
193
|
+
def entity_id(error)
|
|
194
|
+
# untyped by nature: the walk traverses whatever structs this query
|
|
195
|
+
# generated, reassigning across types at each step
|
|
196
|
+
node = T.let(data, T.untyped)
|
|
197
|
+
return unless node && error.path
|
|
198
|
+
|
|
199
|
+
error.path[0..-2].each do |segment|
|
|
200
|
+
node = if segment.is_a?(Integer) || segment.to_s.match?(/\A\d+\z/)
|
|
201
|
+
node.is_a?(Array) ? node[segment.to_i] : nil
|
|
202
|
+
else
|
|
203
|
+
prop = GraphWeaver::Inflect.underscore(segment.to_s).to_sym
|
|
204
|
+
node.respond_to?(prop) ? node.public_send(prop) : nil
|
|
205
|
+
end
|
|
206
|
+
return if node.nil?
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
node.respond_to?(:id) ? node.id : nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The user-facing rollup: errors keyed by field, with the ids of the
|
|
213
|
+
# actual records that failed inlined — "the 3 in people.3.email is
|
|
214
|
+
# useless to a user; people.email plus which people is the answer".
|
|
215
|
+
#
|
|
216
|
+
# { "people.email" => { "messages" => [...], "codes" => [...],
|
|
217
|
+
# "entity_ids" => ["7", "9"], "errors" => [full to_h...] } }
|
|
218
|
+
def report
|
|
219
|
+
errors_by_field.to_h do |field, field_errors|
|
|
220
|
+
[field, {
|
|
221
|
+
"messages" => field_errors.map(&:message),
|
|
222
|
+
"codes" => field_errors.filter_map(&:code).uniq,
|
|
223
|
+
"entity_ids" => field_errors.filter_map { |e| entity_id(e) }.uniq,
|
|
224
|
+
"errors" => field_errors.map(&:to_h),
|
|
225
|
+
}]
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Raised when a GraphQL response carried top-level errors and the caller
|
|
231
|
+
# demanded data (Response#data!, or the one-shot GraphWeaver.execute).
|
|
232
|
+
# Carries the structured errors, any partial data, and top-level
|
|
233
|
+
# extensions (cost/throttle metadata).
|
|
234
|
+
class QueryError < Error
|
|
235
|
+
include ErrorFiltering
|
|
236
|
+
|
|
237
|
+
attr_reader :errors, :data, :extensions
|
|
238
|
+
|
|
239
|
+
def initialize(errors, data: nil, extensions: {})
|
|
240
|
+
@errors = errors
|
|
241
|
+
@data = data
|
|
242
|
+
@extensions = extensions
|
|
243
|
+
super(summary)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# All non-nil error codes — handy for `codes.include?("THROTTLED")`.
|
|
247
|
+
def codes
|
|
248
|
+
errors.map(&:code).compact
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# The machine side: every error with its path/code/extensions, plus
|
|
252
|
+
# the drift verdict — nest this straight into a JSON response.
|
|
253
|
+
def to_h
|
|
254
|
+
super.merge(
|
|
255
|
+
"schema_stale" => schema_stale?,
|
|
256
|
+
"codes" => codes,
|
|
257
|
+
"errors" => errors.map(&:to_h),
|
|
258
|
+
"extensions" => extensions,
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
private
|
|
263
|
+
|
|
264
|
+
def summary
|
|
265
|
+
first = errors.first
|
|
266
|
+
more = errors.size > 1 ? " (and #{errors.size - 1} more)" : ""
|
|
267
|
+
drift = schema_stale? ? " — the server rejected the query shape: the schema may have changed since generation; regenerate modules (bin/generate) and/or refresh the schema cache" : ""
|
|
268
|
+
"GraphQL query failed: #{first}#{more}#{drift}"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Raised when a response can't be cast into the generated structs — the
|
|
273
|
+
# wire data disagreed with the types the schema promised at generation
|
|
274
|
+
# time (a nil where non-null was declared, a malformed scalar, an
|
|
275
|
+
# unknown enum value). #struct names the generated type that failed;
|
|
276
|
+
# #cause carries the original TypeError/KeyError with the offending
|
|
277
|
+
# prop in its message.
|
|
278
|
+
class TypeError < Error
|
|
279
|
+
attr_reader :struct
|
|
280
|
+
|
|
281
|
+
def initialize(struct:, error: nil, message: nil)
|
|
282
|
+
@struct = struct
|
|
283
|
+
super("failed to cast response into #{struct}: #{message || error&.message}")
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def to_h
|
|
287
|
+
super.merge("struct" => struct.to_s, "cause" => cause&.message)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Build-time: the query didn't validate against the schema. Kept an
|
|
292
|
+
# ArgumentError for source compatibility, but carries the structured
|
|
293
|
+
# validation errors (message + line/column) rather than a joined string.
|
|
294
|
+
class ValidationError < ArgumentError
|
|
295
|
+
attr_reader :errors
|
|
296
|
+
|
|
297
|
+
def initialize(errors)
|
|
298
|
+
@errors = errors
|
|
299
|
+
super("invalid query: #{errors.map { |e| e[:message] }.join("; ")}")
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def to_h
|
|
303
|
+
{
|
|
304
|
+
"error" => self.class.name,
|
|
305
|
+
"message" => message,
|
|
306
|
+
"errors" => errors.map { |e| e.transform_keys(&:to_s) },
|
|
307
|
+
}
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "faraday"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
|
|
9
|
+
# Faraday-backed transport. Opt-in (faraday is not a hard dependency):
|
|
10
|
+
#
|
|
11
|
+
# require "graph_weaver/faraday_executor"
|
|
12
|
+
#
|
|
13
|
+
# # simplest: build a default connection from a url
|
|
14
|
+
# GraphWeaver::FaradayExecutor.new("https://api.example.com/graphql")
|
|
15
|
+
#
|
|
16
|
+
# # customize middleware while building
|
|
17
|
+
# GraphWeaver::FaradayExecutor.new(url) do |conn|
|
|
18
|
+
# conn.request :authorization, "Bearer", -> { Tokens.fetch }
|
|
19
|
+
# conn.response :logger
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# # or bring a fully configured connection
|
|
23
|
+
# GraphWeaver::FaradayExecutor.new(Faraday.new(url:) { |conn| ... })
|
|
24
|
+
class GraphWeaver::FaradayExecutor
|
|
25
|
+
# Faraday's network-level failures — added to the shared, extensible
|
|
26
|
+
# transport-error set.
|
|
27
|
+
GraphWeaver.register_transport_error(
|
|
28
|
+
Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def initialize(url_or_connection, headers: {}, &block)
|
|
32
|
+
@connection = case url_or_connection
|
|
33
|
+
when Faraday::Connection
|
|
34
|
+
url_or_connection
|
|
35
|
+
else
|
|
36
|
+
# Faraday appends the default adapter when the block doesn't set one
|
|
37
|
+
Faraday.new(url: url_or_connection, headers:, &block)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def execute(query, variables: {})
|
|
42
|
+
response = begin
|
|
43
|
+
@connection.post do |request|
|
|
44
|
+
request.headers["Content-Type"] = "application/json"
|
|
45
|
+
request.body = JSON.generate(query:, variables:)
|
|
46
|
+
end
|
|
47
|
+
rescue *GraphWeaver.transport_errors.to_a => e
|
|
48
|
+
# never got a response — connection refused/reset, TLS, timeout
|
|
49
|
+
raise GraphWeaver::TransportError, "#{e.class}: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# reached the server, but it returned a non-2xx status
|
|
53
|
+
unless response.success?
|
|
54
|
+
raise GraphWeaver::ServerError.new(status: response.status, body: response.body.to_s)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
body = response.body
|
|
58
|
+
# a caller's connection may already parse json via middleware
|
|
59
|
+
body.is_a?(String) ? JSON.parse(body) : body
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -4,11 +4,18 @@
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "sorbet-runtime"
|
|
6
6
|
require "net/http"
|
|
7
|
+
require "openssl"
|
|
7
8
|
require "uri"
|
|
8
9
|
|
|
10
|
+
require_relative "errors"
|
|
11
|
+
|
|
9
12
|
# Minimal HTTP transport satisfying the generated modules' executor
|
|
10
13
|
# interface: execute(query, variables:) => {"data" => ..., "errors" => ...}
|
|
11
14
|
class GraphWeaver::HttpExecutor
|
|
15
|
+
# net/http's own network-level failures (Errno/SocketError/IOError are
|
|
16
|
+
# already seeded) — added to the shared, extensible transport-error set.
|
|
17
|
+
GraphWeaver.register_transport_error(Timeout::Error, OpenSSL::SSL::SSLError)
|
|
18
|
+
|
|
12
19
|
def initialize(url, headers: {})
|
|
13
20
|
@uri = URI(url)
|
|
14
21
|
@headers = headers
|
|
@@ -18,12 +25,18 @@ class GraphWeaver::HttpExecutor
|
|
|
18
25
|
request = Net::HTTP::Post.new(@uri, { "Content-Type" => "application/json" }.merge(@headers))
|
|
19
26
|
request.body = JSON.generate(query:, variables:)
|
|
20
27
|
|
|
21
|
-
response =
|
|
22
|
-
|
|
28
|
+
response = begin
|
|
29
|
+
Net::HTTP.start(@uri.hostname, @uri.port, use_ssl: @uri.scheme == "https") do |http|
|
|
30
|
+
http.request(request)
|
|
31
|
+
end
|
|
32
|
+
rescue *GraphWeaver.transport_errors.to_a => e
|
|
33
|
+
# never got a response — DNS, connection refused/reset, TLS, timeout
|
|
34
|
+
raise GraphWeaver::TransportError, "#{e.class}: #{e.message}"
|
|
23
35
|
end
|
|
24
36
|
|
|
37
|
+
# reached the server, but it returned a non-2xx status
|
|
25
38
|
unless response.is_a?(Net::HTTPSuccess)
|
|
26
|
-
raise
|
|
39
|
+
raise GraphWeaver::ServerError.new(status: response.code.to_i, body: response.body)
|
|
27
40
|
end
|
|
28
41
|
|
|
29
42
|
JSON.parse(T.must(response.body))
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
name.split("_").map { |part| "#{part[0].upcase}#{part[1..]}" }.join
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
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,54 @@
|
|
|
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
|
+
# GraphWeaver::Testing.configure do |config|
|
|
12
|
+
# config.schema = MySchema
|
|
13
|
+
# config.auto_fake = true # every example runs against a FakeExecutor
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# What it wires up:
|
|
17
|
+
# - seed: defaults to rspec's --seed, so `rspec --seed 1234` reproduces
|
|
18
|
+
# fake data along with test order
|
|
19
|
+
# - auto_fake: when on (and schema is set), each example gets a fresh
|
|
20
|
+
# seeded FakeExecutor installed as GraphWeaver.executor — generated
|
|
21
|
+
# modules run in test mode with zero per-test setup. The prior
|
|
22
|
+
# executor is restored after each example.
|
|
23
|
+
#
|
|
24
|
+
# note: modules generated with a baked-in executor: constant don't consult
|
|
25
|
+
# GraphWeaver.executor — generate without executor: to make them fakeable.
|
|
26
|
+
module GraphWeaver
|
|
27
|
+
module Testing
|
|
28
|
+
module RSpecIntegration
|
|
29
|
+
def self.install(rspec_config)
|
|
30
|
+
rspec_config.before(:suite) do
|
|
31
|
+
config = GraphWeaver::Testing.config
|
|
32
|
+
config.seed ||= RSpec.configuration.seed
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
rspec_config.before(:each) do
|
|
36
|
+
config = GraphWeaver::Testing.config
|
|
37
|
+
next unless config.auto_fake && config.schema
|
|
38
|
+
|
|
39
|
+
@__graph_weaver_prior_executor = GraphWeaver.instance_variable_get(:@executor)
|
|
40
|
+
GraphWeaver.executor = FakeExecutor.new(schema: config.schema)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
rspec_config.after(:each) do
|
|
44
|
+
config = GraphWeaver::Testing.config
|
|
45
|
+
next unless config.auto_fake && config.schema
|
|
46
|
+
|
|
47
|
+
GraphWeaver.executor = @__graph_weaver_prior_executor
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
RSpec.configure { |config| GraphWeaver::Testing::RSpecIntegration.install(config) } if defined?(RSpec)
|
|
@@ -1,20 +1,84 @@
|
|
|
1
1
|
# typed: true
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "fileutils"
|
|
4
5
|
require "graphql"
|
|
5
6
|
require "json"
|
|
7
|
+
require_relative "errors"
|
|
6
8
|
|
|
7
9
|
# Load a schema for codegen from either format a remote service can hand
|
|
8
|
-
# you
|
|
10
|
+
# you — introspection JSON or SDL, as a file path or the content itself —
|
|
11
|
+
# or fetch one straight from a live endpoint via introspect.
|
|
9
12
|
module GraphWeaver::SchemaLoader
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
# Accepts, and detects:
|
|
14
|
+
# - a Hash (a parsed introspection result)
|
|
15
|
+
# - a file path — .json (introspection) or .graphql/.gql (SDL)
|
|
16
|
+
# - raw content — introspection JSON (starts with "{") or SDL
|
|
17
|
+
# so a cache round-trip is symmetrical with introspect:
|
|
18
|
+
# SchemaLoader.load(cached_json) # from Rails.cache/redis/...
|
|
19
|
+
def self.load(source)
|
|
20
|
+
return GraphQL::Schema.from_introspection(source) if source.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
if source.lstrip.start_with?("{") # introspection JSON content
|
|
23
|
+
GraphQL::Schema.from_introspection(JSON.parse(source))
|
|
24
|
+
elsif source.include?("\n") # multi-line: SDL content
|
|
25
|
+
unless source.match?(/^\s*(schema|type|interface|union|enum|scalar|directive|input|")/)
|
|
26
|
+
raise ArgumentError, "unsupported schema content: #{source.lstrip[0, 80].inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
GraphQL::Schema.from_definition(source)
|
|
30
|
+
else # a file path
|
|
31
|
+
case File.extname(source)
|
|
32
|
+
when ".json"
|
|
33
|
+
GraphQL::Schema.from_introspection(JSON.parse(File.read(source)))
|
|
34
|
+
when ".graphql", ".gql"
|
|
35
|
+
GraphQL::Schema.from_definition(File.read(source))
|
|
36
|
+
else
|
|
37
|
+
raise ArgumentError, "unsupported schema format: #{source}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Run the standard introspection query through an executor and build a
|
|
43
|
+
# schema from the result:
|
|
44
|
+
#
|
|
45
|
+
# executor = GraphWeaver::HttpExecutor.new(url, headers: { ... })
|
|
46
|
+
# schema = GraphWeaver::SchemaLoader.introspect(executor)
|
|
47
|
+
#
|
|
48
|
+
# Introspecting a large API takes seconds, so cache: (a file path)
|
|
49
|
+
# stores the raw introspection JSON and reuses it until ttl: seconds
|
|
50
|
+
# elapse (no ttl = until the file is deleted). GraphQL has no standard
|
|
51
|
+
# schema-version signal to invalidate on — a stale cache surfaces as
|
|
52
|
+
# server-side validation errors (see QueryError#schema_stale?), so pick
|
|
53
|
+
# a ttl that matches how fast the API moves, or delete the file.
|
|
54
|
+
#
|
|
55
|
+
# To cache anywhere else (Rails.cache, redis, ...), serialize the schema
|
|
56
|
+
# itself — schemas round-trip through their introspection JSON:
|
|
57
|
+
#
|
|
58
|
+
# json = Rails.cache.fetch("gh_schema", expires_in: 12.hours) do
|
|
59
|
+
# GraphWeaver::SchemaLoader.introspect(executor).to_json
|
|
60
|
+
# end
|
|
61
|
+
# schema = GraphWeaver::SchemaLoader.load(json)
|
|
62
|
+
def self.introspect(executor, cache: nil, ttl: nil)
|
|
63
|
+
if cache && fresh?(cache, ttl)
|
|
64
|
+
return GraphQL::Schema.from_introspection(JSON.parse(File.read(cache)))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
result = executor.execute(GraphQL::Introspection.query, variables: {}).to_h
|
|
68
|
+
if (errors = result["errors"])
|
|
69
|
+
raise GraphWeaver::Error, "introspection failed: #{errors.inspect}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if cache
|
|
73
|
+
FileUtils.mkdir_p(File.dirname(cache))
|
|
74
|
+
File.write(cache, JSON.generate(result))
|
|
18
75
|
end
|
|
76
|
+
|
|
77
|
+
GraphQL::Schema.from_introspection(result)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.fresh?(path, ttl)
|
|
81
|
+
File.exist?(path) && (ttl.nil? || Time.now - File.mtime(path) < ttl)
|
|
19
82
|
end
|
|
83
|
+
private_class_method :fresh?
|
|
20
84
|
end
|