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)


205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/graph_weaver/schema_loader.rb', line 205

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.



256
257
258
259
# File 'lib/graph_weaver/schema_loader.rb', line 256

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.



251
252
253
# File 'lib/graph_weaver/schema_loader.rb', line 251

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.



263
264
265
266
267
268
269
270
# File 'lib/graph_weaver/schema_loader.rb', line 263

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)


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

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.



74
75
76
77
78
79
80
# File 'lib/graph_weaver/schema_loader.rb', line 74

def self.strip_federation(sdl)
  doc = GraphQL.parse(sdl)
  defs = remove_inaccessible(doc.definitions)
    .reject { |defn| defn.respond_to?(:name) && federation_name?(defn.name) }
    .map { |defn| strip_federation_directives(defn) }
  GraphQL::Language::Nodes::Document.new(definitions: defs).to_query_string
end