graph_weaver 0.0.1 → 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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +279 -0
  4. data/Gemfile.lock +75 -17
  5. data/Makefile +11 -1
  6. data/NOTES.md +1 -1
  7. data/PLAN.md +85 -22
  8. data/README.md +86 -26
  9. data/docs/cassettes.md +76 -0
  10. data/docs/errors.md +134 -0
  11. data/docs/generated_modules.md +229 -0
  12. data/docs/getting_started.md +134 -0
  13. data/docs/logging.md +33 -0
  14. data/docs/real_world.md +53 -0
  15. data/docs/scalars.md +183 -0
  16. data/docs/testing.md +103 -0
  17. data/docs/transports.md +124 -0
  18. data/graph_weaver.gemspec +10 -1
  19. data/lib/graph_weaver/client.rb +200 -0
  20. data/lib/graph_weaver/codegen/emit.rb +286 -0
  21. data/lib/graph_weaver/codegen/enum_type.rb +149 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +356 -0
  23. data/lib/graph_weaver/codegen/scalar_type.rb +256 -0
  24. data/lib/graph_weaver/codegen.rb +396 -401
  25. data/lib/graph_weaver/errors.rb +322 -0
  26. data/lib/graph_weaver/hints.rb +63 -0
  27. data/lib/graph_weaver/inflect.rb +19 -0
  28. data/lib/graph_weaver/logging.rb +41 -0
  29. data/lib/graph_weaver/railtie.rb +29 -0
  30. data/lib/graph_weaver/response.rb +55 -0
  31. data/lib/graph_weaver/retry.rb +97 -0
  32. data/lib/graph_weaver/rspec.rb +59 -0
  33. data/lib/graph_weaver/schema_loader.rb +208 -8
  34. data/lib/graph_weaver/selection.rb +68 -0
  35. data/lib/graph_weaver/tasks.rb +71 -0
  36. data/lib/graph_weaver/testing/cassette.rb +224 -0
  37. data/lib/graph_weaver/testing/failure.rb +109 -0
  38. data/lib/graph_weaver/testing/fake_client.rb +228 -0
  39. data/lib/graph_weaver/testing/values.rb +98 -0
  40. data/lib/graph_weaver/testing.rb +106 -0
  41. data/lib/graph_weaver/transport/faraday.rb +56 -0
  42. data/lib/graph_weaver/transport/http.rb +87 -0
  43. data/lib/graph_weaver/transport.rb +120 -0
  44. data/lib/graph_weaver/version.rb +1 -1
  45. data/lib/graph_weaver.rb +268 -4
  46. metadata +132 -2
  47. data/lib/graph_weaver/http_executor.rb +0 -31
