Module: GraphWeaver::SchemaLoader

Defined in:
lib/graph_weaver/schema_loader.rb

Overview

Load a schema for codegen from either format a remote service can hand you — introspection JSON or SDL, as a file path or the content itself — or fetch one straight from a live endpoint via introspect.

Constant Summary collapse

FEDERATION_PREFIXES =
%w[join__ link__ core__].freeze
FEDERATION_DIRECTIVES =
%w[link core inaccessible].to_set.freeze
CACHE_EXTENSIONS =
%w[.json .graphql .gql].freeze

Class Method Summary collapse

Class Method Details

.federation_sdl?(sdl) ⇒ Boolean

A composed Fed2 supergraph is marked by @join__* directives (every merged type carries them); a plain schema has none.

Returns:

  • (Boolean)


54
55
56
# File 'lib/graph_weaver/schema_loader.rb', line 54

def self.federation_sdl?(sdl)
  sdl.match?(/@join__\w/)
end

.introspect(transport, cache: nil, ttl: nil) ⇒ Object

Run the standard introspection query through a transport and build a schema from the result:

 transport = GraphWeaver::Transport::HTTP.new(url, headers: { ... })
 schema = GraphWeaver::SchemaLoader.introspect(transport)

Introspecting a large API takes seconds, so cache: dumps the schema to a file and reuses it until ttl: seconds elapse (no ttl = until the file is deleted). cache: takes

  • true — GraphWeaver.schema_path, the file the generation workflow reads (its extension picks the format)
  • a path — the extension picks the format: .json is the verbatim introspection result, .graphql/.gql is SDL (human-readable, PR-reviewable diffs); both load back identically
  • :json / :graphql / :gql — GraphWeaver.schema_path's location, in that format Reading is format-agnostic: any fresh sibling dump counts, whatever its format — an existing schema.graphql is reused rather than re-introspecting to write schema.json. GraphQL has no standard schema-version signal to invalidate on — a stale cache surfaces as server-side validation errors (see QueryError#schema_stale?), so pick a ttl that matches how fast the API moves, or delete the file.

To cache anywhere else (Rails.cache, redis, ...), serialize the schema itself — schemas round-trip through their introspection JSON:

 json = Rails.cache.fetch("gh_schema", expires_in: 12.hours) do
   GraphWeaver::SchemaLoader.introspect(transport).to_json
 end
 schema = GraphWeaver::SchemaLoader.load(json)


233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/graph_weaver/schema_loader.rb', line 233

