graph_weaver 0.4.6 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1314 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -154
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -258
- data/lib/graph_weaver/codegen/enum_type.rb +27 -124
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +593 -334
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +9 -1
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +14 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +7 -0
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +794 -59
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +43 -8
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +160 -61
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +181 -21
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +28 -10
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +67 -14
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +389 -170
- metadata +20 -3
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require_relative "codegen"
|
|
7
|
+
|
|
8
|
+
module GraphWeaver
|
|
9
|
+
# Anything that holds a schema parses against it. That's a Client, an
|
|
10
|
+
# InProcess wrapper, a FakeClient, and the test Router — each of which
|
|
11
|
+
# already has the two things parsing needs, a schema to check the query
|
|
12
|
+
# against and a client for the module to run on:
|
|
13
|
+
#
|
|
14
|
+
# DashboardQuery = router.parse("query { me { username } }")
|
|
15
|
+
#
|
|
16
|
+
# so the long form (`GraphWeaver.parse(schema: router.schema, client:
|
|
17
|
+
# router, query: ...)`) was only ever spelling out what the object knew.
|
|
18
|
+
#
|
|
19
|
+
# It is a convenience on top of the client contract, not part of it — the
|
|
20
|
+
# client slot stays duck-typed, and a bare graphql-ruby schema class fills
|
|
21
|
+
# it without including this.
|
|
22
|
+
module Parsing
|
|
23
|
+
# Parse a query (a .graphql path or raw string) into a typed module bound
|
|
24
|
+
# to this schema, with this as the module's default client — the module
|
|
25
|
+
# came from here, so it runs against here (a client passed to #execute
|
|
26
|
+
# still wins, e.g. a fake). Same as GraphWeaver.parse(schema:, client:).
|
|
27
|
+
def parse(query, name: nil)
|
|
28
|
+
# #schema is the mixin's one requirement of its includer, and a module
|
|
29
|
+
# has no way to declare that short of an abstract interface
|
|
30
|
+
GraphWeaver.parse(schema: T.unsafe(self).schema, query:, name:, client: parse_client)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Parse every query in a directory (subdirectories included) into typed
|
|
34
|
+
# modules, named like generation would name them — the no-build-step
|
|
35
|
+
# analog of generate! + load_generated!:
|
|
36
|
+
#
|
|
37
|
+
# github.load_queries! # queries/person.graphql => ::PersonQuery
|
|
38
|
+
# github.load_queries!(namespace: Github) # => Github::PersonQuery
|
|
39
|
+
# # a mutation file => ::AdoptMutation
|
|
40
|
+
#
|
|
41
|
+
# Reloadable (constants are replaced), so it suits consoles and dev.
|
|
42
|
+
# Returns the modules.
|
|
43
|
+
def load_queries!(dir = nil, namespace: Object)
|
|
44
|
+
GraphWeaver.query_files(dir || GraphWeaver.queries_paths).map do |path|
|
|
45
|
+
name = GraphWeaver.module_name(path, File.read(path))
|
|
46
|
+
if namespace.const_defined?(name, false)
|
|
47
|
+
# the constant moves, its instances don't — a struct built before the
|
|
48
|
+
# reload keeps failing is_a? against the new module, silently
|
|
49
|
+
GraphWeaver.log(:info) do
|
|
50
|
+
"replacing #{name} — objects built from the previous module stay instances of it"
|
|
51
|
+
end
|
|
52
|
+
namespace.send(:remove_const, name)
|
|
53
|
+
end
|
|
54
|
+
GraphWeaver.log(:info) { "loaded #{name} from #{path}" }
|
|
55
|
+
namespace.const_set(name, parse(path))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# What a parsed module executes through: this object, which holds the
|
|
62
|
+
# schema and runs queries. Client is the one that overrides it — its own
|
|
63
|
+
# #execute is the one-shot parse-and-run, not the client contract, so it
|
|
64
|
+
# bakes the transport it wraps.
|
|
65
|
+
def parse_client = self
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module GraphWeaver
|
|
7
|
+
# Runtime for generated query modules: the client plumbing, which is the
|
|
8
|
+
# one part of a generated module that carries no per-query type
|
|
9
|
+
# information — every module's copy was identical. `extend
|
|
10
|
+
# GraphWeaver::QueryModule` supplies `client`/`client=`; execute and
|
|
11
|
+
# from_response stay generated, since their sigs are the query's types and
|
|
12
|
+
# those are the point.
|
|
13
|
+
#
|
|
14
|
+
# Resolution order, per the docs: per call → per module (`MyQuery.client =`)
|
|
15
|
+
# → the module's baked DEFAULT_CLIENT → `GraphWeaver.client`.
|
|
16
|
+
module QueryModule
|
|
17
|
+
extend T::Sig
|
|
18
|
+
|
|
19
|
+
sig { params(client: T.untyped).void }
|
|
20
|
+
attr_writer :client
|
|
21
|
+
|
|
22
|
+
# the default client (a GraphWeaver::Client or any transport) for
|
|
23
|
+
# execute: per-module override, else the baked default, else the app one
|
|
24
|
+
sig { returns(T.untyped) }
|
|
25
|
+
def client
|
|
26
|
+
@client || default_client
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# The client one execute runs through: the per-call `client:`, else the
|
|
32
|
+
# module's, else the app default. Checked here so a wrong one names the
|
|
33
|
+
# contract and the module, rather than surfacing as a NoMethodError from
|
|
34
|
+
# inside the call.
|
|
35
|
+
sig { params(override: T.untyped).returns(T.untyped) }
|
|
36
|
+
def client_for(override)
|
|
37
|
+
target = override || client
|
|
38
|
+
return target if target.respond_to?(:execute)
|
|
39
|
+
|
|
40
|
+
# Kernel.raise: this module is extended into another, so sorbet can't
|
|
41
|
+
# see that its host is an Object
|
|
42
|
+
Kernel.raise GraphWeaver::Error,
|
|
43
|
+
"#{self}: client must respond to #execute(query, variables:), got #{target.class}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Codegen's `client:` constant, emitted as a DEFAULT_CLIENT lambda so the
|
|
47
|
+
# constant it names is resolved on first use rather than at load — a
|
|
48
|
+
# generated file may load before the initializer that builds the client.
|
|
49
|
+
sig { returns(T.untyped) }
|
|
50
|
+
def default_client
|
|
51
|
+
mod = T.unsafe(self)
|
|
52
|
+
mod.const_defined?(:DEFAULT_CLIENT, false) ? mod.const_get(:DEFAULT_CLIENT).call : GraphWeaver.client!
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/graph_weaver/railtie.rb
CHANGED
|
@@ -7,16 +7,32 @@
|
|
|
7
7
|
# rake_tasks block, so graph_weaver:* tasks appear with no Rakefile
|
|
8
8
|
# edit. (Outside Rails there is no task-discovery hook — add
|
|
9
9
|
# `require "graph_weaver/tasks"` to your Rakefile.)
|
|
10
|
-
# - generated modules: required at boot when
|
|
10
|
+
# - generated modules: required at boot when a generated_paths entry exists,
|
|
11
11
|
# after config/initializers (registrations and GraphWeaver.client=
|
|
12
12
|
# run first — block-built type helpers must exist before the files
|
|
13
13
|
# that include them load). load_generated! stays idempotent, so
|
|
14
14
|
# calling it yourself too is harmless.
|
|
15
|
+
# - Zeitwerk: the generated directory is hidden from it, since the
|
|
16
|
+
# default one lives under app/ and its files define top-level
|
|
17
|
+
# constants.
|
|
15
18
|
class GraphWeaver::Railtie < Rails::Railtie
|
|
16
19
|
rake_tasks do
|
|
17
20
|
require "graph_weaver/tasks"
|
|
18
21
|
end
|
|
19
22
|
|
|
23
|
+
# generated/person_query.rb defines ::PersonQuery, but Zeitwerk infers
|
|
24
|
+
# Generated::PersonQuery from the path — and app/graphql/generated is
|
|
25
|
+
# inside an autoload root by default, so eager loading raised
|
|
26
|
+
# "uninitialized constant Generated::PersonQuery" in production while
|
|
27
|
+
# development (lazy) was fine. load_generated! below requires them.
|
|
28
|
+
initializer "graph_weaver.ignore_generated", before: :setup_main_autoloader do
|
|
29
|
+
Rails.autoloaders.each do |loader|
|
|
30
|
+
# patterns, not paths — generated_paths may be globs, and Zeitwerk
|
|
31
|
+
# expands its own at setup (which is what this runs before)
|
|
32
|
+
GraphWeaver.generated_paths.each { |path| loader.ignore(Rails.root.join(path).to_s) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
20
36
|
# Rails.logger, unless the app already chose one (set
|
|
21
37
|
# GraphWeaver.logger = nil in an initializer to silence)
|
|
22
38
|
initializer "graph_weaver.logger" do
|
|
@@ -24,6 +40,12 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
24
40
|
end
|
|
25
41
|
|
|
26
42
|
initializer "graph_weaver.load_generated", after: :load_config_initializers do
|
|
43
|
+
# The graph_weaver tasks write these files and need none of them loaded.
|
|
44
|
+
# Loading them would let a stale one block its own repair: a dropped
|
|
45
|
+
# extend_type leaves a dangling include, and generate depends on
|
|
46
|
+
# :environment, so boot failed before the task that would regenerate it.
|
|
47
|
+
next if GraphWeaver.skip_generated_load
|
|
48
|
+
|
|
27
49
|
# entries may be globs, so Dir[] rather than Dir.exist?
|
|
28
50
|
GraphWeaver.load_generated! if GraphWeaver.generated_paths.any? { |path| Dir[path].any? }
|
|
29
51
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module GraphWeaver
|
|
7
|
+
# Runtime for the generated `Representations` builders — the entity
|
|
8
|
+
# references a federation `_entities(representations:)` query takes.
|
|
9
|
+
#
|
|
10
|
+
# Codegen types what it can: the kwargs are the entity's @key fields, so a
|
|
11
|
+
# single-key entity can't be under-specified without a Sorbet error. What's
|
|
12
|
+
# left is what a sig can't say — an entity with two alternative keys (either
|
|
13
|
+
# resolves it, neither is individually required) and a nested key set
|
|
14
|
+
# (`organization { id }`, a sub-hash the kwarg's Hash type doesn't pin
|
|
15
|
+
# down). Both land here.
|
|
16
|
+
module Representation
|
|
17
|
+
# `key_sets` is the entity's @key field sets as dotted paths, in
|
|
18
|
+
# declaration order — [["upc", "sku"], ["id"]] for a type keyed either
|
|
19
|
+
# way. The first fully-supplied one wins; the wire hash carries exactly
|
|
20
|
+
# that key set plus __typename, so nothing extraneous reaches the router.
|
|
21
|
+
def self.build(type_name, values, key_sets)
|
|
22
|
+
satisfied = key_sets.find { |paths| missing(values, paths).empty? }
|
|
23
|
+
raise incomplete(type_name, values, key_sets) unless satisfied
|
|
24
|
+
|
|
25
|
+
satisfied.each_with_object({ "__typename" => type_name }) do |path, wire|
|
|
26
|
+
assign(wire, path.split("."), dig(values, path))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.missing(values, paths) = paths.select { |path| dig(values, path).nil? }
|
|
31
|
+
private_class_method :missing
|
|
32
|
+
|
|
33
|
+
# Nested key values arrive as a caller-built hash, so accept either key
|
|
34
|
+
# flavour at every hop — a literal `{ id: "1" }` reads the same as a hash
|
|
35
|
+
# round-tripped through JSON.
|
|
36
|
+
def self.dig(values, path)
|
|
37
|
+
path.split(".").reduce(values) do |scope, name|
|
|
38
|
+
return unless scope.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
scope.key?(name) ? scope[name] : scope[name.to_sym]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
private_class_method :dig
|
|
44
|
+
|
|
45
|
+
def self.assign(wire, path, value)
|
|
46
|
+
*parents, leaf = path
|
|
47
|
+
target = parents.reduce(wire) { |scope, name| scope[name] ||= {} }
|
|
48
|
+
target[leaf] = value
|
|
49
|
+
end
|
|
50
|
+
private_class_method :assign
|
|
51
|
+
|
|
52
|
+
# Name the type and what it's short of, per @key — with a single key
|
|
53
|
+
# there's one answer, so it also fills InputError#field.
|
|
54
|
+
def self.incomplete(type_name, values, key_sets)
|
|
55
|
+
gaps = key_sets.map { |paths| missing(values, paths) }
|
|
56
|
+
|
|
57
|
+
if key_sets.one?
|
|
58
|
+
InputError.new(
|
|
59
|
+
"#{type_name} representation is missing @key #{gaps.first.map(&:inspect).join(", ")}",
|
|
60
|
+
field: gaps.first.one? ? gaps.first.first : nil,
|
|
61
|
+
)
|
|
62
|
+
else
|
|
63
|
+
alternatives = key_sets.zip(gaps).map do |paths, gap|
|
|
64
|
+
# a partly-supplied compound key is the near miss worth pointing at;
|
|
65
|
+
# for one wholly absent, naming it twice says nothing extra
|
|
66
|
+
supplied = gap.size < paths.size
|
|
67
|
+
"#{paths.map(&:inspect).join(" + ")}#{" (missing #{gap.map(&:inspect).join(", ")})" if supplied}"
|
|
68
|
+
end
|
|
69
|
+
InputError.new("#{type_name} representation satisfies none of its @keys — supply #{alternatives.join(", or ")}")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
private_class_method :incomplete
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -44,6 +44,13 @@ module GraphWeaver
|
|
|
44
44
|
sig { returns(T::Boolean) }
|
|
45
45
|
def errors? = !errors.empty?
|
|
46
46
|
|
|
47
|
+
# The same question the other way round — people reach for the positive,
|
|
48
|
+
# and a NoMethodError is a poor answer. Spelled `success?` after
|
|
49
|
+
# Process::Status and Faraday::Response; `ok?` would read as HTTP 200,
|
|
50
|
+
# which a GraphQL response carrying errors also is.
|
|
51
|
+
sig { returns(T::Boolean) }
|
|
52
|
+
def success? = errors.empty?
|
|
53
|
+
|
|
47
54
|
# The typed result, or raise QueryError if the response carried top-level
|
|
48
55
|
# errors (partial data and extensions ride along on the error).
|
|
49
56
|
sig { returns(Data) }
|
data/lib/graph_weaver/retry.rb
CHANGED
|
@@ -19,11 +19,15 @@ require_relative "errors"
|
|
|
19
19
|
#
|
|
20
20
|
# What retries, by default:
|
|
21
21
|
# - TransportError: always (the request never arrived)
|
|
22
|
-
# - ServerError:
|
|
23
|
-
# won't fix it. Override with
|
|
22
|
+
# - ServerError: 5xx, plus 408 and 429 — the rest of 4xx is a bug in
|
|
23
|
+
# the request, retrying won't fix it. Override with
|
|
24
|
+
# retry_if: ->(error) { ... }
|
|
24
25
|
# - responses whose GraphQL error codes intersect retry_codes: (off by
|
|
25
26
|
# default — pass the codes your API uses for transient failures)
|
|
26
27
|
#
|
|
28
|
+
# A server that answers with Retry-After sets the delay itself (clamped
|
|
29
|
+
# to max:); otherwise the configured backoff decides.
|
|
30
|
+
#
|
|
27
31
|
# Exhausting tries re-raises the last error (or returns the last
|
|
28
32
|
# code-matched response).
|
|
29
33
|
class GraphWeaver::Retry
|
|
@@ -32,9 +36,15 @@ class GraphWeaver::Retry
|
|
|
32
36
|
linear: ->(base, attempt) { base * attempt },
|
|
33
37
|
}.freeze
|
|
34
38
|
|
|
35
|
-
#
|
|
39
|
+
# a 4xx is a bug in the request — except these two, which are the
|
|
40
|
+
# server asking you to come back later rather than to fix anything
|
|
41
|
+
RETRIABLE_CLIENT_STATUSES = [408, 429].freeze
|
|
42
|
+
|
|
43
|
+
# retry 5xx (and 408/429), not the rest of 4xx; everything else listed
|
|
44
|
+
# in on: retries
|
|
36
45
|
DEFAULT_RETRY_IF = lambda do |error|
|
|
37
|
-
!error.is_a?(GraphWeaver::ServerError) ||
|
|
46
|
+
!error.is_a?(GraphWeaver::ServerError) ||
|
|
47
|
+
error.status >= 500 || RETRIABLE_CLIENT_STATUSES.include?(error.status)
|
|
38
48
|
end
|
|
39
49
|
|
|
40
50
|
def initialize(client, tries: 3, on: [GraphWeaver::TransportError, GraphWeaver::ServerError],
|
|
@@ -65,19 +75,23 @@ class GraphWeaver::Retry
|
|
|
65
75
|
@client.url if @client.respond_to?(:url)
|
|
66
76
|
end
|
|
67
77
|
|
|
68
|
-
def execute(query, variables: {})
|
|
78
|
+
def execute(query, variables: {}, operation_name: nil)
|
|
69
79
|
attempt = 0
|
|
80
|
+
failure = T.let(nil, T.nilable(Exception))
|
|
70
81
|
|
|
71
82
|
loop do
|
|
72
83
|
attempt += 1
|
|
73
84
|
begin
|
|
74
|
-
response = @client.execute(query, variables:)
|
|
85
|
+
response = @client.execute(query, variables:, operation_name:)
|
|
75
86
|
return response unless attempt < @tries && retryable_response?(response)
|
|
76
87
|
rescue *@on => e
|
|
77
88
|
raise if attempt >= @tries || !@retry_if.call(e)
|
|
89
|
+
|
|
90
|
+
failure = e
|
|
78
91
|
end
|
|
79
92
|
|
|
80
|
-
@sleeper.call(delay(attempt))
|
|
93
|
+
@sleeper.call(delay(attempt, failure))
|
|
94
|
+
failure = nil
|
|
81
95
|
end
|
|
82
96
|
end
|
|
83
97
|
|
|
@@ -90,7 +104,14 @@ class GraphWeaver::Retry
|
|
|
90
104
|
codes.intersect?(@retry_codes)
|
|
91
105
|
end
|
|
92
106
|
|
|
93
|
-
def delay(attempt)
|
|
107
|
+
def delay(attempt, failure)
|
|
108
|
+
# A Retry-After wins over our backoff: the server is the only party
|
|
109
|
+
# that knows when its window reopens, and it isn't guessing. Still
|
|
110
|
+
# clamped to max:, so "come back in an hour" can't park a thread for
|
|
111
|
+
# an hour — and not jittered, since it's an instruction, not a guess.
|
|
112
|
+
after = failure.retry_after if failure.is_a?(GraphWeaver::ServerError)
|
|
113
|
+
return [after, @max].min.to_f if after
|
|
114
|
+
|
|
94
115
|
seconds = [@backoff.call(@base, attempt), @max].min.to_f
|
|
95
116
|
@jitter ? seconds * (0.5 + rand * 0.5) : seconds
|
|
96
117
|
end
|
data/lib/graph_weaver/rspec.rb
CHANGED
|
@@ -8,46 +8,244 @@ require_relative "testing"
|
|
|
8
8
|
#
|
|
9
9
|
# require "graph_weaver/rspec"
|
|
10
10
|
#
|
|
11
|
-
# Then
|
|
12
|
-
#
|
|
11
|
+
# Then say what an example runs against with one tag, on the example or on
|
|
12
|
+
# the group it belongs to (rspec metadata inherits, so a whole describe
|
|
13
|
+
# block can run against real resolvers):
|
|
13
14
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
15
|
+
# it "renders the empty state", graphql: :fake do … end
|
|
16
|
+
# it "authorizes drafts", graphql: :in_process do … end
|
|
17
|
+
# describe "checkout", graphql: :router do … end
|
|
18
|
+
#
|
|
19
|
+
# :fake fabricated, schema-correct data; no resolvers run
|
|
20
|
+
# :in_process your resolvers, one live schema class, in-process
|
|
21
|
+
# :router your resolvers, across a federated graph
|
|
22
|
+
# false opt out — GraphWeaver.client is left exactly as it is,
|
|
23
|
+
# even under config.default_mode
|
|
24
|
+
#
|
|
25
|
+
# `rspec --tag graphql:router` runs one mode's examples.
|
|
26
|
+
#
|
|
27
|
+
# `GraphWeaver.client` is snapshotted before every example and restored
|
|
28
|
+
# after — tagged, untagged, opted out, whatever the example did to it. So
|
|
29
|
+
# an example (or a `before` block, or a shared context) is free to build
|
|
30
|
+
# the client it wants:
|
|
31
|
+
#
|
|
32
|
+
# before { GraphWeaver.client = GraphWeaver::Testing::Failure.throttled }
|
|
33
|
+
# it "pins the name" { graphql_fake(overrides: { "Person.name" => "Ada" }) }
|
|
34
|
+
#
|
|
35
|
+
# **Nothing needs configuring.** Each mode derives what it runs against,
|
|
36
|
+
# and refuses — naming what it looked for — rather than guessing:
|
|
37
|
+
#
|
|
38
|
+
# - the schema is GraphWeaver::Testing.config.schema if you set one, else
|
|
39
|
+
# the committed dump at GraphWeaver.schema_path, else the schema of
|
|
40
|
+
# GraphWeaver.client.
|
|
41
|
+
# - :in_process runs against config.schema, or the schema class your
|
|
42
|
+
# client already uses. Only a live class has resolvers, so when
|
|
43
|
+
# neither is there it says so rather than hunting for one.
|
|
44
|
+
# - :router plans against the composed supergraph: the dump, when that's
|
|
45
|
+
# what it is, else config.router = { supergraph: … }. Subgraphs are
|
|
46
|
+
# derived from what each loaded schema defines (Testing::Subgraphs);
|
|
47
|
+
# one nothing here serves is absent, and only a query that reaches its
|
|
48
|
+
# fields is refused.
|
|
18
49
|
#
|
|
19
50
|
# What it wires up:
|
|
20
51
|
# - seed: defaults to rspec's --seed, so `rspec --seed 1234` reproduces
|
|
21
52
|
# fake data along with test order
|
|
22
|
-
# -
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
53
|
+
# - a client per example, from the tag (or config.default_mode for an
|
|
54
|
+
# untagged one; nil, the default, leaves GraphWeaver.client alone),
|
|
55
|
+
# and GraphWeaver.client restored afterwards either way — so a client
|
|
56
|
+
# an example builds for itself is cleaned up like a tagged one.
|
|
57
|
+
# - graphql_context — the GraphQL context resolvers see, merged onto
|
|
58
|
+
# config.context and reset between examples.
|
|
59
|
+
#
|
|
60
|
+
# The router is built once for the suite (parsing a supergraph per example
|
|
61
|
+
# would be real time) and installed for each.
|
|
26
62
|
#
|
|
27
63
|
# note: modules generated with a baked-in client: constant don't consult
|
|
28
64
|
# GraphWeaver.client — generate without client: to make them fakeable.
|
|
29
65
|
module GraphWeaver
|
|
30
66
|
module Testing
|
|
31
67
|
module RSpecIntegration
|
|
68
|
+
# the one metadata key — namespaced, because an app's own :fake or
|
|
69
|
+
# :router tag must never silently change which client an example runs
|
|
70
|
+
# against
|
|
71
|
+
TAG = :graphql
|
|
72
|
+
|
|
32
73
|
def self.install(rspec_config)
|
|
74
|
+
rspec_config.include(Helpers)
|
|
75
|
+
|
|
33
76
|
rspec_config.before(:suite) do
|
|
34
77
|
config = GraphWeaver::Testing.config
|
|
35
78
|
config.seed ||= RSpec.configuration.seed
|
|
36
79
|
end
|
|
37
80
|
|
|
81
|
+
# snapshot unconditionally: what the example does to GraphWeaver.client
|
|
82
|
+
# is undone whether the tag installed one or the example built its own,
|
|
83
|
+
# so there is no idiom to discover and no way to leak a client forward
|
|
38
84
|
rspec_config.before(:each) do
|
|
39
|
-
config = GraphWeaver::Testing.config
|
|
40
|
-
next unless config.auto_fake && config.schema
|
|
41
|
-
|
|
42
85
|
@__graph_weaver_prior_client = GraphWeaver.client
|
|
43
|
-
|
|
86
|
+
@__graph_weaver_mode = GraphWeaver::Testing::RSpecIntegration.mode_for(
|
|
87
|
+
RSpec.current_example&.metadata || {},
|
|
88
|
+
)
|
|
89
|
+
if @__graph_weaver_mode
|
|
90
|
+
GraphWeaver.client = GraphWeaver::Testing::RSpecIntegration.client_for(@__graph_weaver_mode)
|
|
91
|
+
end
|
|
44
92
|
end
|
|
45
93
|
|
|
46
94
|
rspec_config.after(:each) do
|
|
47
|
-
|
|
48
|
-
next unless config.auto_fake && config.schema
|
|
95
|
+
next unless defined?(@__graph_weaver_prior_client)
|
|
49
96
|
|
|
50
97
|
GraphWeaver.client = @__graph_weaver_prior_client
|
|
98
|
+
remove_instance_variable(:@__graph_weaver_prior_client)
|
|
99
|
+
remove_instance_variable(:@__graph_weaver_mode)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# the mode this example's metadata selects, or the configured default
|
|
104
|
+
def self.mode_for(metadata, config = GraphWeaver::Testing.config)
|
|
105
|
+
tagged = metadata[TAG]
|
|
106
|
+
return config.default_mode if tagged.nil?
|
|
107
|
+
# opt out: no client is installed, and a configured default_mode
|
|
108
|
+
# doesn't sweep this example up
|
|
109
|
+
return if tagged == false
|
|
110
|
+
|
|
111
|
+
mode = tagged.to_s.to_sym
|
|
112
|
+
return mode if CLIENT_MODES.include?(mode)
|
|
113
|
+
|
|
114
|
+
raise GraphWeaver::Error, "#{TAG}: #{tagged.inspect} is not a mode — " \
|
|
115
|
+
"#{CLIENT_MODES.map(&:inspect).join(", ")} (or false to opt out)"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# the client an example in this mode runs against
|
|
119
|
+
def self.client_for(mode, config = GraphWeaver::Testing.config)
|
|
120
|
+
case mode
|
|
121
|
+
when :fake
|
|
122
|
+
FakeClient.new(schema: config.reference_schema!)
|
|
123
|
+
when :in_process
|
|
124
|
+
GraphWeaver::InProcess.new(config.schema_class!, context: config.context)
|
|
125
|
+
when :router
|
|
126
|
+
router = config.built_router
|
|
127
|
+
router.context = config.context
|
|
128
|
+
# built once for the suite, so the trace has to be told where this
|
|
129
|
+
# example starts — otherwise have_fetched reads the last one's
|
|
130
|
+
router.reset_trace
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# included into every example group, so graphql_context is there
|
|
135
|
+
# whether or not this example took a client from the hook
|
|
136
|
+
module Helpers
|
|
137
|
+
# The fake this example runs against, built here rather than by the
|
|
138
|
+
# tag — which is how it takes options. `graphql: :fake` is exactly
|
|
139
|
+
# this call with none:
|
|
140
|
+
#
|
|
141
|
+
# it "shows the two paid orders" do
|
|
142
|
+
# graphql_fake(overrides: { "Reader.name" => "Ada",
|
|
143
|
+
# "Reader.orders" => [{ "status" => "PAID" }, {}] })
|
|
144
|
+
# expect(DashboardQuery.execute!.reader.orders.size).to eq 2
|
|
145
|
+
# end
|
|
146
|
+
#
|
|
147
|
+
# Returns the client, for the assertions that are about the request:
|
|
148
|
+
#
|
|
149
|
+
# fake = graphql_fake
|
|
150
|
+
# 2.times { Dashboard.load }
|
|
151
|
+
# expect(fake.requests.size).to eq 1
|
|
152
|
+
#
|
|
153
|
+
# Installed as GraphWeaver.client and restored after the example,
|
|
154
|
+
# like a tagged one — so the tag is optional here, not required.
|
|
155
|
+
def graphql_fake(**options)
|
|
156
|
+
claim_mode!(:fake)
|
|
157
|
+
options[:schema] ||= GraphWeaver::Testing.config.reference_schema!
|
|
158
|
+
GraphWeaver.client = GraphWeaver::Testing::FakeClient.new(**options)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Run this example against one schema class's real resolvers.
|
|
162
|
+
# `graphql: :in_process` is exactly this call with no argument, which
|
|
163
|
+
# runs config.schema when that is a live class.
|
|
164
|
+
#
|
|
165
|
+
# Naming one is how a federated app tests a single subgraph directly,
|
|
166
|
+
# which is a different question from `graphql: :router` — that plans
|
|
167
|
+
# across the whole graph and stitches. Both are worth asking, and a
|
|
168
|
+
# suite testing several subgraphs needs to say which per example:
|
|
169
|
+
#
|
|
170
|
+
# it "hides a draft review", graphql: :in_process do
|
|
171
|
+
# graphql_in_process(Reviews::Schema)
|
|
172
|
+
# …
|
|
173
|
+
# end
|
|
174
|
+
#
|
|
175
|
+
# Returns the client, and is restored after the example like a tagged
|
|
176
|
+
# one — so the tag is optional here.
|
|
177
|
+
def graphql_in_process(schema = nil, **options)
|
|
178
|
+
claim_mode!(:in_process)
|
|
179
|
+
schema ||= GraphWeaver::Testing.config.schema_class!
|
|
180
|
+
options[:context] ||= GraphWeaver::Testing.config.context
|
|
181
|
+
GraphWeaver.client = GraphWeaver::InProcess.new(schema, **options)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# A tag and a helper are two spellings of one choice, so they can
|
|
185
|
+
# agree (`graphql: :fake` plus `graphql_fake(overrides:)` is the
|
|
186
|
+
# documented way to pass options) but must not contradict: one of the
|
|
187
|
+
# two is then a mistake, and silently letting the later one win hides
|
|
188
|
+
# which.
|
|
189
|
+
def claim_mode!(mode)
|
|
190
|
+
tagged = defined?(@__graph_weaver_mode) ? @__graph_weaver_mode : nil
|
|
191
|
+
if tagged && tagged != mode
|
|
192
|
+
# Kernel.raise: this module is mixed into every example group, so
|
|
193
|
+
# it doesn't include Kernel for sorbet to find
|
|
194
|
+
Kernel.raise GraphWeaver::Error, "this example is tagged #{TAG}: #{tagged.inspect} but calls " \
|
|
195
|
+
"graphql_#{mode} — drop one. The tag is the helper with no arguments, so keep the " \
|
|
196
|
+
"helper when you need to pass it something."
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
@__graph_weaver_mode = mode
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The GraphQL context this example's resolvers see — merged onto
|
|
203
|
+
# config.context, and reset before the next example runs:
|
|
204
|
+
#
|
|
205
|
+
# graphql_context(current_user: alice)
|
|
206
|
+
# graphql_context(admin: true) { … } # only inside the block
|
|
207
|
+
# graphql_context # read it back
|
|
208
|
+
def graphql_context(values = nil, &block)
|
|
209
|
+
mode = defined?(@__graph_weaver_mode) ? @__graph_weaver_mode : nil
|
|
210
|
+
baseline = GraphWeaver::Testing::RSpecIntegration.context!(mode)
|
|
211
|
+
return baseline unless values
|
|
212
|
+
|
|
213
|
+
merged = baseline.merge(values)
|
|
214
|
+
GraphWeaver::Testing::RSpecIntegration.set_context(mode, merged)
|
|
215
|
+
return merged unless block
|
|
216
|
+
|
|
217
|
+
begin
|
|
218
|
+
block.call
|
|
219
|
+
ensure
|
|
220
|
+
GraphWeaver::Testing::RSpecIntegration.set_context(mode, baseline)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# A fake has no resolvers to hand a context to, so silently ignoring
|
|
226
|
+
# one would leave an example asserting on data nothing scoped.
|
|
227
|
+
def self.context!(mode)
|
|
228
|
+
case mode
|
|
229
|
+
when :in_process, :router then GraphWeaver.client.context
|
|
230
|
+
when :fake
|
|
231
|
+
raise GraphWeaver::Error, "graphql_context needs resolvers to receive it, and a " \
|
|
232
|
+
"#{TAG}: :fake example runs against fabricated data — tag it #{TAG}: :in_process or " \
|
|
233
|
+
"#{TAG}: :router (or pin the data itself: " \
|
|
234
|
+
"graphql_fake(overrides: { \"Person.name\" => \"Ada\" }))"
|
|
235
|
+
else
|
|
236
|
+
raise GraphWeaver::Error, "graphql_context needs an example running against your " \
|
|
237
|
+
"resolvers — tag it #{TAG}: :in_process or #{TAG}: :router"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def self.set_context(mode, values)
|
|
242
|
+
client = GraphWeaver.client
|
|
243
|
+
# the router is built once for the suite, so it takes a new context
|
|
244
|
+
# rather than being rebuilt; an InProcess is two ivars, so it isn't
|
|
245
|
+
# worth making mutable for this
|
|
246
|
+
case mode
|
|
247
|
+
when :router then client.context = values
|
|
248
|
+
when :in_process then GraphWeaver.client = GraphWeaver::InProcess.new(client.schema, context: values)
|
|
51
249
|
end
|
|
52
250
|
end
|
|
53
251
|
end
|