@@ -0,0 +1,109 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ module GraphWeaver
7
+ module Testing
8
+ # Canned failure clients — each produces exactly what the real
9
+ # transports produce, so error-handling paths are testable without a
10
+ # server that misbehaves on cue:
11
+ #
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
+ #
17
+ # For type mismatches, corrupt the wire with a FakeClient override:
18
+ # FakeClient.new(schema:, overrides: { "Person.birthday" => 123 })
19
+ # casting then raises GraphWeaver::TypeError, exactly as a bad server
20
+ # payload would. For partial failures, see FakeClient's fail_at:.
21
+ module Failure
22
+ include Kernel # for sorbet
23
+ module_function
24
+
25
+ # the request never reaches the server — cause preserved, like the
26
+ # bundled transports do
27
+ def transport(message = "simulated network failure", cause: SocketError)
28
+ FailureClient.new do
29
+ raise cause, message
30
+ rescue cause => e
31
+ raise GraphWeaver::TransportError, e.message
32
+ end
33
+ end
34
+
35
+ # the server answered non-2xx
36
+ def server(status: 500, body: "simulated server error")
37
+ FailureClient.new { raise GraphWeaver::ServerError.new(status:, body:) }
38
+ end
39
+
40
+ # top-level GraphQL errors: strings, or hashes with message/path/
41
+ # extensions; data: rides along for partial-failure envelopes
42
+ def graphql(*errors, data: nil, extensions: {})
43
+ normalized = errors.flatten.map do |error|
44
+ error.is_a?(String) ? { "message" => error } : JSON.parse(JSON.generate(error))
45
+ end
46
+
47
+ response = { "errors" => normalized }
48
+ response["data"] = data if data
49
+ response["extensions"] = JSON.parse(JSON.generate(extensions)) unless extensions.empty?
50
+ FailureClient.new { response }
51
+ end
52
+
53
+ def throttled
54
+ # array-wrapped so the hash can't parse as kwargs
55
+ graphql([{ message: "rate limited", extensions: { code: "THROTTLED" } }])
56
+ end
57
+
58
+ # A validation-shaped rejection — trips schema_stale? and its
59
+ # regenerate hint, as if the schema changed under the module. Name
60
+ # the casualty explicitly, or pass schema: to sample a real
61
+ # type/field (as if the server just dropped it):
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
66
+ def stale_schema(field: nil, type: nil, schema: nil, seed: nil)
67
+ if schema && (field.nil? || type.nil?)
68
+ rng = Random.new(seed || GraphWeaver::Testing.config.seed || Random.new_seed)
69
+ candidates = schema.types.values.select do |candidate|
70
+ candidate.kind.name == "OBJECT" && !candidate.graphql_name.start_with?("__")
71
+ end
72
+ chosen = candidates.sort_by(&:graphql_name).sample(random: rng)
73
+ type ||= chosen.graphql_name
74
+ field ||= chosen.fields.keys.sort.sample(random: rng)
75
+ end
76
+
77
+ graphql("Field '#{field || "someField"}' doesn't exist on type '#{type || "SomeType"}'")
78
+ end
79
+ end
80
+
81
+ # runs the block per request — raise or return an envelope
82
+ class FailureClient
83
+ def initialize(&response)
84
+ @response = response
85
+ end
86
+
87
+ def execute(_query, variables: {})
88
+ @response.call
89
+ end
90
+ end
91
+
92
+ # Delegates each call to the next client in line (the last one
93
+ # repeats) — fail N times, then succeed, for retry/backoff testing:
94
+ #
95
+ # Sequence.new(Failure.transport, Failure.transport, fake)
96
+ class Sequence
97
+ def initialize(*clients)
98
+ @clients = clients.flatten
99
+ @calls = 0
100
+ end
101
+
102
+ def execute(query, variables: {})
103
+ client = @clients[[@calls, @clients.size - 1].min]
104
+ @calls += 1
105
+ client.execute(query, variables:)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,228 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "graphql"
5
+ require "json"
6
+
7
+ # A fake client that fabricates schema-correct responses for whatever query
8
+ # arrives — the zero-setup way to test code built on generated modules:
9
+ #
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
+ #
14
+ # Values are type-correct by construction (real enum values, valid
15
+ # __typename members for unions/interfaces, iso8601 for date scalars), so
16
+ # every fake response casts cleanly through the generated structs. See
17
+ # Values for value fabrication (mode: :faker / :literal).
18
+ #
19
+ # overrides: pin fields by GraphQL name — schema vocabulary, so keys
20
+ # survive query refactors. "Type.field" beats "field"; values are
21
+ # literals or zero-arg procs. (An override with a wrong-typed value is
22
+ # also the way to simulate a corrupt payload — casting raises
23
+ # GraphWeaver::TypeError.)
24
+ #
25
+ # FakeClient.new(schema:, overrides: {
26
+ # "Person.name" => "Daniel",
27
+ # "email" => -> { "test@example.com" },
28
+ # })
29
+ #
30
+ # Partial failures: fail_at simulates a field-level error with
31
+ # spec-correct null propagation — the field's error lands in the errors
32
+ # array (with its concrete path), the field becomes null, and nulls
33
+ # bubble past non-null positions to the nearest nullable ancestor, just
34
+ # like a real server:
35
+ #
36
+ # FakeClient.new(schema:, fail_at: "person.pets.name")
37
+ # FakeClient.new(schema:, fail_at: { path: "person.email", message: "hidden", code: "PRIVATE" })
38
+ #
39
+ # errors: appends verbatim top-level errors alongside the fake data.
40
+ #
41
+ # Type mismatches: corrupt: names fields ("Type.field") that should
42
+ # arrive wire-corrupted — a wrong-typed value derived from the schema,
43
+ # so casting raises GraphWeaver::TypeError. One spec checks the failure
44
+ # path; every other spec gets working data:
45
+ #
46
+ # FakeClient.new(schema:, corrupt: "Person.birthday")
47
+ #
48
+ # seed: makes a run reproducible (also seeds faker). Per-instance options
49
+ # fall back to GraphWeaver::Testing.config.
50
+ class GraphWeaver::Testing::FakeClient
51
+ include GraphWeaver::Selection
52
+
53
+ # sentinel: a simulated failure bubbling up to the nearest nullable spot
54
+ NULL_BUBBLE = Object.new.freeze
55
+
56
+ def initialize(schema:, overrides: {}, seed: nil, mode: nil, list_size: nil, null_chance: nil,
57
+ errors: nil, fail_at: nil, corrupt: nil)
58
+ config = GraphWeaver::Testing.config
59
+ @schema = schema
60
+ @overrides = config.overrides.merge(overrides)
61
+ @values = GraphWeaver::Testing::Values.new(seed:, mode:)
62
+ @list_size = list_size || config.list_size
63
+ @null_chance = null_chance || config.null_chance
64
+ # NOT Array(): it would explode a bare Hash into key/value pairs
65
+ @extra_errors = wrap(errors).map { |error| normalize_error(error) }
66
+ @fail_at = wrap(fail_at).map { |spec| normalize_fail_spec(spec) }
67
+ @corrupt = wrap(corrupt)
68
+ end
69
+
70
+ def execute(query, variables: {})
71
+ operation = load_operation(query)
72
+ root_type = operation_root_type(operation)
73
+
74
+ @path = []
75
+ @failures = []
76
+ data = object_value(root_type, operation.selections)
77
+ data = nil if data.equal?(NULL_BUBBLE) # total propagation, like a real server
78
+
79
+ response = { "data" => data }
80
+ errors = @failures + @extra_errors
81
+ response["errors"] = errors unless errors.empty?
82
+ response
83
+ end
84
+
85
+ private
86
+
87
+ def rng = @values.rng
88
+
89
+ def wrap(value)
90
+ case value
91
+ when nil then []
92
+ when Array then value
93
+ else [value]
94
+ end
95
+ end
96
+
97
+ def normalize_error(error)
98
+ error.is_a?(String) ? { "message" => error } : JSON.parse(JSON.generate(error))
99
+ end
100
+
101
+ def normalize_fail_spec(spec)
102
+ spec.is_a?(String) ? { "path" => spec } : JSON.parse(JSON.generate(spec))
103
+ end
104
+
105
+ def object_value(type, selections)
106
+ result = {}
107
+ each_field(type, selections) do |key, node|
108
+ @path.push(key)
109
+ value = node.name == "__typename" ? type.graphql_name : field_value(type, node)
110
+ @path.pop
111
+
112
+ if value.equal?(NULL_BUBBLE)
113
+ # bubble past non-null fields to the nearest nullable ancestor
114
+ return NULL_BUBBLE if non_null_field?(type, node)
115
+
116
+ value = nil
117
+ end
118
+ result[key] = value
119
+ end
120
+
121
+ result
122
+ end
123
+
124
+ def non_null_field?(type, node)
125
+ return false if node.name == "__typename"
126
+
127
+ @schema.get_field(type.graphql_name, node.name).type.kind.name == "NON_NULL"
128
+ end
129
+
130
+ def field_value(parent_type, node)
131
+ if (spec = matching_failure)
132
+ @failures << {
133
+ "message" => spec["message"] || "simulated failure",
134
+ "path" => @path.dup,
135
+ }.merge(spec["code"] ? { "extensions" => { "code" => spec["code"] } } : {})
136
+ spec["triggered"] = true
137
+
138
+ return NULL_BUBBLE
139
+ end
140
+
141
+ override = @overrides.fetch("#{parent_type.graphql_name}.#{node.name}") do
142
+ @overrides[node.name]
143
+ end
144
+ return override.is_a?(Proc) ? override.call : override unless override.nil?
145
+
146
+ field_type = @schema.get_field(parent_type.graphql_name, node.name).type
147
+ if @corrupt.include?("#{parent_type.graphql_name}.#{node.name}")
148
+ return corrupt_value(field_type)
149
+ end
150
+
151
+ type_value(field_type, node)
152
+ end
153
+
154
+ # a value casting can't accept, derived from the field's own type — and
155
+ # wrapped per list layer so the corruption lands on the element cast
156
+ def corrupt_value(type)
157
+ case type.kind.name
158
+ when "NON_NULL" then corrupt_value(type.of_type)
159
+ when "LIST" then [corrupt_value(type.of_type)]
160
+ when "SCALAR"
161
+ case type.graphql_name
162
+ when "Int", "Float" then "not-a-number"
163
+ when "Boolean" then "not-a-boolean"
164
+ else 123 # breaks String/ID props and every string-wire custom scalar
165
+ end
166
+ when "ENUM" then "__NOT_A_REAL_VALUE__"
167
+ else [] # objects/unions: an Array fails Hash-shaped casting loudly
168
+ end
169
+ end
170
+
171
+ # first untriggered fail_at spec whose field chain (indices stripped)
172
+ # matches where we are
173
+ def matching_failure
174
+ chain = @path.reject { |segment| segment.is_a?(Integer) }.join(".")
175
+ @fail_at.find { |spec| !spec["triggered"] && spec["path"] == chain }
176
+ end
177
+
178
+ # honor pagination-ish arg semantics: first/last/limit with a literal
179
+ # int caps the fabricated list length
180
+ def list_length(node)
181
+ argument = node.arguments.find { |arg| %w[first last limit].include?(arg.name) }
182
+ return argument.value if argument && argument.value.is_a?(Integer)
183
+
184
+ rng.rand(@list_size)
185
+ end
186
+
187
+ def type_value(type, node, non_null: false)
188
+ case type.kind.name
189
+ when "NON_NULL"
190
+ type_value(type.of_type, node, non_null: true)
191
+ when "LIST"
192
+ elements = Array.new(list_length(node)) do |index|
193
+ @path.push(index)
194
+ element = type_value(type.of_type, node)
195
+ @path.pop
196
+ element
197
+ end
198
+
199
+ if elements.any? { |element| element.equal?(NULL_BUBBLE) }
200
+ # non-null elements bubble the whole list; nullable ones go nil
201
+ return NULL_BUBBLE if type.of_type.kind.name == "NON_NULL"
202
+
203
+ elements.map! { |element| element.equal?(NULL_BUBBLE) ? nil : element }
204
+ end
205
+ elements
206
+ else
207
+ return if !non_null && rng.rand < @null_chance
208
+
209
+ core_value(type, node)
210
+ end
211
+ end
212
+
213
+ def core_value(type, node)
214
+ case type.kind.name
215
+ when "SCALAR"
216
+ @values.scalar(type.graphql_name, node.name)
217
+ when "ENUM"
218
+ type.values.keys.sort.sample(random: rng)
219
+ when "OBJECT"
220
+ object_value(type, node.selections)
221
+ when "UNION", "INTERFACE"
222
+ member = @schema.possible_types(type).sort_by(&:graphql_name).sample(random: rng)
223
+ object_value(member, node.selections)
224
+ else
225
+ raise NotImplementedError, "cannot fake kind: #{type.kind.name}"
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,98 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "date"
5
+
6
+ # The value engine behind FakeClient and Cassette#anonymize!: seeded,
7
+ # type-correct scalar generation with optional faker-backed semantics
8
+ # matched on field names — strings (name/email/url/...) and numbers
9
+ # (age/price/count/latitude/...) alike. Keeps a consistent id mapping so
10
+ # the same original id always anonymizes to the same fake id.
11
+ class GraphWeaver::Testing::Values
12
+ include GraphWeaver::Inflect
13
+
14
+ STRING_SEMANTICS = {
15
+ /email/ => -> { ::Faker::Internet.email },
16
+ /(^|_)first_name$/ => -> { ::Faker::Name.first_name },
17
+ /(^|_)last_name$/ => -> { ::Faker::Name.last_name },
18
+ /(^|_)(full_)?name$/ => -> { ::Faker::Name.name },
19
+ /(^|_)(url|website|link)$/ => -> { ::Faker::Internet.url },
20
+ /phone/ => -> { ::Faker::PhoneNumber.phone_number },
21
+ /(^|_)address$/ => -> { ::Faker::Address.full_address },
22
+ /(^|_)(city)$/ => -> { ::Faker::Address.city },
23
+ /(^|_)(title|description)$/ => -> { ::Faker::Lorem.sentence(word_count: 3) },
24
+ }.freeze
25
+
26
+ NUMBER_SEMANTICS = {
27
+ /(^|_)age$/ => ->(rng) { rng.rand(1..99) },
28
+ /(^|_)(price|amount|cost|total)(_cents)?$/ => ->(rng) { (rng.rand(1.0..10_000.0) * 100).round / 100.0 },
29
+ /(^|_)(count|quantity|size)$/ => ->(rng) { rng.rand(0..100) },
30
+ /latitude/ => ->(rng) { rng.rand(-90.0..90.0).round(6) },
31
+ /longitude/ => ->(rng) { rng.rand(-180.0..180.0).round(6) },
32
+ /(^|_)year$/ => ->(rng) { rng.rand(1970..2030) },
33
+ }.freeze
34
+
35
+ attr_reader :rng
36
+
37
+ def initialize(seed: nil, mode: nil)
38
+ config = GraphWeaver::Testing.config
39
+ @rng = Random.new(seed || config.seed || Random.new_seed)
40
+ @mode = resolve_mode(mode || config.mode)
41
+ @sequence = 0
42
+ @id_map = {}
43
+ end
44
+
45
+ def scalar(type_name, field_name)
46
+ prop = underscore(field_name)
47
+
48
+ if @mode == :faker
49
+ # rebind per call: several Values instances may interleave (e.g. two
50
+ # seeded fakes), and faker's rng is global
51
+ ::Faker::Config.random = @rng
52
+ case type_name
53
+ when "String"
54
+ STRING_SEMANTICS.each { |pattern, faker| return faker.call if pattern.match?(prop) }
55
+ when "Int", "Float"
56
+ NUMBER_SEMANTICS.each do |pattern, gen|
57
+ next unless pattern.match?(prop)
58
+
59
+ value = gen.call(@rng)
60
+ return type_name == "Int" ? value.to_i : value.to_f
61
+ end
62
+ end
63
+ end
64
+
65
+ case type_name
66
+ when "ID" then (@sequence += 1).to_s
67
+ when "String" then "#{field_name}-#{@sequence += 1}"
68
+ when "Int" then @rng.rand(0..1_000)
69
+ when "Float" then @rng.rand(0.0..1_000.0).round(2)
70
+ when "Boolean" then [true, false].sample(random: @rng)
71
+ when "Date" then (Date.new(2020, 1, 1) + @rng.rand(0..2_000)).iso8601
72
+ when "DateTime", "Time", "ISO8601DateTime" then Time.at(1_600_000_000 + @rng.rand(0..100_000_000)).utc.iso8601
73
+ else "#{type_name}-#{@sequence += 1}" # unknown custom scalar: override it
74
+ end
75
+ end
76
+
77
+ # same original id => same fake id, so relationships survive anonymization
78
+ def mapped_id(original)
79
+ @id_map[original] ||= (@sequence += 1).to_s
80
+ end
81
+
82
+ private
83
+
84
+ # :faker is an explicit ask — fail loudly when the gem is missing; auto
85
+ # (nil) quietly falls back to :literal
86
+ def resolve_mode(mode)
87
+ case mode
88
+ when :faker
89
+ raise ArgumentError, "mode: :faker requires the faker gem (add it to your Gemfile's test group)" unless defined?(::Faker)
90
+
91
+ :faker
92
+ when :literal then :literal
93
+ when nil then defined?(::Faker) ? :faker : :literal
94
+ else
95
+ raise ArgumentError, "mode: must be one of #{GraphWeaver::Testing::MODES.inspect} (or nil for auto), got #{mode.inspect}"
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,106 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../graph_weaver"
5
+
6
+ # faker is optional — semantic values (name/email/age/price/...) when
7
+ # present, type-based values when not
8
+ begin
9
+ require "faker"
10
+ rescue LoadError
11
+ # fall back to type-based generation
12
+ end
13
+
14
+ # Opt-in test tooling: require "graph_weaver/testing" from your spec
15
+ # helper (never from production code). Configure once, initializer-style:
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
26
+ #
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
31
+ #
32
+ # rspec users: require "graph_weaver/rspec" instead — it hooks the suite
33
+ # (seed from rspec, optional auto-faked client per example).
34
+ module GraphWeaver
35
+ module Testing
36
+ MODES = [:faker, :literal].freeze
37
+
38
+ class Config
39
+ attr_accessor :overrides, :seed, :list_size, :null_chance, :cassette_dir, :auto_fake,
40
+ :record, :anonymize
41
+ attr_writer :schema
42
+ attr_reader :mode
43
+
44
+ def initialize
45
+ @overrides = {}
46
+ @seed = nil
47
+ @list_size = 1..3
48
+ @null_chance = 0.0
49
+ @mode = nil # auto
50
+ @schema = nil
51
+ @cassette_dir = "spec/cassettes"
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
67
+ end
68
+
69
+ def mode=(mode)
70
+ unless mode.nil? || MODES.include?(mode)
71
+ raise ArgumentError, "mode: must be one of #{MODES.inspect} (or nil for auto), got #{mode.inspect}"
72
+ end
73
+
74
+ @mode = mode
75
+ end
76
+ end
77
+
78
+ class << self
79
+ def config
80
+ @config ||= Config.new
81
+ end
82
+
83
+ def configure
84
+ yield config
85
+ end
86
+
87
+ # back to defaults — between tests, or to undo an experiment
88
+ def reset!
89
+ @config = nil
90
+ end
91
+
92
+ # resolve a cassette name ("github") against cassette_dir; paths
93
+ # with separators or extensions pass through
94
+ def cassette_path(name)
95
+ return name if name.include?("/") || name.end_with?(".yml", ".yaml")
96
+
97
+ File.join(config.cassette_dir, "#{name}.yml")
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ require_relative "testing/values"
104
+ require_relative "testing/fake_client"
105
+ require_relative "testing/failure"
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