vcr_stripe_webhook 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3a8235b3c64f4d6c484b72c9338943983f32edbef036d4490099792ebb5613ac
4
+ data.tar.gz: 6c772cedb526c96fd06a4eab52be8892d5a1884a33bddf9455537e998592eca4
5
+ SHA512:
6
+ metadata.gz: ea08bd8d64a7c372365dc7a8b42903f82acda35a195b8e1df31025d68dedb4a7929883c723b0e38e26ccaf5cd32b25efe6fff450b4934a7344bb249387074ec2
7
+ data.tar.gz: 8cce67fa17d558e942a744c29f9201af74c6d3584688229205ecd6967284f9267d540fd22ef078036b5f3f6d9d57f3987b96e136e8e7b2b26c8ef94a655268d2
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,28 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.0
3
+
4
+ Style/StringLiterals:
5
+ Enabled: true
6
+ EnforcedStyle: double_quotes
7
+
8
+ Style/StringLiteralsInInterpolation:
9
+ Enabled: true
10
+ EnforcedStyle: double_quotes
11
+
12
+ Style/Documentation:
13
+ Enabled: false
14
+
15
+ Layout/LineLength:
16
+ Max: 120
17
+
18
+ Metrics/AbcSize:
19
+ Enabled: false
20
+
21
+ Metrics/MethodLength:
22
+ Enabled: false
23
+
24
+ Metrics/CyclomaticComplexity:
25
+ Enabled: false
26
+
27
+ Metrics/BlockLength:
28
+ Enabled: false
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.0.6
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.3.0] - 2024-12-01
4
+
5
+ - Update gem dependencies
6
+ - Fix typo
7
+
8
+ ## [0.2.0] - 2023-10-16
9
+
10
+ - Add new methods
11
+ - `VcrStripeWebhook.receive_webhook_event`
12
+ - `VcrStripeWebhook.receive_webhook_events`
13
+
14
+ ### Breaking changes
15
+
16
+ - Remove `VcrStripeWebhook.receive_webhook`
17
+
18
+ ## [0.1.0] - 2023-10-05
19
+
20
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in vcr_stripe_webhook.gemspec
6
+ gemspec
7
+
8
+ gem "rake"
9
+ gem "rspec"
10
+ gem "rubocop"
data/Gemfile.lock ADDED
@@ -0,0 +1,83 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ vcr_stripe_webhook (0.3.0)
5
+ stripe (>= 9.0)
6
+ vcr (~> 6.0)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ addressable (2.8.7)
12
+ public_suffix (>= 2.0.2, < 7.0)
13
+ ast (2.4.2)
14
+ base64 (0.2.0)
15
+ bigdecimal (3.1.8)
16
+ crack (1.0.0)
17
+ bigdecimal
18
+ rexml
19
+ diff-lcs (1.5.1)
20
+ dotenv (3.1.4)
21
+ hashdiff (1.1.2)
22
+ json (2.8.2)
23
+ language_server-protocol (3.17.0.3)
24
+ parallel (1.26.3)
25
+ parser (3.3.6.0)
26
+ ast (~> 2.4.1)
27
+ racc
28
+ public_suffix (6.0.1)
29
+ racc (1.8.1)
30
+ rainbow (3.1.1)
31
+ rake (13.2.1)
32
+ regexp_parser (2.9.3)
33
+ rexml (3.3.9)
34
+ rspec (3.13.0)
35
+ rspec-core (~> 3.13.0)
36
+ rspec-expectations (~> 3.13.0)
37
+ rspec-mocks (~> 3.13.0)
38
+ rspec-core (3.13.2)
39
+ rspec-support (~> 3.13.0)
40
+ rspec-expectations (3.13.3)
41
+ diff-lcs (>= 1.2.0, < 2.0)
42
+ rspec-support (~> 3.13.0)
43
+ rspec-mocks (3.13.2)
44
+ diff-lcs (>= 1.2.0, < 2.0)
45
+ rspec-support (~> 3.13.0)
46
+ rspec-support (3.13.1)
47
+ rubocop (1.69.0)
48
+ json (~> 2.3)
49
+ language_server-protocol (>= 3.17.0)
50
+ parallel (~> 1.10)
51
+ parser (>= 3.3.0.2)
52
+ rainbow (>= 2.2.2, < 4.0)
53
+ regexp_parser (>= 2.4, < 3.0)
54
+ rubocop-ast (>= 1.36.1, < 2.0)
55
+ ruby-progressbar (~> 1.7)
56
+ unicode-display_width (>= 2.4.0, < 4.0)
57
+ rubocop-ast (1.36.2)
58
+ parser (>= 3.3.1.0)
59
+ ruby-progressbar (1.13.0)
60
+ stripe (13.2.0)
61
+ unicode-display_width (3.1.2)
62
+ unicode-emoji (~> 4.0, >= 4.0.4)
63
+ unicode-emoji (4.0.4)
64
+ vcr (6.3.1)
65
+ base64
66
+ webmock (3.24.0)
67
+ addressable (>= 2.8.0)
68
+ crack (>= 0.3.2)
69
+ hashdiff (>= 0.4.0, < 2.0.0)
70
+
71
+ PLATFORMS
72
+ x86_64-linux
73
+
74
+ DEPENDENCIES
75
+ dotenv (~> 3.0)
76
+ rake
77
+ rspec
78
+ rubocop
79
+ vcr_stripe_webhook!
80
+ webmock (~> 3.0)
81
+
82
+ BUNDLED WITH
83
+ 2.4.20
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Shunichi Ikegami
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # VcrStripeWebhook
2
+
3
+ Record and replay Stripe webhooks during integration test.
4
+
5
+ ## Requirements
6
+
7
+ - [vcr gem](https://github.com/vcr/vcr)
8
+ - [Stripe CLI](https://stripe.com/docs/stripe-cli)
9
+
10
+ ## Installation
11
+
12
+ Add this gem to Gemfile.
13
+
14
+ ```
15
+ gem 'vcr_stripe_webhook', github: 'shunichi/vcr_stripe_webhook'
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```ruby
21
+ # Set Stripe API key for test mode
22
+ Stripe.api_key = 'sk_test_...'
23
+ ```
24
+
25
+ Use `VcrStripeWebhook.use_cassette` instead of `VCR.use_cassette`.
26
+
27
+ ```ruby
28
+ # This method wraps VCR.use_cassette
29
+ VcrStripeWebhook.use_cassette('create_customer') do |vcr_cassette|
30
+ stripe_customer = nil
31
+
32
+ # Get webhook payload from Stripe server or current cassette
33
+ webhook_event = VcrStripeWebhook.receive_webhook_event('customer.created') do
34
+ customer_params = {
35
+ email: 'test-user@example.com',
36
+ name: 'test-user',
37
+ }
38
+ stripe_customer = Stripe::Customer.create(customer_params)
39
+ end
40
+
41
+ # Call your webhook manually
42
+ post your_stripe_webhook_path, params: webhook_event.as_json,
43
+ headers: { 'Content-Type: application/json' }
44
+
45
+ # Insert assertion here
46
+ ensure
47
+ stripe_customer&.delete
48
+ end
49
+ ```
50
+
51
+ On the first run, this code records HTTP requests/responses with VCR gem and additionally records received webhooks with vcr_stripe_webhook gem.
52
+ On the second run or later, it replays the recorded requests, responses and webhooks.
53
+
54
+ ## Development
55
+
56
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
57
+
58
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
59
+
60
+ ## Contributing
61
+
62
+ Bug reports and pull requests are welcome on GitHub at https://github.com/shunichi/vcr_stripe_webhook.
63
+
64
+ ## License
65
+
66
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VcrStripeWebhook
4
+ class Configuration
5
+ DEFAULT_TIMEOUT_SEC = 5
6
+
7
+ attr_writer :timeout, :cassette_library_dir, :stripe_api_key
8
+
9
+ def timeout
10
+ @timeout ||= DEFAULT_TIMEOUT_SEC
11
+ end
12
+
13
+ def cassette_library_dir
14
+ @cassette_library_dir ||=
15
+ File.join(VCR.configuration.cassette_library_dir, "stripe_webhooks")
16
+ end
17
+
18
+ def stripe_api_key
19
+ @stripe_api_key ||= Stripe.api_key
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VcrStripeWebhook
4
+ class Error < StandardError
5
+ end
6
+
7
+ class EventWaitTimeout < Error
8
+ end
9
+
10
+ class CassetteDataError < Error
11
+ end
12
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VcrStripeWebhook
4
+ class Event
5
+ attr_reader :type
6
+
7
+ def initialize(type, event_hash)
8
+ @type = type
9
+ @event_hash = event_hash
10
+ end
11
+
12
+ def to_h
13
+ {
14
+ "type" => type,
15
+ "value" => as_hash
16
+ }
17
+ end
18
+
19
+ def as_hash
20
+ @event_hash
21
+ end
22
+
23
+ def as_json
24
+ @json = JSON.generate(@event_hash)
25
+ end
26
+
27
+ class << self
28
+ def from_hash(hash)
29
+ Event.new(hash["type"], hash["value"])
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require_relative "event"
6
+ require_relative "error"
7
+
8
+ module VcrStripeWebhook
9
+ class EventCassette
10
+ attr_reader :name, :vcr_cassette
11
+
12
+ def initialize(name, vcr_cassette, recording)
13
+ @mutex = Mutex.new
14
+ @name = name
15
+ @vcr_cassette = vcr_cassette
16
+ @recording = recording
17
+ if recording
18
+ @data = Data.new
19
+ else
20
+ @data = Data.deserialize(path)
21
+ @wait_counter = 0
22
+ end
23
+ end
24
+
25
+ def path
26
+ dir = VcrStripeWebhook.configuration.cassette_library_dir
27
+ File.join(dir, "#{name}.stripe_webhooks.yml")
28
+ end
29
+
30
+ def recording?
31
+ @recording
32
+ end
33
+
34
+ def wait_for_events(waiter, timeout:, &block)
35
+ if recording?
36
+ record_and_wait_for_events(waiter, timeout: timeout, &block)
37
+ else
38
+ replay_and_wait_for_events(waiter, &block)
39
+ end
40
+ end
41
+
42
+ def receive_event_payload(event)
43
+ @mutex.synchronize do
44
+ @data.events.push(event)
45
+ end
46
+ end
47
+
48
+ def serialize
49
+ @data.serialize(path)
50
+ end
51
+
52
+ private
53
+
54
+ def record_and_wait_for_events(waiter, timeout:)
55
+ wait_start = @data.events.size
56
+
57
+ yield
58
+
59
+ start_time = Time.now
60
+ loop do
61
+ finished = false
62
+ target_events = nil
63
+ @mutex.synchronize do
64
+ wait_end = @data.events.size
65
+ target_events = @data.events[wait_start...wait_end]
66
+ finished = waiter.call(target_events)
67
+ @data.waits.push(Wait.new(wait_start, wait_end)) if finished
68
+ end
69
+
70
+ return target_events if finished
71
+
72
+ if Time.now - start_time > timeout
73
+ message = waiter.timeout_message(target_events, recording: true)
74
+ raise EventWaitTimeout, message
75
+ end
76
+
77
+ sleep 0.5
78
+ end
79
+ end
80
+
81
+ def replay_and_wait_for_events(waiter)
82
+ wait = @data.waits[@wait_counter]
83
+ if wait.nil?
84
+ raise CassetteDataError, <<~ERROR_MESSAGE
85
+ receive_webhook_event(s) calls is more than the calls in the recorded data.
86
+ If you modify test code after recording, record real webhooks again.
87
+ ERROR_MESSAGE
88
+ end
89
+
90
+ yield
91
+
92
+ target_events = @data.events[wait.start...wait.end]
93
+ finished = waiter.call(target_events)
94
+ unless finished
95
+ message = waiter.timeout_message(target_events, recording: false)
96
+ raise raise CassetteDataError, message
97
+ end
98
+
99
+ @wait_counter += 1
100
+ target_events
101
+ end
102
+
103
+ def logger
104
+ VcrStripeWebhook.logger
105
+ end
106
+
107
+ class Wait
108
+ attr_reader :start, :end
109
+
110
+ def initialize(wait_start, wait_end)
111
+ @start = wait_start
112
+ @end = wait_end
113
+ end
114
+
115
+ def to_h
116
+ {
117
+ "start" => start,
118
+ "end" => self.end
119
+ }
120
+ end
121
+
122
+ class << self
123
+ def from_hash(hash)
124
+ Wait.new(hash["start"], hash["end"])
125
+ end
126
+ end
127
+ end
128
+
129
+ class Data
130
+ attr_reader :events, :waits
131
+
132
+ def initialize(events: [], waits: [])
133
+ @events = events
134
+ @waits = waits
135
+ end
136
+
137
+ def serialize(path)
138
+ yaml = YAML.dump({
139
+ "events" => @events.map(&:to_h),
140
+ "waits" => @waits.map(&:to_h)
141
+ })
142
+ FileUtils.mkdir_p File.dirname(path)
143
+ File.write(path, yaml)
144
+ end
145
+
146
+ class << self
147
+ def deserialize(path)
148
+ yaml = YAML.safe_load_file(path, permitted_classes: [])
149
+ events = yaml["events"].map { |event_hash| Event.from_hash(event_hash) }
150
+ waits = yaml["waits"].map { |hash| Wait.from_hash(hash) }
151
+ Data.new(events: events, waits: waits)
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "server"
5
+ require_relative "stripe_cli"
6
+
7
+ module VcrStripeWebhook
8
+ class EventReceiver
9
+ class << self
10
+ def instance
11
+ @instance ||= EventReceiver.new
12
+ end
13
+
14
+ def terminate
15
+ return unless @instance
16
+
17
+ @instance.stop
18
+ @instance = nil
19
+ end
20
+ end
21
+
22
+ def initialize
23
+ @started = false
24
+ end
25
+
26
+ def start
27
+ return if @started
28
+
29
+ api_key = VcrStripeWebhook.configuration.stripe_api_key
30
+
31
+ @fence_mutex = Mutex.new
32
+ @fence_cond_var = ConditionVariable.new
33
+ @fence_test_clock_id = nil
34
+ @fence_signaled = false
35
+ @last_fence_event_created = nil
36
+
37
+ @cassette = nil
38
+ @server = VcrStripeWebhook::Server.new do |payload|
39
+ receive_webhook(payload)
40
+ end
41
+ @cli = VcrStripeWebhook::StripeCLI.new(@server.port, api_key)
42
+ @started = true
43
+ end
44
+
45
+ def stop
46
+ @cli.terminate
47
+ @server.close
48
+ @started = false
49
+ end
50
+
51
+ def use_cassette(cassette)
52
+ if cassette.recording?
53
+ start
54
+ logger.info "Using cassette: #{cassette.name}"
55
+ @cassette = cassette
56
+ yield cassette.vcr_cassette
57
+ logger.info "Eject cassette: #{cassette.name}"
58
+ @cassette = nil
59
+ wait_for_rest_webhooks
60
+ else
61
+ yield cassette.vcr_cassette
62
+ end
63
+ end
64
+
65
+ # Create and delete dummy test clock to wait for the rest webhooks to be received.
66
+ # Stripe doesn’t guarantee delivery of events in the order in which they’re generated.
67
+ # But waiting dummy test clock event lower the probability of webhook ordering problem.
68
+ def wait_for_rest_webhooks
69
+ time = Time.now
70
+ name = "vcr_sw_fence_#{time.to_i}_#{SecureRandom.alphanumeric}"
71
+ test_clock = nil
72
+ @fence_mutex.synchronize do
73
+ raise "Fence already used!" if @fence_test_clock_id
74
+
75
+ @fence_signaled = false
76
+ test_clock = Stripe::TestHelpers::TestClock.create(frozen_time: time.to_i, name: name)
77
+ @fence_test_clock_id = test_clock.id
78
+ end
79
+ test_clock.delete
80
+ @fence_mutex.synchronize do
81
+ loop do
82
+ break if @fence_signaled
83
+
84
+ @fence_cond_var.wait(@fence_mutex, VcrStripeWebhook.configuration.timeout)
85
+ end
86
+ @fence_test_clock_id = nil
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def receive_webhook(payload)
93
+ event_hash = JSON.parse(payload)
94
+ if @last_fence_event_created && event_hash["created"] < @last_fence_event_created
95
+ logger.info "Ignore old event: #{event_hash["type"]}"
96
+ return
97
+ end
98
+
99
+ logger.info "Server received event: #{event_hash["type"]} (cassette=#{@cassette&.name})"
100
+ @cassette&.receive_event_payload(Event.new(event_hash["type"], event_hash))
101
+
102
+ @fence_mutex.synchronize do
103
+ if @fence_test_clock_id && event_hash["type"] == "test_helpers.test_clock.deleted" && event_hash.dig(
104
+ "data", "object", "id"
105
+ ) == @fence_test_clock_id
106
+ @last_fence_event_created = event_hash["created"]
107
+ @fence_signaled = true
108
+ @fence_cond_var.signal
109
+ end
110
+ end
111
+ end
112
+
113
+ def logger
114
+ VcrStripeWebhook.logger
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module VcrStripeWebhook
6
+ # Tiny web server for stripe webhook
7
+ class Server
8
+ attr_reader :tcp_server, :port
9
+
10
+ def initialize(port = 0, &block)
11
+ run(port, &block)
12
+ end
13
+
14
+ def close
15
+ logger.info "Closing Server"
16
+ tcp_server.close
17
+ end
18
+
19
+ def wait_event(event_type)
20
+ @event_waiter.wait(event_type)
21
+ end
22
+
23
+ def join
24
+ @accept_thread.join
25
+ end
26
+
27
+ private
28
+
29
+ def run(port, &block)
30
+ @tcp_server = TCPServer.open(port)
31
+ _family, port, host, _ip = tcp_server.addr
32
+ @port = port
33
+ logger.info "Server is on #{[host, port].join(":")}"
34
+
35
+ @block = block
36
+ @accept_thread = Thread.start do
37
+ run_thread
38
+ end
39
+ end
40
+
41
+ def run_thread
42
+ loop do
43
+ socket = tcp_server.accept
44
+ Thread.start(socket) do |s|
45
+ request = RequestParser.new(s)
46
+
47
+ if request.json?
48
+ @block&.call(request.body)
49
+ s.write("HTTP/1.1 204\r\n\r\n")
50
+ else
51
+ s.write("HTTP/1.1 400\r\n\r\n")
52
+ end
53
+ ensure
54
+ s.close
55
+ end
56
+ end
57
+ rescue IOError
58
+ # Closed in another thread
59
+ end
60
+
61
+ def logger
62
+ VcrStripeWebhook.logger
63
+ end
64
+
65
+ class RequestParser
66
+ MAX_HEADER_LENGTH = 16 * 1024
67
+
68
+ attr_reader :method, :path, :headers, :body
69
+
70
+ def initialize(socket)
71
+ @method, @path = read_start_line(socket)
72
+ @headers = read_headers(socket)
73
+ return unless content_length
74
+
75
+ @body = socket.read(content_length)
76
+ end
77
+
78
+ def content_length
79
+ @content_length ||= headers["content-length"]&.to_i
80
+ end
81
+
82
+ def content_type
83
+ return @content_type if defined?(@content_type)
84
+
85
+ if headers["content-type"]
86
+ @content_type, _attributes = headers["content-type"].split(/;\s+/, 2)
87
+ else
88
+ @content_type = nil
89
+ end
90
+ @content_type
91
+ end
92
+
93
+ def json?
94
+ content_type == "application/json"
95
+ end
96
+
97
+ def json_value
98
+ return @json_value if defined?(@json_value)
99
+
100
+ @json_value =
101
+ if json?
102
+ begin
103
+ JSON.parse(@body)
104
+ rescue StandardError
105
+ nil
106
+ end
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ def read_start_line(socket)
113
+ line = socket.gets("\n")
114
+ method, path, _http_version = line.split(" ", 3)
115
+ [method, path]
116
+ end
117
+
118
+ def read_headers(socket)
119
+ headers = {}
120
+ loop do
121
+ line = socket.gets("\n", MAX_HEADER_LENGTH, chomp: true)
122
+ break if line.nil? || line.empty?
123
+
124
+ key, value = line.split(/:\s+/, 2).map(&:strip)
125
+ headers[key.downcase] = value
126
+ end
127
+ headers
128
+ end
129
+
130
+ def logger
131
+ VcrStripeWebhook.logger
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VcrStripeWebhook
4
+ class StripeCLI
5
+ def initialize(server_port, api_key)
6
+ @server_port = server_port
7
+ @api_key = api_key
8
+ run
9
+ end
10
+
11
+ def terminate
12
+ @stdout.close
13
+ @read_out_thread.join
14
+ @stderr.close
15
+ @read_err_thread.join
16
+ Process.kill(:TERM, @pid)
17
+ Process.waitpid(@pid)
18
+ end
19
+
20
+ private
21
+
22
+ def run
23
+ spawn_process
24
+
25
+ @ready_waiter = ReadyWaiter.new
26
+
27
+ @read_out_thread = Thread.new do
28
+ read_thread("OUT", @stdout)
29
+ end
30
+ @read_err_thread = Thread.new do
31
+ read_thread("ERR", @stderr, ready_check: true)
32
+ end
33
+
34
+ logger.info "Waiting for Stripe CLI to be ready..."
35
+ @ready_waiter.wait_ready
36
+ self
37
+ end
38
+
39
+ def spawn_process
40
+ env = { "STRIPE_API_KEY" => @api_key }
41
+ r_out, w_out = IO.pipe
42
+ r_err, w_err = IO.pipe
43
+ command_and_args = ["stripe", "listen", "--forward-to", "localhost:#{@server_port}/"]
44
+ logger.info "Spawning Stripe CLI: #{command_and_args.join(" ")}"
45
+ @pid = spawn(env, *command_and_args, { out: w_out, err: w_err })
46
+ w_out.close
47
+ w_err.close
48
+ @stdout = r_out
49
+ @stderr = r_err
50
+ logger.info "Stripe CLI PID=#{@pid}"
51
+ rescue Errno::ENOENT
52
+ raise "Could not spawn Stripe CLI"
53
+ end
54
+
55
+ def read_thread(prefix, io, ready_check: false)
56
+ loop do
57
+ line = io.gets(chomp: true)
58
+ return if line.nil?
59
+
60
+ @ready_waiter.signal_ready if ready_check && line.start_with?("Ready!")
61
+ logger.info "Stripe CLI: #{prefix}: #{line}"
62
+ rescue IOError
63
+ # Closed in another thread
64
+ break
65
+ end
66
+ end
67
+
68
+ def logger
69
+ VcrStripeWebhook.logger
70
+ end
71
+
72
+ class ReadyWaiter
73
+ def initialize
74
+ @ready = false
75
+ @ready_mutex = Mutex.new
76
+ @ready_cond_var = ConditionVariable.new
77
+ end
78
+
79
+ def wait_ready
80
+ @ready_mutex.synchronize do
81
+ loop do
82
+ break if @ready
83
+
84
+ @ready_cond_var.wait(@ready_mutex)
85
+ end
86
+ end
87
+ end
88
+
89
+ def signal_ready
90
+ @ready_mutex.synchronize do
91
+ @ready = true
92
+ @ready_cond_var.signal
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VcrStripeWebhook
4
+ VERSION = "0.3.0"
5
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VcrStripeWebhook
4
+ class ProcWaiter
5
+ def initialize(_proc)
6
+ @proc
7
+ end
8
+
9
+ def call(events)
10
+ @proc.call(events)
11
+ end
12
+
13
+ def timeout_message(_events, recording:)
14
+ events_words = recording ? "received events" : "the recorded events"
15
+ "wait_until proc does not return true with #{events_words}."
16
+ end
17
+ end
18
+
19
+ class EventTypeWaiter
20
+ attr_reader :event_types
21
+
22
+ def initialize(event_types)
23
+ @event_types = event_types
24
+ end
25
+
26
+ def call(events)
27
+ type_set = events.map(&:type).to_set
28
+ event_types.all? { |type| type_set.member?(type) }
29
+ end
30
+
31
+ def timeout_message(events, recording:)
32
+ type_set = events.map(&:type).to_set
33
+ missing_event_types = event_types.reject { |type| type_set.member?(type) }
34
+ events_words = recording ? "Received events" : "The recorded events"
35
+ types_words = missing_event_types.map { |type| "'#{type}'" }.join(", ")
36
+ "#{events_words} don't contain #{types_words}."
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "set"
5
+ require_relative "vcr_stripe_webhook/version"
6
+ require_relative "vcr_stripe_webhook/configuration"
7
+ require_relative "vcr_stripe_webhook/event_receiver"
8
+ require_relative "vcr_stripe_webhook/event_cassette"
9
+ require_relative "vcr_stripe_webhook/waiter"
10
+
11
+ module VcrStripeWebhook
12
+ class << self
13
+ attr_reader :server, :current_event_cassette
14
+ attr_writer :logger
15
+
16
+ def stop
17
+ EventReceiver.terminate
18
+ end
19
+
20
+ def configuration
21
+ @configuration ||= VcrStripeWebhook::Configuration.new
22
+ end
23
+
24
+ def logger
25
+ @logger ||= Logger.new("log/vcr_stripe_webhook.log")
26
+ end
27
+
28
+ def use_cassette(name, options = {}, &block)
29
+ VCR.use_cassette(name, options) do |vcr_cassette|
30
+ @current_event_cassette = EventCassette.new(name, vcr_cassette, vcr_cassette.recording?)
31
+ EventReceiver.instance.use_cassette(@current_event_cassette, &block)
32
+ @current_event_cassette.serialize if @current_event_cassette.recording?
33
+ ensure
34
+ @current_event_cassette = nil
35
+ end
36
+ end
37
+
38
+ def receive_webhook_events(event_types: nil, wait_until: nil, timeout: configuration.timeout, &block)
39
+ raise "No event cassette inserted." if current_event_cassette.nil?
40
+ raise "event_types or wait_until must be passed as an argument." if event_types.nil? && wait_until.nil?
41
+
42
+ waiter =
43
+ if event_types
44
+ EventTypeWaiter.new(event_types)
45
+ else
46
+ ProcWaiter.new(wait_until)
47
+ end
48
+ current_event_cassette.wait_for_events(waiter, timeout: timeout, &block)
49
+ end
50
+
51
+ def receive_webhook_event(event_type, timeout: configuration.timeout, &block)
52
+ events = receive_webhook_events(event_types: [event_type], timeout: timeout, &block)
53
+ events.find { |event| event.type == event_type }
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,4 @@
1
+ module VcrStripeWebhook
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: vcr_stripe_webhook
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Shunichi Ikegami
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-12-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: stripe
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '9.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '9.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: vcr
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '6.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '6.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: dotenv
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: webmock
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ description: Record and replay Stripe webhooks during test
70
+ email:
71
+ - sike.tm@gmail.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - ".rspec"
77
+ - ".rubocop.yml"
78
+ - ".ruby-version"
79
+ - CHANGELOG.md
80
+ - Gemfile
81
+ - Gemfile.lock
82
+ - LICENSE.txt
83
+ - README.md
84
+ - Rakefile
85
+ - lib/vcr_stripe_webhook.rb
86
+ - lib/vcr_stripe_webhook/configuration.rb
87
+ - lib/vcr_stripe_webhook/error.rb
88
+ - lib/vcr_stripe_webhook/event.rb
89
+ - lib/vcr_stripe_webhook/event_cassette.rb
90
+ - lib/vcr_stripe_webhook/event_receiver.rb
91
+ - lib/vcr_stripe_webhook/server.rb
92
+ - lib/vcr_stripe_webhook/stripe_cli.rb
93
+ - lib/vcr_stripe_webhook/version.rb
94
+ - lib/vcr_stripe_webhook/waiter.rb
95
+ - sig/vcr_stripe_webhook.rbs
96
+ homepage: https://github.com/shunichi/vcr_stripe_webhook
97
+ licenses:
98
+ - MIT
99
+ metadata:
100
+ homepage_uri: https://github.com/shunichi/vcr_stripe_webhook
101
+ source_code_uri: https://github.com/shunichi/vcr_stripe_webhook
102
+ changelog_uri: https://github.com/shunichi/vcr_stripe_webhook/blob/master/CHANGELOG.md
103
+ post_install_message:
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: 3.0.0
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 3.2.33
119
+ signing_key:
120
+ specification_version: 4
121
+ summary: Record and replay Stripe webhooks during test
122
+ test_files: []