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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1314 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -154
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -258
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -124
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +593 -334
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +9 -1
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +14 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +7 -0
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +794 -59
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +43 -8
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +160 -61
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +181 -21
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +28 -10
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +67 -14
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +389 -170
  62. 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
@@ -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 generated_path exists,
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) }
@@ -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: only 5xx — a 4xx is a bug in the request, retrying
23
- # won't fix it. Override with retry_if: ->(error) { ... }
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
- # retry 5xx, not 4xx; everything else listed in on: retries
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) || error.status >= 500
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
@@ -8,46 +8,244 @@ require_relative "testing"
8
8
  #
9
9
  # require "graph_weaver/rspec"
10
10
  #
11
- # Then opt in to per-example fakes (explicit on purpose silently
12
- # swapping every example onto a fake is too surprising to be a default):
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
- # GraphWeaver::Testing.configure do |config|
15
- # config.auto_fake = true # every example runs against a fake
16
- # # config.schema = MySchema # optional: the committed dump auto-locates
17
- # end
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
- # - auto_fake: when on (and a schema resolves), each example gets a
23
- # fresh seeded FakeClient installed as GraphWeaver.client
24
- # generated modules run in test mode with zero per-test setup. The
25
- # prior client is restored after each example.
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
- GraphWeaver.client = FakeClient.new(schema: config.schema)
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
- config = GraphWeaver::Testing.config
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