def self.introspect(transport, cache: nil, ttl: nil)
  cache = cache_path(cache)

  if cache
    # reuse whatever fresh dump is present, regardless of format —
    # don't re-introspect to write schema.json when a usable
    # schema.graphql already sits there
    existing = cache_candidates(cache).find { |candidate| fresh?(candidate, ttl) }
    if existing
      GraphWeaver.log(:info) { "schema cache hit: #{existing}#{" (ttl #{ttl}s)" if ttl}" }
      return load(existing)
    end

    GraphWeaver.log(:info) { "schema cache miss: #{cache}" }
  end

  result = GraphWeaver.log_timed(:info, "introspected #{transport.respond_to?(:url) ? transport.url : transport.class}") do
    transport.execute(GraphQL::Introspection.query, variables: {}).to_h
  end
  if (errors = result["errors"])
    raise GraphWeaver::Error, "introspection failed: #{errors.inspect}"
  end

  schema = GraphQL::Schema.from_introspection(result)

  if cache
    FileUtils.mkdir_p(File.dirname(cache))
    # the extension picks the format: .json is the verbatim wire
    # artifact; .graphql/.gql is SDL — human-readable, PR-reviewable
    # diffs (both generate byte-identical code)
    meta = stamp(transport)
    content = if cache.end_with?(".json")
      JSON.generate(meta ? result.merge("graph_weaver" => meta) : result)
    else
      header = meta && "# graph_weaver: #{JSON.generate(meta)}\n\n"
      "#{header}#{schema.to_definition}"
    end
    File.write(cache, content)
    GraphWeaver.log(:info) { "wrote schema cache: #{cache} (#{content.bytesize} bytes)" }
  end

  schema
end

.load(source) ⇒ Object

Accepts, and detects:

- a Hash (a parsed introspection result)
- a file path — .json (introspection) or .graphql/.gql (SDL)
- raw content — introspection JSON (starts with "{") or SDL

so a cache round-trip is symmetrical with introspect: SchemaLoader.load(cached_json) # from Rails.cache/redis/...



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/graph_weaver/schema_loader.rb', line 19

def self.load(source)
  return GraphQL::Schema.from_introspection(source) if source.is_a?(Hash)

  if source.lstrip.start_with?("{") # introspection JSON content
    GraphQL::Schema.from_introspection(JSON.parse(source))
  elsif source.include?("\n") # multi-line: SDL content
    unless source.match?(/^\s*(schema|type|interface|union|enum|scalar|directive|input|")/)
      raise ArgumentError, "unsupported schema content: #{source.lstrip[0, 80].inspect}"
    end

    build_sdl(source)
  else # a file path
    case File.extname(source)
    when ".json"
      GraphQL::Schema.from_introspection(JSON.parse(File.read(source)))
    when ".graphql", ".gql"
      build_sdl(File.read(source))
    else
      raise ArgumentError, "unsupported schema format: #{source}"
    end
  end
end

.locate(path = GraphWeaver.schema_path) ⇒ Object

locate_path, loaded.



284
285
286
287
# File 'lib/graph_weaver/schema_loader.rb', line 284

def self.locate(path = GraphWeaver.schema_path)
  found = locate_path(path)
  found && load(found)
end

.locate_path(path = GraphWeaver.schema_path) ⇒ Object

The conventional schema dump, whatever its format: schema_path or the first sibling extension that exists. nil when none is on disk.



279
280
281
# File 'lib/graph_weaver/schema_loader.rb', line 279

def self.locate_path(path = GraphWeaver.schema_path)
  cache_candidates(path).find { |candidate| File.exist?(candidate) }
end

.provenance(path) ⇒ Object

The provenance recorded in a dump (=> ..., "introspected_at" => ...), whichever format holds it; nil for local/unannotated dumps.



291
292
293
294
295
296
297
298
# File 'lib/graph_weaver/schema_loader.rb', line 291

def self.provenance(path)
  content = File.read(path)
  if path.end_with?(".json")
    JSON.parse(content)["graph_weaver"]
  elsif (meta = content[/\A# graph_weaver: (\{.*\})$/, 1])
    JSON.parse(meta)
  end
end

.stale?(path, transport: nil) ⇒ Boolean

Re-introspect a dump's source and compare — true when the server has drifted from what's on disk. transport: overrides the transport (auth etc); by default one is built from the dump's recorded url. Wired up as rake graph_weaver:schema:verify / :refresh.

Returns:

  • (Boolean)


304
305
306
307
308
309
# File 'lib/graph_weaver/schema_loader.rb', line 304

def self.stale?(path, transport: nil)
  transport ||= source_transport(path)
  fresh = introspect(transport)

  fresh.to_definition != load(path).to_definition
end

.strip_federation(sdl) ⇒ Object

Drop the composition machinery from supergraph SDL: the synthetic join__/link__ type and directive definitions, and every @join__/@link application on the types that remain. What's left is the merged graph's ordinary type shapes — exactly what codegen reads. Parsing is lenient (it's schema building that rejects the join directives), so we parse, filter the AST, and reprint clean SDL for from_definition — no graphql-ruby monkeypatch and no join__ leaking into schema.types.



81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/graph_weaver/schema_loader.rb', line 81

def self.strip_federation(sdl)
  doc = GraphQL.parse(sdl)
  defs = remove_inaccessible(doc.definitions)
    .reject { |defn| federation_definition?(defn) }
    .map { |defn| strip_federation_directives(defn) }

  if defs.none? { |d| d.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition) }
    raise GraphWeaver::Error,
      "supergraph has no object types left after stripping the federation machinery — " \
      "is the whole schema behind @inaccessible?"
  end

  GraphQL::Language::Nodes::Document.new(definitions: defs).to_query_string
end