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.
@@ -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 = Net::HTTP.start(@uri.hostname, @uri.port, use_ssl: @uri.scheme == "https") do |http|
22
- http.request(request)
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 "HTTP #{response.code}: #{response.body}"
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: an introspection dump (.json) or SDL (.graphql/.gql).
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
- def self.load(path)
11
- case File.extname(path)
12
- when ".json"
13
- GraphQL::Schema.from_introspection(JSON.parse(File.read(path)))
14
- when ".graphql", ".gql"
15
- GraphQL::Schema.from_definition(File.read(path))
16
- else
17
- raise ArgumentError, "unsupported schema format: #{path}"
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