graph_weaver 0.1.0 → 0.2.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +213 -0
  4. data/Gemfile.lock +32 -2
  5. data/Makefile +7 -2
  6. data/NOTES.md +1 -1
  7. data/PLAN.md +34 -1
  8. data/README.md +56 -41
  9. data/docs/cassettes.md +76 -0
  10. data/docs/errors.md +37 -9
  11. data/docs/generated_modules.md +139 -29
  12. data/docs/getting_started.md +134 -0
  13. data/docs/logging.md +33 -0
  14. data/docs/real_world.md +27 -20
  15. data/docs/scalars.md +122 -8
  16. data/docs/testing.md +44 -34
  17. data/docs/transports.md +124 -0
  18. data/graph_weaver.gemspec +7 -1
  19. data/lib/graph_weaver/client.rb +200 -0
  20. data/lib/graph_weaver/codegen/emit.rb +88 -39
  21. data/lib/graph_weaver/codegen/enum_type.rb +149 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +109 -9
  23. data/lib/graph_weaver/codegen/scalar_type.rb +53 -35
  24. data/lib/graph_weaver/codegen.rb +301 -67
  25. data/lib/graph_weaver/errors.rb +37 -25
  26. data/lib/graph_weaver/hints.rb +63 -0
  27. data/lib/graph_weaver/inflect.rb +2 -1
  28. data/lib/graph_weaver/logging.rb +41 -0
  29. data/lib/graph_weaver/railtie.rb +29 -0
  30. data/lib/graph_weaver/retry.rb +97 -0
  31. data/lib/graph_weaver/rspec.rb +19 -14
  32. data/lib/graph_weaver/schema_loader.rb +156 -20
  33. data/lib/graph_weaver/selection.rb +3 -3
  34. data/lib/graph_weaver/tasks.rb +71 -0
  35. data/lib/graph_weaver/testing/cassette.rb +42 -21
  36. data/lib/graph_weaver/testing/failure.rb +22 -22
  37. data/lib/graph_weaver/testing/{fake_executor.rb → fake_client.rb} +13 -13
  38. data/lib/graph_weaver/testing/values.rb +2 -2
  39. data/lib/graph_weaver/testing.rb +32 -16
  40. data/lib/graph_weaver/transport/faraday.rb +56 -0
  41. data/lib/graph_weaver/transport/http.rb +87 -0
  42. data/lib/graph_weaver/transport.rb +120 -0
  43. data/lib/graph_weaver/version.rb +1 -1
  44. data/lib/graph_weaver.rb +208 -38
  45. metadata +73 -5
  46. data/lib/graph_weaver/faraday_executor.rb +0 -61
  47. data/lib/graph_weaver/http_executor.rb +0 -44
  48. data/lib/graph_weaver/testing/rspec.rb +0 -5
@@ -4,7 +4,7 @@
4
4
  require "graphql"
5
5
 
6
6
  module GraphWeaver
7
- # Shared query-selection walking — the rules Codegen, FakeExecutor, and
7
+ # Shared query-selection walking — the rules Codegen, FakeClient, and
8
8
  # the cassette Anonymizer all follow, in one place so they can't drift:
9
9
  # how fragments flatten into selections, and when a type condition
10
10
  # applies. Hosts set @schema and call load_operation before walking.
@@ -27,7 +27,7 @@ module GraphWeaver
27
27
  case operation&.operation_type
28
28
  when "query", nil then @schema.query
29
29
  when "mutation" then @schema.mutation
30
- else raise NotImplementedError, "unsupported operation: #{operation.operation_type}"
30
+ else raise GraphWeaver::Error, "unsupported operation: #{operation.operation_type}"
31
31
  end
32
32
  end
33
33
 
@@ -47,7 +47,7 @@ module GraphWeaver
47
47
  end
48
48
  each_field(type, fragment.selections, &block) if applies?(fragment.type.name, type)
49
49
  else
