Module: GraphWeaver

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/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, SchemaLoader, Selection, Testing, TypeHelpers Classes: Client, Codegen, Error, GraphQLError, QueryError, Railtie, Response, Retry, ServerError, Transport, TransportError, TypeError, ValidationError

Constant Summary collapse

VERSION =
"0.2.0"

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.auto_coerce ⇒ Object

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.



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

def auto_coerce
  @auto_coerce
end

.client ⇒ Object

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.



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

def client
  @client
end

.generated_path ⇒ Object



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

def generated_path = @generated_path || "app/graphql/generated"

.logger ⇒ Object

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_path ⇒ Object



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

def queries_path = @queries_path || "app/graphql/queries"

.schema_path ⇒ Object



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

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

Class Method Details

.clear_scalars! ⇒ Object

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



231
232
233
# File 'lib/graph_weaver.rb', line 231

def clear_scalars!
  Codegen.clear_scalars!
end

.client! ⇒ Object

the default client, when one is required



47
48
49
# File 'lib/graph_weaver.rb', line 47

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

.each_query(queries, schema:, client:) ⇒ Object

(base, generated_source) per .graphql file in a directory



134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/graph_weaver.rb', line 134

def each_query(queries, schema:, client:)
  Dir[File.join(queries, "*.graphql")].sort.map do |path|
    base = File.basename(path, ".graphql")
    source = Codegen.generate(
      schema:,
      query: File.read(path),
      module_name: "#{Inflect.camelize(base)}Query",
      client:,
    )
    [base, source]
  end
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.



265
266
267
268
# File 'lib/graph_weaver.rb', line 265

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.



271
272
273
# File 'lib/graph_weaver.rb', line 271

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

.generate!(schema: nil, queries: queries_path, output: generated_path, client: 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).



76
77
78
79
80
81
82
83
84
85
86
# File 'lib/graph_weaver.rb', line 76

def generate!(schema: nil, queries: queries_path, output: generated_path, client: nil)
  schema ||= locate_schema!
  FileUtils.mkdir_p(output)

  each_query(queries, schema:, client:).map do |base, source|
    target = File.join(output, "#{base}_query.rb")
    File.write(target, source)
    log(:info) { "generated #{target}" }
    target
  end
end

.load_generated!(path = generated_path) ⇒ 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).



119
120
121
122
123
124
# File 'lib/graph_weaver.rb', line 119

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

.locate_schema! ⇒ Object

the conventional schema dump, required



127
128
129
130
# File 'lib/graph_weaver.rb', line 127

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).



32
33
34
# File 'lib/graph_weaver.rb', line 32

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

.parse(schema:, query:, name: nil, client: nil, scalars: nil, enums: nil, types: nil) ⇒ 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.



244
245
246
247
248
249
250
251
# File 'lib/graph_weaver.rb', line 244

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

  Codegen.parse(schema:, query:, module_name: name, client:, scalars:, enums:, types:)
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.



194
195
196
# File 'lib/graph_weaver.rb', line 194

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, ...)



199
200
201
# File 'lib/graph_weaver.rb', line 199

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. Call before generating.



179
180
181
# File 'lib/graph_weaver.rb', line 179

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

Add one or more exception classes to the transport-error set.



56
57
58
59
# File 'lib/graph_weaver/errors.rb', line 56

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

.register_type(graphql_name, *mixins, requires: nil, &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, and srb tc checks them against each query's selection:

 GraphWeaver.register_type("Pet", PetHelpers)

Or build the mixin inline with a block (module_eval'd into an auto-named module — quick, but invisible to srb tc):

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

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



218
219
220
# File 'lib/graph_weaver.rb', line 218

def register_type(graphql_name, *mixins, requires: nil, &block)
  Codegen.register_type(graphql_name, *mixins, requires:, &block)
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.)



225
226
227
# File 'lib/graph_weaver.rb', line 225

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.



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

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

.transport_errors ⇒ Object

The exception classes the bundled transports reclassify as TransportError — network-level failures where the request never reached the server. A mutable Set: each transport contributes its own on load (net/http adds Timeout/SSL, Faraday adds its ConnectionFailed, …), and you can add more so they get the same handling:

 GraphWeaver.transport_errors << MyPool::TimeoutError
 GraphWeaver.register_transport_error(Adapter::ResetError)

SystemCallError covers every Errno::* (connection refused/reset, host unreachable); SocketError covers DNS.



51
52
53
# File 'lib/graph_weaver/errors.rb', line 51

def transport_errors
  @transport_errors ||= Set[SocketError, SystemCallError, IOError]
end

.verify_generated!(schema: nil, queries: queries_path, output: generated_path, client: 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


95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/graph_weaver.rb', line 95

def verify_generated!(schema: nil, queries: queries_path, output: generated_path, client: nil)
  schema ||= locate_schema!
  stale = each_query(queries, schema:, client:).filter_map do |base, source|
    target = File.join(output, "#{base}_query.rb")
    target unless File.exist?(target) && File.read(target) == source
  end

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

  true
end