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.
- checksums.yaml +4 -4
- data/.yardopts +6 -0
- data/CHANGELOG.md +213 -0
- data/Gemfile.lock +32 -2
- data/Makefile +7 -2
- data/NOTES.md +1 -1
- data/PLAN.md +34 -1
- data/README.md +56 -41
- data/docs/cassettes.md +76 -0
- data/docs/errors.md +37 -9
- data/docs/generated_modules.md +139 -29
- data/docs/getting_started.md +134 -0
- data/docs/logging.md +33 -0
- data/docs/real_world.md +27 -20
- data/docs/scalars.md +122 -8
- data/docs/testing.md +44 -34
- data/docs/transports.md +124 -0
- data/graph_weaver.gemspec +7 -1
- data/lib/graph_weaver/client.rb +200 -0
- data/lib/graph_weaver/codegen/emit.rb +88 -39
- data/lib/graph_weaver/codegen/enum_type.rb +149 -0
- data/lib/graph_weaver/codegen/nodes.rb +109 -9
- data/lib/graph_weaver/codegen/scalar_type.rb +53 -35
- data/lib/graph_weaver/codegen.rb +301 -67
- data/lib/graph_weaver/errors.rb +37 -25
- data/lib/graph_weaver/hints.rb +63 -0
- data/lib/graph_weaver/inflect.rb +2 -1
- data/lib/graph_weaver/logging.rb +41 -0
- data/lib/graph_weaver/railtie.rb +29 -0
- data/lib/graph_weaver/retry.rb +97 -0
- data/lib/graph_weaver/rspec.rb +19 -14
- data/lib/graph_weaver/schema_loader.rb +156 -20
- data/lib/graph_weaver/selection.rb +3 -3
- data/lib/graph_weaver/tasks.rb +71 -0
- data/lib/graph_weaver/testing/cassette.rb +42 -21
- data/lib/graph_weaver/testing/failure.rb +22 -22
- data/lib/graph_weaver/testing/{fake_executor.rb → fake_client.rb} +13 -13
- data/lib/graph_weaver/testing/values.rb +2 -2
- data/lib/graph_weaver/testing.rb +32 -16
- data/lib/graph_weaver/transport/faraday.rb +56 -0
- data/lib/graph_weaver/transport/http.rb +87 -0
- data/lib/graph_weaver/transport.rb +120 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +208 -38
- metadata +73 -5
- data/lib/graph_weaver/faraday_executor.rb +0 -61
- data/lib/graph_weaver/http_executor.rb +0 -44
- 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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
(
|
|
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
|
-
#
|
|
27
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
48
|
-
#
|
|
49
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
elsif
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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 = @
|
|
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
|
|
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
|
|
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
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
|
18
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
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
|
|
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
|
|
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
|
-
#
|
|
96
|
-
class
|
|
97
|
-
def initialize(*
|
|
98
|
-
@
|
|
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
|
-
|
|
103
|
+
client = @clients[[@calls, @clients.size - 1].min]
|
|
104
104
|
@calls += 1
|
|
105
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
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
|
-
#
|
|
37
|
-
#
|
|
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
|
-
#
|
|
46
|
+
# FakeClient.new(schema:, corrupt: "Person.birthday")
|
|
47
47
|
#
|
|
48
|
-
# seed: makes a run reproducible (also seeds faker). Per-
|
|
48
|
+
# seed: makes a run reproducible (also seeds faker). Per-instance options
|
|
49
49
|
# fall back to GraphWeaver::Testing.config.
|
|
50
|
-
class GraphWeaver::Testing::
|
|
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
|
|
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
|
|
50
|
+
# seeded fakes), and faker's rng is global
|
|
51
51
|
::Faker::Config.random = @rng
|
|
52
52
|
case type_name
|
|
53
53
|
when "String"
|
data/lib/graph_weaver/testing.rb
CHANGED
|
@@ -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
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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
|
|
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, :
|
|
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
|
-
|
|
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/
|
|
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
|