50
- raise NotImplementedError, "unsupported selection: #{selection.class}"
50
+ raise GraphWeaver::Error, "unsupported selection: #{selection.class}"
51
51
  end
52
52
  end
53
53
  end
@@ -0,0 +1,71 @@
1
+ # typed: ignore — Rake DSL at top level, loaded from Rakefiles
2
+ # frozen_string_literal: true
3
+
4
+ # Rake tasks — add to your Rakefile:
5
+ #
6
+ # require "graph_weaver/tasks"
7
+ #
8
+ # The tasks use the conventional paths (GraphWeaver.queries_path /
9
+ # generated_path / schema_path — override in your Rakefile or an
10
+ # initializer). Register custom scalars before the tasks run — they're
11
+ # baked into generated source.
12
+ #
13
+ # rake graph_weaver:generate # queries_path -> generated_path
14
+ # rake graph_weaver:verify # fail if generated files are stale (CI)
15
+ require_relative "../graph_weaver"
16
+
17
+ # in Rails, run app config first (initializers register scalars/enums/
18
+ # helpers, and they're baked into generated source)
19
+ GRAPH_WEAVER_DEPS = Rake::Task.task_defined?("environment") ? ["environment"] : []
20
+
21
+ namespace :graph_weaver do
22
+ desc "Generate typed query modules (#{GraphWeaver.queries_path} -> #{GraphWeaver.generated_path})"
23
+ task generate: GRAPH_WEAVER_DEPS do
24
+ # schema auto-located at GraphWeaver.schema_path, any supported extension
25
+ GraphWeaver.generate!.each { |path| puts "wrote #{path}" }
26
+ end
27
+
28
+ desc "Verify generated query modules are up to date"
29
+ task verify: GRAPH_WEAVER_DEPS do
30
+ GraphWeaver.verify_generated!
31
+ puts "generated queries up to date"
32
+ end
33
+
34
+ namespace :schema do
35
+ # both tasks re-introspect from the url recorded in the dump
36
+ # (GRAPHWEAVER_AUTH supplies a token for private APIs)
37
+
38
+ desc "Fail when the server's schema has drifted from the local dump"
39
+ task :verify do
40
+ path = GraphWeaver::SchemaLoader.locate_path or abort "no schema dump at #{GraphWeaver.schema_path}"
41
+ if GraphWeaver::SchemaLoader.stale?(path)
42
+ abort "#{path} is stale — the server's schema has drifted (rake graph_weaver:schema:refresh)"
43
+ end
44
+
45
+ puts "#{path} matches the server"
46
+ end
47
+
48
+ desc "Re-introspect the recorded url and rewrite the local dump"
49
+ task :refresh do
50
+ path = GraphWeaver::SchemaLoader.locate_path or abort "no schema dump at #{GraphWeaver.schema_path}"
51
+ meta = GraphWeaver::SchemaLoader.provenance(path) or abort "#{path} records no source url"
52
+
53
+ transport = GraphWeaver.new(meta["url"], auth: ENV["GRAPHWEAVER_AUTH"]).transport
54
+ GraphWeaver::SchemaLoader.introspect(transport, cache: path, ttl: 0)
55
+ puts "refreshed #{path} from #{meta["url"]}"
56
+ end
57
+ end
58
+
59
+ namespace :cassettes do
60
+ desc "Anonymize every cassette in Testing.config.cassette_dir (PII-safe to commit)"
61
+ task :anonymize do
62
+ require "graph_weaver/testing"
63
+
64
+ schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
65
+ Dir[File.join(GraphWeaver::Testing.config.cassette_dir, "*.yml")].sort.each do |path|
66
+ GraphWeaver::Testing::Cassette.new(path).anonymize!(schema:)
67
+ puts "anonymized #{path}"
68
+ end
69
+ end
70
+ end
71
+ end
@@ -7,12 +7,12 @@ require "yaml"
7
7
 
8
8
  module GraphWeaver
9
9
  module Testing
