graph_weaver 0.1.0 → 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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +213 -0
  4. data/Gemfile.lock +32 -2
  5. data/Makefile +7 -2
  6. data/NOTES.md +1 -1
  7. data/PLAN.md +34 -1
  8. data/README.md +56 -41
  9. data/docs/cassettes.md +76 -0
  10. data/docs/errors.md +37 -9
  11. data/docs/generated_modules.md +139 -29
  12. data/docs/getting_started.md +134 -0
  13. data/docs/logging.md +33 -0
  14. data/docs/real_world.md +27 -20
  15. data/docs/scalars.md +122 -8
  16. data/docs/testing.md +44 -34
  17. data/docs/transports.md +124 -0
  18. data/graph_weaver.gemspec +7 -1
  19. data/lib/graph_weaver/client.rb +200 -0
  20. data/lib/graph_weaver/codegen/emit.rb +88 -39
  21. data/lib/graph_weaver/codegen/enum_type.rb +149 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +109 -9
  23. data/lib/graph_weaver/codegen/scalar_type.rb +53 -35
  24. data/lib/graph_weaver/codegen.rb +301 -67
  25. data/lib/graph_weaver/errors.rb +37 -25
  26. data/lib/graph_weaver/hints.rb +63 -0
  27. data/lib/graph_weaver/inflect.rb +2 -1
  28. data/lib/graph_weaver/logging.rb +41 -0
  29. data/lib/graph_weaver/railtie.rb +29 -0
  30. data/lib/graph_weaver/retry.rb +97 -0
  31. data/lib/graph_weaver/rspec.rb +19 -14
  32. data/lib/graph_weaver/schema_loader.rb +156 -20
  33. data/lib/graph_weaver/selection.rb +3 -3
  34. data/lib/graph_weaver/tasks.rb +71 -0
  35. data/lib/graph_weaver/testing/cassette.rb +42 -21
  36. data/lib/graph_weaver/testing/failure.rb +22 -22
  37. data/lib/graph_weaver/testing/{fake_executor.rb → fake_client.rb} +13 -13
  38. data/lib/graph_weaver/testing/values.rb +2 -2
  39. data/lib/graph_weaver/testing.rb +32 -16
  40. data/lib/graph_weaver/transport/faraday.rb +56 -0
  41. data/lib/graph_weaver/transport/http.rb +87 -0
  42. data/lib/graph_weaver/transport.rb +120 -0
  43. data/lib/graph_weaver/version.rb +1 -1
  44. data/lib/graph_weaver.rb +208 -38
  45. metadata +73 -5
  46. data/lib/graph_weaver/faraday_executor.rb +0 -61
  47. data/lib/graph_weaver/http_executor.rb +0 -44
  48. data/lib/graph_weaver/testing/rspec.rb +0 -5
@@ -1,16 +1,27 @@
1
1
  # typed: true
2
2
  # frozen_string_literal: true
3
3
 
4
- require "set"
5
4
  require "sorbet-runtime"
6
5
  require_relative "inflect"
6
+ require_relative "logging"
7
7
 
8
8
  module GraphWeaver
9
9
  # Base for every error GraphWeaver raises — rescue this to catch them
10
10
  # all. #message is the human-friendly side; #to_h is the machine side —
11
11
  # a JSON-ready Hash (string keys) for logging, agents, or surfacing
12
- # structured failures to users. Subclasses merge in their specifics.
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.
13
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
+
14
25
  def to_h
15
26
  { "error" => self.class.name, "message" => message }
16
27
  end
@@ -26,14 +37,14 @@ module GraphWeaver
26
37
  end
27
38
 
28
39
  class << self
29
- # The exception classes the bundled executors reclassify as
40
+ # The exception classes the bundled transports reclassify as
30
41
  # TransportError — network-level failures where the request never
31
42
  # reached the server. A mutable Set: each transport contributes its own
32
43
  # on load (net/http adds Timeout/SSL, Faraday adds its ConnectionFailed,
33
44
  # …), and you can add more so they get the same handling:
