Module: GraphWeaver

Extended by:
T::Sig
Defined in:
lib/graph_weaver.rb,
lib/graph_weaver/hints.rb,
lib/graph_weaver/rspec.rb,
lib/graph_weaver/errors.rb,
lib/graph_weaver/inflect.rb,
lib/graph_weaver/logging.rb,
lib/graph_weaver/testing.rb,
lib/graph_weaver/version.rb,
lib/graph_weaver/response.rb,
lib/graph_weaver/selection.rb,
lib/graph_weaver/input_struct.rb,
lib/graph_weaver/transport/http.rb,
lib/graph_weaver/testing/failure.rb,
lib/graph_weaver/testing/cassette.rb,
lib/graph_weaver/codegen/enum_type.rb,
lib/graph_weaver/transport/faraday.rb

Overview

Opt-in test tooling: require "graph_weaver/testing" from your spec helper (never from production code). Configure once, initializer-style:

 GraphWeaver::Testing.configure do |config|
   config.schema = MySchema                  # for auto_fake / cassettes
   config.seed = 42                          # reproducible fakes
   config.mode = :faker                      # or :literal; nil = auto
   config.overrides = { "Person.name" => "Daniel" }
   config.list_size = 2..4
   config.null_chance = 0.1                  # nullable fields go nil sometimes
   config.cassette_dir = "spec/cassettes"
 end

mode picks how values are fabricated: :faker — semantic, field-name matched (requires the faker gem) :literal — plain type-derived values ("name-1", seeded numbers) nil — auto: :faker when the gem is loaded, else :literal

rspec users: require "graph_weaver/rspec" instead — it hooks the suite (seed from rspec, optional auto-faked client per example).

Defined Under Namespace

Modules: ErrorFiltering, Hints, Inflect, InputStruct, SchemaLoader, Selection, Testing, TypeHelpers Classes: Client, Codegen, Error, GraphQLError, InputError, QueryError, Railtie, Response, Retry, ServerError, Transport, TransportError, TypeError, ValidationError

Constant Summary collapse

VERSION =
"0.4.6"

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.auto_coerceObject

Default input coercion for scalars that don't say coerce: themselves, resolved lazily at generation time (so set it any time before you generate — no reset_scalars! ordering dance):

 GraphWeaver.auto_coerce = true

Convertible built-ins take their conversion (Int accepts 5/"5"), and any scalar with a full cast/serialize pair (Date, your Money) accepts its raw wire form. An explicit coerce: true/false/Symbol on a registration always wins.



275
276
277
# File 'lib/graph_weaver.rb', line 275

def auto_coerce
  @auto_coerce
end

.clientObject

The app's default client — how generated modules find their server:

 GraphWeaver.client = GraphWeaver.new(url, auth: token)