10
- # Raised by ReplayExecutor when a request has no recording.
10
+ # Raised by Replayer when a request has no recording.
11
11
  class MissingRecording < GraphWeaver::Error
12
12
  def initialize(path:, query:)
13
13
  super(<<~MSG.strip)
14
14
  no recording for this request in #{path} — re-record it
15
- (RecordingExecutor / Cassette.use with a live executor, or delete
15
+ (Recorder / Cassette.use with a live client, or delete
16
16
  the cassette to start over). Query:
17
17
  #{query.strip[0, 200]}
18
18
  MSG
@@ -23,12 +23,12 @@ module GraphWeaver
23
23
  # cassette is a YAML file of {query, variables, response} entries,
24
24
  # keyed on the normalized query + variables.
25
25
  #
26
- # # record against a real executor, replay when the file exists:
27
- # executor = GraphWeaver::Testing::Cassette.use("github", executor: real)
26
+ # # record against a real client, replay when the file exists:
27
+ # client = GraphWeaver::Testing::Cassette.use("github", client: real)
28
28
  #
29
29
  # Cassettes hold real responses — anonymize before committing:
30
30
  #
31
- # Cassette.new("spec/cassettes/github.yml").anonymize!(schema:)
31
+ # Cassette.new("spec/cassettes/github.yml").anonymize!(schema:)
32
32
  #
33
33
  # keeps every shape (list lengths, null positions, enums, __typename,
34
34
  # id relationships via a consistent mapping) while replacing values
@@ -44,21 +44,26 @@ module GraphWeaver
44
44
  def exist? = File.exist?(@path)
45
45
  def size = @entries.size
46
46
 
47
- # replay when recorded, record when not (VCR's once mode).
48
- # executor: is required to record; omit it to replay-or-raise.
49
- def self.use(path, executor: nil)
47
+ # Replay when recorded, record when not (VCR's once mode).
48
+ # client: is required to record; omit it to replay-or-raise.
49
+ # With Testing.config.record on (or GRAPHWEAVER_RECORD=1), always
50
+ # records — the "just re-record everything" switch.
51
+ def self.use(path, client: nil)
50
52
  cassette = new(path)
51
- if cassette.exist?
52
- ReplayExecutor.new(cassette)
53
- elsif executor
54
- RecordingExecutor.new(executor, cassette)
53
+ if Testing.config.record && client
54
+ Recorder.new(client, cassette)
55
+ elsif cassette.exist?
56
+ Replayer.new(cassette)
57
+ elsif client
58
+ Recorder.new(client, cassette)
55
59
  else
56
- raise MissingRecording.new(path: cassette.path, query: "(no executor to record with)")
60
+ raise MissingRecording.new(path: cassette.path, query: "(no client to record with)")
57
61
  end
58
62
  end
59
63
 
60
64
  def lookup(query, variables)
61
- @entries.find { |entry| entry["key"] == self.class.key(query, variables) }
65
+ wanted = self.class.key(query, variables)
66
+ @entries.find { |entry| entry["key"] == wanted }
62
67
  end
63
68
 
64
69
  def record(query, variables, response)
@@ -74,7 +79,7 @@ module GraphWeaver
74
79
  end
75
80
 
76
81
  # Replace recorded response values with fakes, preserving structure.
77
- # Walks each entry's query against the schema (like FakeExecutor,
82
+ # Walks each entry's query against the schema (like FakeClient,
78
83
  # but transforming what's there instead of generating from scratch).
79
84
  def anonymize!(schema:, seed: nil, mode: nil)
80
85
  anonymizer = Anonymizer.new(schema:, seed:, mode:)
@@ -98,15 +103,31 @@ module GraphWeaver
98
103
  end
99
104
  end
100
105
 
101
- # Tees requests through a live executor and records every response.
102
- class RecordingExecutor
103
- def initialize(executor, cassette)
104
- @executor = executor
106
+ # Tees requests through a live client and records every response.
107
+ # With Testing.config.anonymize (or anonymize: true), responses are
108
+ # anonymized as they're recorded — and the anonymized version is what
109
+ # the caller sees too, so assertions written now hold on replay.
110
+ class Recorder
111
+ def initialize(client, cassette, anonymize: nil)
112
+ @client = client
105
113
  @cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
114
+
115
+ config = Testing.config
116
+ if anonymize.nil? ? config.anonymize : anonymize
117
+ unless config.schema
118
+ raise ArgumentError, "anonymizing recordings needs GraphWeaver::Testing.config.schema"
119
+ end
120
+
121
+ @anonymizer = Anonymizer.new(schema: config.schema, seed: config.seed)
122
+ end
106
123
  end
107
124
 
108
125
  def execute(query, variables: {})
109
- response = @executor.execute(query, variables:).to_h
126
+ response = @client.execute(query, variables:).to_h
127
+ if @anonymizer && (data = response["data"])
128
+ response = response.merge("data" => @anonymizer.anonymize(query, data))
129
+ end
130
+
110
131
  @cassette.record(query, variables, response)
111
132
  response
112
133
  end
@@ -114,7 +135,7 @@ module GraphWeaver
114
135
 
115
136
  # Serves recorded responses; raises MissingRecording on unknown
116
137
  # requests rather than silently faking.
117
- class ReplayExecutor
138
+ class Replayer
118
139
  def initialize(cassette)
119
140
  @cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
120
141
  end
@@ -5,19 +5,19 @@ require "json"
5
5
 
6
6
  module GraphWeaver
7
7
  module Testing
8
- # Canned failure executors — each produces exactly what the real
8
+ # Canned failure clients — each produces exactly what the real
9
9
  # transports produce, so error-handling paths are testable without a
10
10
  # server that misbehaves on cue:
11
11
  #
12
- # PersonQuery.execute(id: "1", executor: Failure.transport) # TransportError
13
- # PersonQuery.execute(id: "1", executor: Failure.server(status: 502))
14
- # PersonQuery.execute(id: "1", executor: Failure.throttled) # QueryError, code THROTTLED
15
- # PersonQuery.execute(id: "1", executor: Failure.stale_schema) # schema_stale? => true
12
+ # PersonQuery.execute(id: "1", Failure.transport) # TransportError
13
+ # PersonQuery.execute(id: "1", Failure.server(status: 502))
14
+ # PersonQuery.execute(id: "1", Failure.throttled) # QueryError, code THROTTLED
15
+ # PersonQuery.execute(id: "1", Failure.stale_schema) # schema_stale? => true
16
16
  #
17
- # For type mismatches, corrupt the wire with a FakeExecutor override:
18
- # FakeExecutor.new(schema:, overrides: { "Person.birthday" => 123 })
17
+ # For type mismatches, corrupt the wire with a FakeClient override:
18
+ # FakeClient.new(schema:, overrides: { "Person.birthday" => 123 })
19
19
  # casting then raises GraphWeaver::TypeError, exactly as a bad server
20
- # payload would. For partial failures, see FakeExecutor's fail_at:.
20
+ # payload would. For partial failures, see FakeClient's fail_at:.
21
21
  module Failure
22
22
  include Kernel # for sorbet
23
23
  module_function
@@ -25,7 +25,7 @@ module GraphWeaver
25
25
  # the request never reaches the server — cause preserved, like the
26
26
  # bundled transports do
27
27
  def transport(message = "simulated network failure", cause: SocketError)
28
- FailureExecutor.new do
28
+ FailureClient.new do
29
29
  raise cause, message
30
30
  rescue cause => e
31
31
  raise GraphWeaver::TransportError, e.message
@@ -34,7 +34,7 @@ module GraphWeaver
34
34
 
35
35
  # the server answered non-2xx
36
36
  def server(status: 500, body: "simulated server error")
37
- FailureExecutor.new { raise GraphWeaver::ServerError.new(status:, body:) }
37
+ FailureClient.new { raise GraphWeaver::ServerError.new(status:, body:) }
38
38
  end
39
39
 
40
40
  # top-level GraphQL errors: strings, or hashes with message/path/
@@ -47,7 +47,7 @@ module GraphWeaver
47
47
  response = { "errors" => normalized }
48
48
  response["data"] = data if data
49
49
  response["extensions"] = JSON.parse(JSON.generate(extensions)) unless extensions.empty?
50
- FailureExecutor.new { response }
50
+ FailureClient.new { response }
51
51
  end
52
52
 
53
53
  def throttled
@@ -60,9 +60,9 @@ module GraphWeaver
60
60
  # the casualty explicitly, or pass schema: to sample a real
61
61
  # type/field (as if the server just dropped it):
62
62
  #
63
- # Failure.stale_schema(type: "Person", field: "name")
64
- # Failure.stale_schema(schema: MySchema) # random real field
65
- # Failure.stale_schema(schema: MySchema, seed: 42) # reproducibly random
63
+ # Failure.stale_schema(type: "Person", field: "name")
64
+ # Failure.stale_schema(schema: MySchema) # random real field
65
+ # Failure.stale_schema(schema: MySchema, seed: 42) # reproducibly random
66
66
  def stale_schema(field: nil, type: nil, schema: nil, seed: nil)
67
67
  if schema && (field.nil? || type.nil?)
68
68
  rng = Random.new(seed || GraphWeaver::Testing.config.seed || Random.new_seed)
@@ -79,7 +79,7 @@ module GraphWeaver
79
79
  end
80
80
 
81
81
  # runs the block per request — raise or return an envelope
82
- class FailureExecutor
82
+ class FailureClient
83
83
  def initialize(&response)
84
84
  @response = response
85
85
  end
@@ -89,20 +89,20 @@ module GraphWeaver
89
89
  end
90
90
  end
91
91
 
92
- # Delegates each call to the next executor in line (the last one
92
+ # Delegates each call to the next client in line (the last one
93
93
  # repeats) — fail N times, then succeed, for retry/backoff testing:
94
94
  #
95
- # SequenceExecutor.new(Failure.transport, Failure.transport, fake)
96
- class SequenceExecutor
97
- def initialize(*executors)
98
- @executors = executors.flatten
95
+ # Sequence.new(Failure.transport, Failure.transport, fake)
96
+ class Sequence
97
+ def initialize(*clients)
98
+ @clients = clients.flatten
99
99
  @calls = 0
100
100
  end
101
101
 
102
102
  def execute(query, variables: {})
103
- executor = @executors[[@calls, @executors.size - 1].min]
103
+ client = @clients[[@calls, @clients.size - 1].min]
104
104
  @calls += 1
105
- executor.execute(query, variables:)
105
+ client.execute(query, variables:)
106
106
  end
107
107
  end
108
108
  end
@@ -4,12 +4,12 @@
4
4
  require "graphql"
5
5
  require "json"
6
6
 
7
- # An executor that fabricates schema-correct responses for whatever query
7
+ # A fake client that fabricates schema-correct responses for whatever query
8
8
  # arrives — the zero-setup way to test code built on generated modules:
9
9
  #
10
- # fake = GraphWeaver::Testing::FakeExecutor.new(schema:)
11
- # result = PersonQuery.execute!(id: "1", executor: fake)
12
- # result.person.name # => a plausible String, typed and castable
10
+ # fake = GraphWeaver::Testing::FakeClient.new(schema:)
11
+ # result = PersonQuery.execute!(id: "1", fake — positionally)
12
+ # result.person.name # => a plausible String, typed and castable
13
13
  #
14
14
  # Values are type-correct by construction (real enum values, valid
15
15
  # __typename members for unions/interfaces, iso8601 for date scalars), so
@@ -22,10 +22,10 @@ require "json"
22
22
  # also the way to simulate a corrupt payload — casting raises
23
23
  # GraphWeaver::TypeError.)
24
24
  #
25
- # FakeExecutor.new(schema:, overrides: {
26
- # "Person.name" => "Daniel",
27
- # "email" => -> { "test@example.com" },
28
- # })
25
+ # FakeClient.new(schema:, overrides: {
26
+ # "Person.name" => "Daniel",
27
+ # "email" => -> { "test@example.com" },
28
+ # })
29
29
  #
30
30
  # Partial failures: fail_at simulates a field-level error with
31
31
  # spec-correct null propagation — the field's error lands in the errors
@@ -33,8 +33,8 @@ require "json"
33
33
  # bubble past non-null positions to the nearest nullable ancestor, just
34
34
  # like a real server:
35
35
  #
36
- # FakeExecutor.new(schema:, fail_at: "person.pets.name")
37
- # FakeExecutor.new(schema:, fail_at: { path: "person.email", message: "hidden", code: "PRIVATE" })
36
+ # FakeClient.new(schema:, fail_at: "person.pets.name")
37
+ # FakeClient.new(schema:, fail_at: { path: "person.email", message: "hidden", code: "PRIVATE" })
38
38
  #
39
39
  # errors: appends verbatim top-level errors alongside the fake data.
40
40
  #
@@ -43,11 +43,11 @@ require "json"
43
43
  # so casting raises GraphWeaver::TypeError. One spec checks the failure
44
44
  # path; every other spec gets working data:
45
45
  #
46
- # FakeExecutor.new(schema:, corrupt: "Person.birthday")
46
+ # FakeClient.new(schema:, corrupt: "Person.birthday")
47
47
  #
48
- # seed: makes a run reproducible (also seeds faker). Per-executor options
48
+ # seed: makes a run reproducible (also seeds faker). Per-instance options
49
49
  # fall back to GraphWeaver::Testing.config.
50
- class GraphWeaver::Testing::FakeExecutor
50
+ class GraphWeaver::Testing::FakeClient
51
51
  include GraphWeaver::Selection
52
52
 
53
53
  # sentinel: a simulated failure bubbling up to the nearest nullable spot
@@ -3,7 +3,7 @@
3
3
 
4
4
  require "date"
5
5
 
6
- # The value engine behind FakeExecutor and Cassette#anonymize!: seeded,
6
+ # The value engine behind FakeClient and Cassette#anonymize!: seeded,
7
7
  # type-correct scalar generation with optional faker-backed semantics
8
8
  # matched on field names — strings (name/email/url/...) and numbers
9
9
  # (age/price/count/latitude/...) alike. Keeps a consistent id mapping so
@@ -47,7 +47,7 @@ class GraphWeaver::Testing::Values
47
47
 
48
48
  if @mode == :faker
49
49
  # rebind per call: several Values instances may interleave (e.g. two
50
- # seeded executors), and faker's rng is global
50
+ # seeded fakes), and faker's rng is global
51
51
  ::Faker::Config.random = @rng
52
52
  case type_name
53
53
  when "String"
@@ -14,29 +14,31 @@ end
14
14
  # Opt-in test tooling: require "graph_weaver/testing" from your spec
15
15
  # helper (never from production code). Configure once, initializer-style:
16
16
  #
17
- # GraphWeaver::Testing.configure do |config|
18
- # config.schema = MySchema # for auto_fake / cassettes
19
- # config.seed = 42 # reproducible fakes
20
- # config.mode = :faker # or :literal; nil = auto
21
- # config.overrides = { "Person.name" => "Daniel" }
22
- # config.list_size = 2..4
23
- # config.null_chance = 0.1 # nullable fields go nil sometimes
24
- # config.cassette_dir = "spec/cassettes"
25
- # end
17
+ # GraphWeaver::Testing.configure do |config|
18
+ # config.schema = MySchema # for auto_fake / cassettes
19
+ # config.seed = 42 # reproducible fakes
20
+ # config.mode = :faker # or :literal; nil = auto
21
+ # config.overrides = { "Person.name" => "Daniel" }
22
+ # config.list_size = 2..4
23
+ # config.null_chance = 0.1 # nullable fields go nil sometimes
24
+ # config.cassette_dir = "spec/cassettes"
25
+ # end
26
26
  #
27
27
  # mode picks how values are fabricated:
28
- # :faker — semantic, field-name matched (requires the faker gem)
29
- # :literal — plain type-derived values ("name-1", seeded numbers)
30
- # nil — auto: :faker when the gem is loaded, else :literal
28
+ # :faker — semantic, field-name matched (requires the faker gem)
29
+ # :literal — plain type-derived values ("name-1", seeded numbers)
30
+ # nil — auto: :faker when the gem is loaded, else :literal
31
31
  #
32
32
  # rspec users: require "graph_weaver/rspec" instead — it hooks the suite
33
- # (seed from rspec, optional auto-faked executor per example).
33
+ # (seed from rspec, optional auto-faked client per example).
34
34
  module GraphWeaver
35
35
  module Testing
36
36
  MODES = [:faker, :literal].freeze
37
37
 
38
38
  class Config
39
- attr_accessor :overrides, :seed, :list_size, :null_chance, :schema, :cassette_dir, :auto_fake
39
+ attr_accessor :overrides, :seed, :list_size, :null_chance, :cassette_dir, :auto_fake,
40
+ :record, :anonymize
41
+ attr_writer :schema
40
42
  attr_reader :mode
41
43
 
42
44
  def initialize
@@ -47,7 +49,21 @@ module GraphWeaver
47
49
  @mode = nil # auto
48
50
  @schema = nil
49
51
  @cassette_dir = "spec/cassettes"
50
- @auto_fake = false
52
+ # on by default — engages only when a schema resolves (below), so
53
+ # requiring graph_weaver/rspec in a conventional app is
54
+ # zero-config; config.auto_fake = false opts out
55
+ @auto_fake = true
56
+ # GRAPHWEAVER_RECORD=1 rspec ... -> Cassette.use re-records
57
+ @record = !ENV["GRAPHWEAVER_RECORD"].to_s.empty?
58
+ # anonymize responses as they're recorded (needs config.schema)
59
+ @anonymize = false
60
+ end
61
+
62
+ # the explicitly configured schema, else the conventional dump
63
+ # (SchemaLoader.locate at GraphWeaver.schema_path) — nil when
64
+ # neither exists, which quietly disables auto_fake
65
+ def schema
66
+ @schema ||= GraphWeaver::SchemaLoader.locate
51
67
  end
52
68
 
53
69
  def mode=(mode)
@@ -85,6 +101,6 @@ module GraphWeaver
85
101
  end
86
102
 
87
103
  require_relative "testing/values"
88
- require_relative "testing/fake_executor"
104
+ require_relative "testing/fake_client"
89
105
  require_relative "testing/failure"
90
106
  require_relative "testing/cassette"
@@ -0,0 +1,56 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "faraday"
5
+
6
+ require_relative "../transport"
7
+
8
+ module GraphWeaver
9
+ class Transport
10
+ # Faraday-backed transport. Opt-in (faraday is not a hard dependency):
11
+ #
12
+ # require "graph_weaver/transport/faraday"
13
+ #
14
+ # # simplest: build a default connection from a url
15
+ # GraphWeaver::Transport::Faraday.new("https://api.example.com/graphql")
16
+ #
17
+ # # customize middleware while building
18
+ # GraphWeaver::Transport::Faraday.new(url) do |conn|
19
+ # conn.request :authorization, "Bearer", -> { Tokens.fetch }
20
+ # conn.response :logger
21
+ # end
22
+ #
23
+ # # or bring a fully configured connection
24
+ # GraphWeaver::Transport::Faraday.new(Faraday.new(url:) { |conn| ... })
25
+ class Faraday < Transport
26
+ # Faraday's network-level failures — added to the shared, extensible
27
+ # transport-error set.
28
+ GraphWeaver.register_transport_error(
29
+ ::Faraday::ConnectionFailed, ::Faraday::TimeoutError, ::Faraday::SSLError
30
+ )
31
+
32
+ def initialize(url_or_connection, headers: {}, &block)
33
+ @connection = case url_or_connection
34
+ when ::Faraday::Connection
35
+ url_or_connection
36
+ else
37
+ # Faraday appends the default adapter when the block doesn't set one
38
+ ::Faraday.new(url: url_or_connection, headers:, &block)
39
+ end
40
+ @url = @connection.url_prefix.to_s
41
+ end
42
+
43
+ private
44
+
45
+ sig { override.params(body: String).returns([Integer, T.untyped]) }
46
+ def post(body)
47
+ response = @connection.post do |request|
48
+ request.headers["Content-Type"] = "application/json"
49
+ request.body = body
50
+ end
51
+
52
+ [response.status, response.body]
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,87 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+
8
+ require_relative "../transport"
9
+
10
+ module GraphWeaver
11
+ class Transport
12
+ # Minimal net/http transport — zero dependencies, loaded by default:
13
+ #
14
+ # GraphWeaver::Transport::HTTP.new(url, headers: { ... }, read_timeout: 10)
15
+ #
16
+ # Timeouts surface as TransportError (retriable). The connection is
17
+ # persistent (keep-alive), serialized behind a mutex — one socket per
18
+ # transport, dropped on any failure so the next call starts fresh.
19
+ # For real connection pooling and a middleware ecosystem, use
20
+ # Transport::Faraday.
21
+ class HTTP < Transport
22
+ # net/http's own network-level failures (Errno/SocketError/IOError
23
+ # are already seeded) — added to the shared, extensible
24
+ # transport-error set.
25
+ GraphWeaver.register_transport_error(Timeout::Error, OpenSSL::SSL::SSLError)
26
+
27
+ def initialize(url, headers: {}, open_timeout: 10, read_timeout: 30, keep_alive_timeout: 2)
28
+ @url = url
29
+ @uri = URI(url)
30
+ @headers = headers
31
+ @open_timeout = open_timeout
32
+ @read_timeout = read_timeout
33
+ @keep_alive_timeout = keep_alive_timeout
34
+ @mutex = Mutex.new
35
+ @http = T.let(nil, T.nilable(Net::HTTP))
36
+ end
37
+
38
+ private
39
+
40
+ sig { override.params(body: String).returns([Integer, T.untyped]) }
41
+ def post(body)
42
+ request = Net::HTTP::Post.new(@uri, { "Content-Type" => "application/json" }.merge(@headers))
43
+ request.body = body
44
+
45
+ response = @mutex.synchronize do
46
+ begin
47
+ connection.request(request)
48
+ rescue => e
49
+ # socket state is unknown — drop it so the next call starts
50
+ # fresh (retry policy belongs to Retry, not here)
51
+ disconnect
52
+ raise e
53
+ end
54
+ end
55
+
56
+ [response.code.to_i, response.body]
57
+ end
58
+
59
+ # The persistent connection. net/http proactively reconnects when
60
+ # idle past keep_alive_timeout, so a server-closed keep-alive
61
+ # socket doesn't produce spurious failures.
62
+ def connection
63
+ @http ||= begin
64
+ GraphWeaver.log(:debug) { "connecting to #{@uri.hostname}:#{@uri.port}" }
65
+ Net::HTTP.start(
66
+ @uri.hostname, @uri.port,
67
+ use_ssl: @uri.scheme == "https",
68
+ open_timeout: @open_timeout, read_timeout: @read_timeout,
69
+ keep_alive_timeout: @keep_alive_timeout,
70
+ )
71
+ end
72
+ end
73
+
74
+ def disconnect
75
+ http = @http
76
+ return unless http
77
+
78
+ GraphWeaver.log(:debug) { "dropping connection to #{@uri.hostname}:#{@uri.port}" }
79
+ http.finish if http.started?
80
+ rescue IOError
81
+ # already closed
82
+ ensure
83
+ @http = nil
84
+ end
85
+ end
86
+ end
87
+ end