34
45
  #
35
- # GraphWeaver.transport_errors << MyPool::TimeoutError
36
- # GraphWeaver.register_transport_error(Adapter::ResetError)
46
+ # GraphWeaver.transport_errors << MyPool::TimeoutError
47
+ # GraphWeaver.register_transport_error(Adapter::ResetError)
37
48
  #
38
49
  # SystemCallError covers every Errno::* (connection refused/reset, host
39
50
  # unreachable); SocketError covers DNS.
@@ -72,11 +83,12 @@ module GraphWeaver
72
83
  class GraphQLError
73
84
  attr_reader :message, :locations, :path, :extensions
74
85
 
75
- def initialize(message:, locations: [], path: nil, extensions: {})
86
+ def initialize(message:, locations: [], path: nil, extensions: {}, type: nil)
76
87
  @message = message
77
88
  @locations = locations
78
89
  @path = path
79
90
  @extensions = extensions
91
+ @error_type = type
80
92
  end
81
93
 
82
94
  def self.from_h(hash)
@@ -85,13 +97,16 @@ module GraphWeaver
85
97
  locations: hash["locations"] || [],
86
98
  path: hash["path"],
87
99
  extensions: hash["extensions"] || {},
100
+ type: hash["type"],
88
101
  )
89
102
  end
90
103
 
91
104
  # The machine-readable error code, e.g. "THROTTLED" — the thing to
92
- # branch on. nil when the server didn't set extensions.code.
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.
93
108
  def code
94
- extensions["code"]
109
+ extensions["code"] || @error_type
95
110
  end
96
111
 
97
112
  # Message shapes servers use when they reject the *shape* of a query
@@ -166,8 +181,8 @@ module GraphWeaver
166
181
 
167
182
  # True when any error looks like the server rejected the query's
168
183
  # 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).
184
+ # generated. Refresh the schema dump and regenerate (rake
185
+ # graph_weaver:schema:refresh && rake graph_weaver:generate).
171
186
  def schema_stale?
172
187
  errors.any?(&:validation?)
173
188
  end
@@ -175,9 +190,9 @@ module GraphWeaver
175
190
  # Errors grouped by the field they point at (index-stripped dotted
176
191
  # path; nil key for global errors) — iterate with each_error:
177
192
  #
178
- # response.each_error do |field, errors|
179
- # form.add_error(field, errors.map(&:message))
180
- # end
193
+ # response.each_error do |field, errors|
194
+ # form.add_error(field, errors.map(&:message))
195
+ # end
181
196
  def errors_by_field
182
197
  errors.group_by(&:field)
183
198
  end
@@ -213,8 +228,8 @@ module GraphWeaver
213
228
  # actual records that failed inlined — "the 3 in people.3.email is
214
229
  # useless to a user; people.email plus which people is the answer".
215
230
  #
216
- # { "people.email" => { "messages" => [...], "codes" => [...],
217
- # "entity_ids" => ["7", "9"], "errors" => [full to_h...] } }
231
+ # { "people.email" => { "messages" => [...], "codes" => [...],
232
+ # "entity_ids" => ["7", "9"], "errors" => [full to_h...] } }
218
233
  def report
219
234
  errors_by_field.to_h do |field, field_errors|