Accepts a Client or anything satisfying the execute contract (a schema class, a fake — testing's auto_fake swaps one in per example). Generated modules resolve per call -> per module (MyQuery.client=) -> baked constant -> here.



42
43
44
# File 'lib/graph_weaver.rb', line 42

def client
  @client
end

.extend_t_sig=(value) ⇒ Object (writeonly)

Whether generated modules/structs emit extend T::Sig (so sig resolves standalone). Default (nil) auto-detects: an app that globally injects T::Sig (class Module; include T::Sig) makes the per-struct extend redundant — rubocop's Sorbet/RedundantExtendTSig flags it — so generation skips it. Force with true/false. Resolved at generation time.

 GraphWeaver.extend_t_sig = false   # never emit (rely on a global include)


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

def extend_t_sig=(value)
  @extend_t_sig = value
end

.fragments_pathsObject

Reusable named fragments, defined once and available to every query — each query inlines only the ones it (transitively) spreads, so the sent query stays self-contained.



78
# File 'lib/graph_weaver.rb', line 78

def fragments_paths = @fragments_paths ||= ["app/graphql/fragments"]

.generated_pathsObject



73
# File 'lib/graph_weaver.rb', line 73

def generated_paths = @generated_paths ||= ["app/graphql/generated", "app/graphql/*/generated"]

.inputs_module(output = generated_path) ⇒ Object



102
103
104
# File 'lib/graph_weaver.rb', line 102

def inputs_module(output = generated_path)
  @inputs_module || derive_module("Inputs", output)
end

.loggerObject

Where GraphWeaver narrates what it's doing — anything stdlib-Logger-compatible (Logger, Rails.logger, semantic_logger...). Silent by default; Rails apps get Rails.logger wired by the railtie.

 GraphWeaver.logger = Logger.new($stdout, level: Logger::INFO)

What logs where:

debug — full queries + variables on the wire, responses
      (status/bytes/ms), connection lifecycle, parsed modules
info  — schema introspection and cache decisions, generated files
      written, query modules loaded
warn  — every GraphWeaver error raised

Queries, variables, and responses appear at debug ONLY — they can carry PII. Auth headers never log.



21
22
23
# File 'lib/graph_weaver/logging.rb', line 21

def logger
  @logger
end

.queries_pathsObject

Entries may be glob patterns — the generated default also matches per-schema layouts (app/graphql/github/generated). Queries stay single-schema: load_queries! parses everything against one client.



72
# File 'lib/graph_weaver.rb', line 72

def queries_paths = @queries_paths ||= ["app/graphql/queries"]

.schema_pathObject



92
# File 'lib/graph_weaver.rb', line 92

def schema_path = @schema_path || "app/graphql/schema.json"

.unions_module(output = generated_path) ⇒ Object



106
107
108
# File 'lib/graph_weaver.rb', line 106

def unions_module(output = generated_path)
  @unions_module || derive_module("Unions", output)
end

Class Method Details

.clear_scalars!Object

Empty the scalar registry entirely, built-ins included (see reset_scalars! to restore the defaults).



386
387
388
# File 'lib/graph_weaver.rb', line 386

def clear_scalars!
  Codegen.clear_scalars!
end

.client!Object

the default client, when one is required



45
46
47
# File 'lib/graph_weaver.rb', line 45

def client!
  @client or raise Error, "no client configured — set GraphWeaver.client= or pass a client"
end

.derive_module(suffix, output) ⇒ Object

Name a shared module from the output path: in a multi-schema layout, else GraphQL.



112
113
114
115
116
117
118
119
120
121
# File 'lib/graph_weaver.rb', line 112

def derive_module(suffix, output)
  segments = File.expand_path(output.to_s).split(File::SEPARATOR)
  segments.pop if segments.last == "generated"
  parent = segments.last.to_s
  if parent.match?(/\A[a-zA-Z]\w*\z/) && !%w[graphql app lib spec support test].include?(parent)
    "#{Inflect.camelize(parent)}#{suffix}"
  else
    "GraphQL#{suffix}"
  end
end

.did_you_mean(dictionary, term) ⇒ Object

The closest entry in dictionary to term — a "did you mean" suggestion, or nil (also nil when did_you_mean isn't loadable). One home for the guard used by codegen validation, alias resolution, and the runtime prop hints.



299
300
301
302
303
# File 'lib/graph_weaver.rb', line 299

def did_you_mean(dictionary, term)
  return unless defined?(DidYouMean::SpellChecker)

  DidYouMean::SpellChecker.new(dictionary: dictionary).correct(term).first
end

.execute(source, query, **variables) ⇒ Object

One-shot dynamic execution — a throwaway client, no build step:

 GraphWeaver.execute(schema, "query($id: ID!) { ... }", id: "1")   # => Response
 GraphWeaver.execute!(url, "query { viewer { login } }")           # => Result (or raise)

The first argument is a url or schema source, exactly as GraphWeaver.new; this is Client#execute on a client you don't keep. (A url source introspects the schema on every call — keep a client for more than one query.) Variables are plain kwargs, as on a generated module (nothing reserved). execute returns the Response envelope, execute! the typed result, raising QueryError on top-level errors.



422
423
424
425
# File 'lib/graph_weaver.rb', line 422

def execute(source, query, **variables)
  client = source.is_a?(Client) ? source : Client.new(source)
  client.execute(query, **variables)
end

.execute!(source, query, **variables) ⇒ Object

execute + data! — the typed result, or a raised QueryError. See execute.



428
429
430
# File 'lib/graph_weaver.rb', line 428

def execute!(source, query, **variables)
  execute(source, query, **variables).data!
end

.extend_t_sig?Boolean

The resolved boolean codegen uses: the explicit setting, else emit unless T::Sig is globally injected into Module.

Returns:

  • (Boolean)


288
289
290
# File 'lib/graph_weaver.rb', line 288

def extend_t_sig?
  @extend_t_sig.nil? ? !global_tsig? : @extend_t_sig
end

.extend_type(graphql_name, *mixins, requires: nil, **kw, &block) ⇒ Object

Include app-owned helper modules into every struct generated from a GraphQL type — derived values live as methods next to the honest wire data, on the struct at runtime (fakes/cassettes included):

 GraphWeaver.extend_type("Pet", PetHelpers)

A helper that reads a wire field is checked by srb tc in the module's own scope, not the struct's, so the field won't resolve — write it

typed: false or reach it via T.unsafe(self). Or build the mixin inline

with a block (module_eval'd into an auto-named module — invisible to srb):

 GraphWeaver.extend_type("Pet") do
   def display_name = "#{name} the pet"
 end

Additive (repeated and client-scoped registrations stack). Global; client.extend_type scopes to one client.



373
374
375
# File 'lib/graph_weaver.rb', line 373

def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
  Codegen.extend_type(graphql_name, *mixins, requires:, **kw, &block)
end

.fragments_pathObject



82
# File 'lib/graph_weaver.rb', line 82

def fragments_path = fragments_paths.first

.generate!(schema: nil, queries: queries_path, output: generated_path, client: nil, inputs_module: nil, unions_module: nil) ⇒ Object

Generate every .graphql query in a directory into checked-in Ruby files. Paths default to the conventions above; schema: defaults to the dump at schema_path (any supported extension):

 GraphWeaver.generate!   # queries_path -> generated_path

person.graphql => person_query.rb defining PersonQuery. Returns the written paths. Pair with a freshness spec (docs/generated_modules.md).



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/graph_weaver.rb', line 132

def generate!(schema: nil, queries: queries_path, output: generated_path, client: nil,
  inputs_module: nil, unions_module: nil)
  schema ||= locate_schema!
  inputs_module ||= self.inputs_module(output)
  unions_module ||= self.unions_module(output)

  plan = generation_plan(queries:, schema:, client:, inputs_module:, unions_module:)
  written = plan.map do |filename, source|
    target = File.join(output, filename)
    FileUtils.mkdir_p(File.dirname(target))
    File.write(target, source)
    log(:info) { "generated #{target}" }
    target
  end

  # a type dropped from the schema (or a union no longer hoisted) must not
  # linger as a stale file — inputs/ and unions.rb are wholly generated
  (shared_artifacts(output) - written).each do |orphan|
    File.delete(orphan)
    log(:info) { "pruned #{orphan}" }
  end

  written
end

.generated_pathObject



81
# File 'lib/graph_weaver.rb', line 81

def generated_path = generated_paths.first

.generated_path=(path) ⇒ Object



88
89
90
# File 'lib/graph_weaver.rb', line 88

def generated_path=(path)
  @generated_paths = path.nil? ? nil : [path]
end

.generation_plan(queries:, schema:, client:, inputs_module: self.inputs_module, unions_module: self.unions_module, fragments: fragments_paths) ⇒ Object

(filename, source) per artifact. Every variable type is emitted once into inputs.rb, and each named shared fragment spread as a whole-union field once into unions.rb, with query modules aliasing what they use — the difference between hundreds of duplicated bool_exp structs (or the same union re-typed per query) and one copy per schema. (Single-query parse inlines both — there's no cross-query set to share against.)



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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/graph_weaver.rb', line 222

def generation_plan(queries:, schema:, client:, inputs_module: self.inputs_module,
  unions_module: self.unions_module, fragments: fragments_paths)
  used = { inputs: [], enums: [], mapped: [] }
  used_unions = []
  shared = Codegen.load_fragments(fragments)

  plan = Dir[File.join(queries, "*.graphql")].sort.map do |path|
    base = File.basename(path, ".graphql")
    source = File.read(path)
    codegen = Codegen.new(
      schema:,
      query: Codegen.inline_fragments(source, shared),
      module_name: "#{Inflect.camelize(base)}Query",
      client:,
      inputs_namespace: inputs_module,
      unions_namespace: unions_module,
      hoistable_unions: Codegen.shared_fragment_spreads(source, shared),
    )
    out = codegen.generate
    codegen.variable_type_names.each { |kind, names| used[kind] |= names }
    used_unions |= codegen.used_union_names
    ["#{base}_query.rb", out]
  end

  if inputs_module && used.values.any?(&:any?)
    inputs = Codegen.generate_inputs(
      schema:, module_name: inputs_module,
      input_types: used[:inputs], enum_types: used[:enums] + used[:mapped],
    )
    plan = inputs.to_a + plan
  end

  if unions_module && used_unions.any?
    unions = Codegen.generate_unions(
      schema:, module_name: unions_module, fragments: shared, names: used_unions,
    )
    plan = unions.to_a + plan
  end

  plan
end

.global_tsig?Boolean

Whether the host app has globally injected T::Sig into every module (class Module; include T::Sig) — extracted so it's stubbable in tests.

Returns:

  • (Boolean)


294
# File 'lib/graph_weaver.rb', line 294

def global_tsig? = Module.include?(T::Sig)

.load_generated!(path = nil) ⇒ Object

Load the generated modules — one line in an initializer or spec helper (loading happens only when you call this; skip it and require files yourself if you'd rather):

 GraphWeaver.load_generated!

In Rails, prefer this over autoloading: Zeitwerk would expect Generated::PersonQuery from generated/person_query.rb, and generated code only changes on regeneration anyway (restart, like a schema migration).



201
202
203
204
205
206
207
# File 'lib/graph_weaver.rb', line 201

def load_generated!(path = nil)
  paths = path ? [path] : generated_paths
  files = paths.flat_map { |dir| Dir[File.join(dir, "**/*.rb")].sort }.uniq
  files.each { |file| require File.expand_path(file) }
  log(:info) { "loaded #{files.size} generated module(s) from #{paths.join(", ")}" }
  files
end

.locate_schema!Object

the conventional schema dump, required



210
211
212
213
# File 'lib/graph_weaver.rb', line 210

def locate_schema!
  SchemaLoader.locate or raise Error,
    "no schema dump at #{schema_path} (.json/.graphql/.gql) — pass schema:, or cache one: GraphWeaver.new(url, cache: true).schema"
end

.log(level, &block) ⇒ Object

Internal: level-gated and lazy — the block only runs when a logger is listening. Messages carry "graph_weaver" as progname.



25
26
27
# File 'lib/graph_weaver/logging.rb', line 25

def log(level, &block)
  logger&.public_send(level, "graph_weaver", &block)
end

.log_timed(level, label) ⇒ Object

Internal: run the block, logging "



31
32
33
34
35
36
37
38
39
# File 'lib/graph_weaver/logging.rb', line 31

def log_timed(level, label)
  return yield unless logger

  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  result = yield
  ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
  log(level) { "#{label} (#{ms}ms)" }
  result
end

.new(source, **options, &middleware) ⇒ Object

A client for one GraphQL server — transport, schema, and scoped scalars in one object (see Client):

 github = GraphWeaver.new("https://api.github.com/graphql", auth: token, cache: true)
 RepoQuery = github.parse("queries/repo.graphql")

The first argument is a url or any schema source (a live schema class, or a path/SDL/introspection dump).



30
31
32
# File 'lib/graph_weaver.rb', line 30

def new(source, **options, &middleware)
  Client.new(source, **options, &middleware)
end

.parse(schema:, query:, name: nil, client: nil, scalars: nil, enums: nil, types: nil, fragments: fragments_paths) ⇒ Object

Parse a query into a typed query module:

 PersonQuery = GraphWeaver.parse(schema:, query: "queries/person.graphql")

query is a .graphql/.gql path (module name derived from the file name) or a raw query string (name derived from the operation name, falling back to "Query" for anonymous operations — collisions are impossible since each parse gets its own container). Pass name: to override, client: to bake the module's default client/transport.



399
400
401
402
403
404
405
406
407
408
# File 'lib/graph_weaver.rb', line 399

def parse(schema:, query:, name: nil, client: nil, scalars: nil, enums: nil, types: nil,
  fragments: fragments_paths)
  if query.end_with?(".graphql", ".gql")
    name ||= "#{Inflect.camelize(File.basename(query, ".*"))}Query"
    query = File.read(query)
  end
  query = Codegen.inline_fragments(query, Codegen.load_fragments(fragments))

  Codegen.parse(schema:, query:, module_name: name, client:, scalars:, enums:, types:)
end

.queries_pathObject



80
# File 'lib/graph_weaver.rb', line 80

def queries_path = queries_paths.first

.queries_path=(path) ⇒ Object



84
85
86
# File 'lib/graph_weaver.rb', line 84

def queries_path=(path)
  @queries_paths = path.nil? ? nil : [path]
end

.register_enum(graphql_name, type, map: nil, fallback: nil, requires: nil) ⇒ Object

Map a GraphQL enum onto an app-owned T::Enum, so generated code speaks YOUR enum — casting wire values in, serializing members out:

 GraphWeaver.register_enum("Species", PetKind)

The mapping is inferred by name ("CAT" <-> PetKind::Cat); map: pins renames, fallback: absorbs unknown wire values on cast (inputs stay strict), requires: names files the generated code should require. Generation fails naming any schema value that doesn't resolve — exhaustiveness checked ahead of runtime. Global; client.register_enum scopes to one client.



347
348
349
# File 'lib/graph_weaver.rb', line 347

def register_enum(graphql_name, type, map: nil, fallback: nil, requires: nil)
  Codegen.register_enum(graphql_name, type, map:, fallback:, requires:)
end

.register_enums(mappings) ⇒ Object

Bulk, inference-only form: register_enums("Species" => PetKind, ...)



352
353
354
# File 'lib/graph_weaver.rb', line 352

def register_enums(mappings)
  Codegen.register_enums(mappings)
end

.register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil) ⇒ Object

Teach the generator how a GraphQL custom scalar deserializes into a rich Ruby object (and serializes back onto the wire when used as a variable):

 GraphWeaver.register_scalar("Money", Money, requires: "bigdecimal")

A field typed Money then generates const :price, T.nilable(Money) and casts with Money.parse(...) in from_h. Pass a real class as type: and cast:/serialize: are inferred from it — .parse/#to_s, or .load/.dump — by probing the deserialize side (see ScalarType::CODECS). Override with a Symbol method name (safest — no string to misspell), a Proc(expr) => code string, or :itself to force pass-through. requires: (a String or Array) names files the generated code needs — validated, and actually required to confirm it resolves when type: is a real class. coerce: true makes a variable of this scalar accept the value OR its raw input (e.g. "12.00"), running the latter through the cast before serializing — it raises on bad input, so some safety survives. Built-in scalars are pre-registered the same way, so this also overrides them.

Pass a Type.field coordinate instead of a scalar name to override just that one field — so the same scalar can deserialize as different Ruby types across fields (a Date for User.birthday, a Time elsewhere):

 GraphWeaver.register_scalar("User.birthday", Date)

A field-level override wins over the scalar-name registration. Same signature either way. Call before generating.



332
333
334
# File 'lib/graph_weaver.rb', line 332

def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
  Codegen.register_scalar(graphql_name, type, cast:, serialize:, requires:, coerce:)
end

.register_transport_error(*classes) ⇒ Object



70
71
72
73
# File 'lib/graph_weaver/errors.rb', line 70

def register_transport_error(*classes)
  transport_errors.merge(classes)
  classes
end

.reset_scalars!Object

Restore the built-in scalars, dropping every custom registration — the clean slate to reach for between tests or to undo overrides. (Coercible built-ins are auto_coerce's job, not a reset flavor.)



380
381
382
# File 'lib/graph_weaver.rb', line 380

def reset_scalars!
  Codegen.reset_scalars!
end

.resolve_transport(target) ⇒ Object

The transport behind a client-or-transport value: a Client resolves to its own transport, anything else already speaks execute. Generated modules call this on every execute, so any slot in the resolution chain can hold either kind.



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

def resolve_transport(target)
  target.is_a?(Client) ? target.transport! : target
end

.shared_artifacts(output) ⇒ Object

The wholly-generated shared-artifact files under output (inputs/*.rb and unions.rb) — safe to prune when regeneration no longer produces them.



159
160
161
# File 'lib/graph_weaver.rb', line 159

def shared_artifacts(output)
  Dir[File.join(output, "inputs", "*.rb")] + Dir[File.join(output, "unions.rb")]
end

.transport_errorsObject



61
62
63
64
65
66
# File 'lib/graph_weaver/errors.rb', line 61

def transport_errors
  @transport_errors ||= T.let(
    Set[SocketError, SystemCallError, IOError],
    T.nilable(T::Set[T.class_of(Exception)]),
  )
end

.verify_generated!(schema: nil, queries: queries_path, output: generated_path, client: nil, inputs_module: nil, unions_module: nil) ⇒ Object

The freshness guard: raise unless every generated file matches what the current schema + queries + scalar registrations would produce. One line in a spec, or rake graph_weaver:verify in CI:

 it "generated queries are current" do
   GraphWeaver.verify_generated!
 end


171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/graph_weaver.rb', line 171

def verify_generated!(schema: nil, queries: queries_path, output: generated_path, client: nil,
  inputs_module: nil, unions_module: nil)
  schema ||= locate_schema!
  inputs_module ||= self.inputs_module(output)
  unions_module ||= self.unions_module(output)
  plan = generation_plan(queries:, schema:, client:, inputs_module:, unions_module:)
  stale = plan.filter_map do |filename, source|
    target = File.join(output, filename)
    target unless File.exist?(target) && File.read(target) == source
  end
  # strays: a shared-artifact file the current schema + queries no longer produce
  stale += shared_artifacts(output) - plan.map { |f, _| File.join(output, f) }

  unless stale.empty?
    raise Error, "stale generated queries — regenerate (rake graph_weaver:generate): #{stale.join(", ")}"
  end

  true
end