220
235
  [field, {
@@ -264,7 +279,7 @@ module GraphWeaver
264
279
  def summary
265
280
  first = errors.first
266
281
  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" : ""
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)" : ""
268
283
  "GraphQL query failed: #{first}#{more}#{drift}"
269
284
  end
270
285
  end
@@ -288,10 +303,11 @@ module GraphWeaver
288
303
  end
289
304
  end
290
305
 
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
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
295
311
  attr_reader :errors
296
312
 
297
313
  def initialize(errors)
@@ -300,11 +316,7 @@ module GraphWeaver
300
316
  end
301
317
 
302
318
  def to_h
303
- {
304
- "error" => self.class.name,
305
- "message" => message,
306
- "errors" => errors.map { |e| e.transform_keys(&:to_s) },
307
- }
319
+ super.merge("errors" => errors.map { |e| e.transform_keys(&:to_s) })
308
320
  end
309
321
  end
310
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
@@ -12,7 +12,8 @@ module GraphWeaver
12
12
  end
13
13
 
14
14
  def camelize(name)
15
- name.split("_").map { |part| "#{part[0].upcase}#{part[1..]}" }.join
15
+ # reject: leading underscores split into empty parts (_Service, _and)
16
+ name.split("_").reject(&:empty?).map { |part| "#{part[0].upcase}#{part[1..]}" }.join
16
17
  end
17
18
  end
18
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,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
@@ -6,23 +6,28 @@ require_relative "testing"
6
6
  # RSpec integration — require from your spec helper instead of
7
7
  # "graph_weaver/testing":
8
8
  #
9
- # require "graph_weaver/rspec"
9
+ # require "graph_weaver/rspec"
10
10
  #
11
- # GraphWeaver::Testing.configure do |config|
12
- # config.schema = MySchema
13
- # config.auto_fake = true # every example runs against a FakeExecutor
14
- # end
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
15
20
  #
16
21
  # What it wires up:
17
22
  # - seed: defaults to rspec's --seed, so `rspec --seed 1234` reproduces
18
23
  # 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.
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.
23
28
  #
24
- # note: modules generated with a baked-in executor: constant don't consult
25
- # GraphWeaver.executor — generate without executor: to make them fakeable.
29
+ # note: modules generated with a baked-in client: constant don't consult
30
+ # GraphWeaver.client — generate without client: to make them fakeable.
26
31
  module GraphWeaver
27
32
  module Testing
28
33
  module RSpecIntegration
@@ -36,15 +41,15 @@ module GraphWeaver
36
41
  config = GraphWeaver::Testing.config
37
42
  next unless config.auto_fake && config.schema
38
43
 
39
- @__graph_weaver_prior_executor = GraphWeaver.instance_variable_get(:@executor)
40
- GraphWeaver.executor = FakeExecutor.new(schema: config.schema)
44
+ @__graph_weaver_prior_client = GraphWeaver.client
45
+ GraphWeaver.client = FakeClient.new(schema: config.schema)
41
46
  end
42
47
 
43
48
  rspec_config.after(:each) do
44
49
  config = GraphWeaver::Testing.config
45
50
  next unless config.auto_fake && config.schema
46
51
 
47
- GraphWeaver.executor = @__graph_weaver_prior_executor
52
+ GraphWeaver.client = @__graph_weaver_prior_client
48
53
  end
49
54
  end
50
55
  end
@@ -15,7 +15,7 @@ module GraphWeaver::SchemaLoader
15
15
  # - a file path — .json (introspection) or .graphql/.gql (SDL)
16
16
  # - raw content — introspection JSON (starts with "{") or SDL
17
17
  # so a cache round-trip is symmetrical with introspect:
18
- # SchemaLoader.load(cached_json) # from Rails.cache/redis/...
18
+ # SchemaLoader.load(cached_json) # from Rails.cache/redis/...
19
19
  def self.load(source)
20
20
  return GraphQL::Schema.from_introspection(source) if source.is_a?(Hash)
21
21
 
@@ -39,43 +39,179 @@ module GraphWeaver::SchemaLoader
39
39
  end
40
40
  end
41
41
 
42
- # Run the standard introspection query through an executor and build a
42
+ # Run the standard introspection query through a transport and build a
43
43
  # schema from the result:
44
44
  #
45
- # executor = GraphWeaver::HttpExecutor.new(url, headers: { ... })
46
- # schema = GraphWeaver::SchemaLoader.introspect(executor)
45
+ # transport = GraphWeaver::Transport::HTTP.new(url, headers: { ... })
46
+ # schema = GraphWeaver::SchemaLoader.introspect(transport)
47
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.
48
+ # Introspecting a large API takes seconds, so cache: dumps the schema
49
+ # to a file and reuses it until ttl: seconds elapse (no ttl = until the
50
+ # file is deleted). cache: takes
51
+ # - true GraphWeaver.schema_path, the file the generation workflow
52
+ # reads (its extension picks the format)
53
+ # - a path the extension picks the format: .json is the verbatim
54
+ # introspection result, .graphql/.gql is SDL (human-readable,
55
+ # PR-reviewable diffs); both load back identically
56
+ # - :json / :graphql / :gql — GraphWeaver.schema_path's location, in
57
+ # that format
58
+ # Reading is format-agnostic: any fresh sibling dump counts, whatever
59
+ # its format — an existing schema.graphql is reused rather than
60
+ # re-introspecting to write schema.json.
61
+ # GraphQL has no standard schema-version signal to invalidate on — a
62
+ # stale cache surfaces as server-side validation errors (see
63
+ # QueryError#schema_stale?), so pick a ttl that matches how fast the
64
+ # API moves, or delete the file.
54
65
  #
55
66
  # To cache anywhere else (Rails.cache, redis, ...), serialize the schema
56
67
  # itself — schemas round-trip through their introspection JSON:
57
68
  #
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)))
69
+ # json = Rails.cache.fetch("gh_schema", expires_in: 12.hours) do
70
+ # GraphWeaver::SchemaLoader.introspect(transport).to_json
71
+ # end
72
+ # schema = GraphWeaver::SchemaLoader.load(json)
73
+ def self.introspect(transport, cache: nil, ttl: nil)
74
+ cache = cache_path(cache)
75
+
76
+ if cache
77
+ # reuse whatever fresh dump is present, regardless of format —
78
+ # don't re-introspect to write schema.json when a usable
79
+ # schema.graphql already sits there
80
+ existing = cache_candidates(cache).find { |candidate| fresh?(candidate, ttl) }
81
+ if existing
82
+ GraphWeaver.log(:info) { "schema cache hit: #{existing}#{" (ttl #{ttl}s)" if ttl}" }
83
+ return load(existing)
84
+ end
85
+
86
+ GraphWeaver.log(:info) { "schema cache miss: #{cache}" }
65
87
  end
66
88
 
67
- result = executor.execute(GraphQL::Introspection.query, variables: {}).to_h
89
+ result = GraphWeaver.log_timed(:info, "introspected #{transport.respond_to?(:url) ? transport.url : transport.class}") do
90
+ transport.execute(GraphQL::Introspection.query, variables: {}).to_h
91
+ end
68
92
  if (errors = result["errors"])
69
93
  raise GraphWeaver::Error, "introspection failed: #{errors.inspect}"
70
94
  end
71
95
 
96
+ schema = GraphQL::Schema.from_introspection(result)
97
+
72
98
  if cache
73
99
  FileUtils.mkdir_p(File.dirname(cache))
74
- File.write(cache, JSON.generate(result))
100
+ # the extension picks the format: .json is the verbatim wire
101
+ # artifact; .graphql/.gql is SDL — human-readable, PR-reviewable
102
+ # diffs (both generate byte-identical code)
103
+ meta = stamp(transport)
104
+ content = if cache.end_with?(".json")
105
+ JSON.generate(meta ? result.merge("graph_weaver" => meta) : result)
106
+ else
107
+ header = meta && "# graph_weaver: #{JSON.generate(meta)}\n\n"
108
+ "#{header}#{schema.to_definition}"
109
+ end
110
+ File.write(cache, content)
111
+ GraphWeaver.log(:info) { "wrote schema cache: #{cache} (#{content.bytesize} bytes)" }
75
112
  end
76
113
 
77
- GraphQL::Schema.from_introspection(result)
114
+ schema
115
+ end
116
+
117
+ # The conventional schema dump, whatever its format: schema_path or the
118
+ # first sibling extension that exists. nil when none is on disk.
119
+ def self.locate_path(path = GraphWeaver.schema_path)
120
+ cache_candidates(path).find { |candidate| File.exist?(candidate) }
121
+ end
122
+
123
+ # locate_path, loaded.
124
+ def self.locate(path = GraphWeaver.schema_path)
125
+ found = locate_path(path)
126
+ found && load(found)
127
+ end
128
+
129
+ # The provenance recorded in a dump ({"url" => ..., "introspected_at"
130
+ # => ...}), whichever format holds it; nil for local/unannotated dumps.
131
+ def self.provenance(path)
132
+ content = File.read(path)
133
+ if path.end_with?(".json")
134
+ JSON.parse(content)["graph_weaver"]
135
+ elsif (meta = content[/\A# graph_weaver: (\{.*\})$/, 1])
136
+ JSON.parse(meta)
137
+ end
138
+ end
139
+
140
+ # Re-introspect a dump's source and compare — true when the server has
141
+ # drifted from what's on disk. transport: overrides the transport (auth
142
+ # etc); by default one is built from the dump's recorded url. Wired up
143
+ # as `rake graph_weaver:schema:verify` / `:refresh`.
144
+ def self.stale?(path, transport: nil)
145
+ transport ||= source_transport(path)
146
+ fresh = introspect(transport)
147
+
148
+ fresh.to_definition != load(path).to_definition
149
+ end
150
+
151
+ # a transport to the dump's recorded url (GRAPHWEAVER_AUTH supplies a
152
+ # token when set)
153
+ def self.source_transport(path)
154
+ meta = provenance(path)
155
+ unless meta&.key?("url")
156
+ raise GraphWeaver::Error, "#{path} records no source url — pass transport:"
157
+ end
158
+
159
+ GraphWeaver.new(meta["url"], auth: ENV["GRAPHWEAVER_AUTH"]).transport
160
+ end
161
+ private_class_method :source_transport
162
+
163
+ # Where a dump came from, recorded into the file so it can be
164
+ # re-verified later — a parsable header comment in SDL, a
165
+ # "graph_weaver" sibling key in introspection JSON (from_introspection
166
+ # reads only "data"). nil when the transport has no url (schema
167
+ # classes, fakes).
168
+ def self.stamp(transport)
169
+ return unless transport.respond_to?(:url) && transport.url
170
+
171
+ require "time"
172
+ { "url" => transport.url, "introspected_at" => Time.now.utc.iso8601 }
173
+ end
174
+ private_class_method :stamp
175
+
176
+ CACHE_EXTENSIONS = %w[.json .graphql .gql].freeze
177
+
178
+ # cache: true / :json / :graphql / :gql / a path => the file to write
179
+ # (nil for no caching). Symbols and true anchor at GraphWeaver.schema_path —
180
+ # the schema dump the generation workflow reads, so one file serves both
181
+ # (introspect caches it, rake generate loads it).
182
+ def self.cache_path(cache)
183
+ case cache
184
+ when nil, false
185
+ nil
186
+ when true
187
+ GraphWeaver.schema_path
188
+ when Symbol
189
+ unless CACHE_EXTENSIONS.include?(".#{cache}")
190
+ raise ArgumentError, "cache: format must be :json, :graphql, or :gql, got #{cache.inspect}"
191
+ end
192
+
193
+ "#{strip_extension(GraphWeaver.schema_path)}.#{cache}"
194
+ else
195
+ unless cache.end_with?(*CACHE_EXTENSIONS)
196
+ raise ArgumentError, "cache: must be a .json or .graphql/.gql path, got #{cache}"
197
+ end
198
+
199
+ cache
200
+ end
201
+ end
202
+ private_class_method :cache_path
203
+
204
+ # the requested path first, then its siblings in the other formats
205
+ def self.cache_candidates(path)
206
+ base = strip_extension(path)
207
+ [path, *CACHE_EXTENSIONS.map { |ext| base + ext }].uniq
208
+ end
209
+ private_class_method :cache_candidates
210
+
211
+ def self.strip_extension(path)
212
+ path.delete_suffix(File.extname(path))
78
213
  end
214
+ private_class_method :strip_extension
79
215
 
80
216
  def self.fresh?(path, ttl)
81
217
  File.exist?(path) && (ttl.nil? || Time.now - File.mtime(path) < ttl)