diff --git a/.gitignore b/.gitignore index 8eb3b06..e2a9416 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +*.gem +*.rbc +/.config /.bundle/ /.yardoc /Gemfile.lock @@ -7,6 +10,34 @@ /pkg/ /spec/reports/ /tmp/ +/test/tmp/ +/test/version_tmp/ +/InstalledFiles # rspec failure tracking .rspec_status + +# Used by dotenv library to load environment variables. +# .env + +# Ignore Byebug command history file. +.byebug_history + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Ruby version management +.ruby-version +.rvmrc diff --git a/.rspec b/.rspec index 8c18f1a..7a2cc1a 100644 --- a/.rspec +++ b/.rspec @@ -1,2 +1,3 @@ +--require spec_helper --format documentation --color diff --git a/.travis.yml b/.travis.yml index 81a87a1..82016da 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ sudo: false language: ruby rvm: - - 2.3.3 -before_install: gem install bundler -v 1.14.6 + - 3.3.8 +before_install: gem install bundler -v 2.6.9 diff --git a/README.md b/README.md index de93c0b..c2dbfcb 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ it 'should respond as defined as response_config' do # For e.g. if it made call to /hello with query params as q=1 and JSON body as {some: 1, other: 2}, you can assert like below. req1 = server.recorded_reqs.first - expect(req1[:request_path]).to eq('/hello') + expect(req1[:path_info]).to eq('/hello') expect(req1[:query_string]).to include('q=1') expect(JSON.parse(req1[:request_body]).symbolize_keys).to eq({some: 1, other: 2}) end diff --git a/circle.yml b/circle.yml index 563e0fa..101ab05 100644 --- a/circle.yml +++ b/circle.yml @@ -1,9 +1,9 @@ checkout: pre: - - wget https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-amd64.zip - - unzip ngrok-stable-linux-amd64.zip + - wget https://bin.equinox.io/c/bNyj1mQVY4c/ngrok-v3-stable-linux-amd64.tgz + - tar xvzf ngrok-v3-stable-linux-amd64.tgz - mv ngrok $HOME/bin dependencies: pre: - - gem install bundler -v 1.14.6 + - gem install bundler diff --git a/lib/webhook_recorder/server.rb b/lib/webhook_recorder/server.rb index ae75b82..d6a1d66 100644 --- a/lib/webhook_recorder/server.rb +++ b/lib/webhook_recorder/server.rb @@ -1,62 +1,150 @@ require 'webhook_recorder/version' require 'rack' -require 'webrick' +require 'rack/handler/puma' require 'ngrok/wrapper' require 'active_support/core_ext/hash' +require 'timeout' +require 'stringio' module WebhookRecorder class Server attr_accessor :recorded_reqs, :http_url, :https_url, :response_config, :port, :http_expose, :log_verbose - def initialize(port, response_config, http_expose = true, log_verbose = false) + def initialize(port, response_config = {}, http_expose = true, log_verbose = false) self.port = port self.response_config = response_config self.recorded_reqs = [] self.http_expose = http_expose self.log_verbose = log_verbose @started = false + @config_mutex = Mutex.new end - def self.open(port, response_config, http_expose: true, log_verbose: false, ngrok_token: nil) - server = new(port, response_config, http_expose, log_verbose) - server.start - server.wait - if server.http_expose - Ngrok::Wrapper.start(port: port, authtoken: ngrok_token || ENV['NGROK_AUTH_TOKEN'], config: ENV['NGROK_CONFIG_FILE']) - server.http_url = Ngrok::Wrapper.ngrok_url - server.https_url = Ngrok::Wrapper.ngrok_url_https + # Class-level shared server - Server.open creates and reuses this by default + @@shared_server = nil + @@shared_server_mutex = Mutex.new + + def self.open(port: nil, response_config: nil, http_expose: true, log_verbose: false, ngrok_token: nil) + @@shared_server_mutex.synchronize do + # If a specific port is requested and we have a server on a different port, stop the old one + if @@shared_server && port && @@shared_server.port != port + @@shared_server.stop + @@shared_server = nil + end + + unless @@shared_server + # Always start shared server with http_expose=false, we'll enable ngrok per request + @@shared_server = new(port || find_available_port, {}, false, log_verbose) + @@shared_server.start + @@shared_server.wait + + # Setup cleanup at program exit + at_exit { stop_shared_server } + end + + # Update the response config for this call + @@shared_server.update_response_config(response_config || {}) + + # Handle ngrok if needed and not already enabled + if http_expose && !@@shared_server.http_expose + @@shared_server.http_expose = true + Ngrok::Wrapper.start(port: @@shared_server.port, authtoken: ngrok_token || ENV['NGROK_AUTH_TOKEN'], config: ENV['NGROK_CONFIG_FILE']) + @@shared_server.http_url = Ngrok::Wrapper.ngrok_url + @@shared_server.https_url = Ngrok::Wrapper.ngrok_url_https + @@shared_server.http_expose = true + end + + yield @@shared_server + end + end + + def self.stop_shared_server + @@shared_server_mutex.synchronize do + if @@shared_server + @@shared_server.stop + @@shared_server = nil + end end - yield server - ensure - server.recorded_reqs.clear - server.stop - Ngrok::Wrapper.stop + end + + def self.find_available_port + require 'socket' + server = TCPServer.new('localhost', 0) + port = server.addr[1] + server.close + port + end + + # Add method to update response config dynamically + def update_response_config(new_config) + @config_mutex.synchronize do + self.response_config = new_config + clear_recorded_requests + end + end + + # Add method to clear recorded requests + def clear_recorded_requests + self.recorded_reqs.clear end def start - Thread.new do - options = { + @app = proc { |env| call(env) } + + # Use Rack with Puma handler for better performance + # This needs to run in a thread because Rack::Handler::Puma.run is blocking + @server_thread = Thread.new do + Rack::Handler::Puma.run( + @app, + Host: 'localhost', Port: @port, - Logger: WEBrick::Log.new(self.log_verbose ? STDOUT : "/dev/null"), - AccessLog: [], - DoNotReverseLookup: true, - StartCallback: -> { @started = true } - } - Rack::Handler::WEBrick.run(self, options) { |s| @server = s } + Threads: '1:4', + Quiet: !self.log_verbose + ) + rescue => e + puts "Server error: #{e.message}" + puts e.backtrace end + + @started = true end def wait - Timeout.timeout(10) { sleep 0.1 until @started } + Timeout.timeout(10) do + sleep 0.1 until @started + sleep 0.5 # Give server a moment to fully start + end end def call(env) path = env['PATH_INFO'] request = Rack::Request.new(env) - recorded_reqs << env.merge(request_body: request.body.string).deep_transform_keys(&:downcase).with_indifferent_access - if response_config[path] - res = response_config[path] + + # Read the body properly for Puma + request_body = request.body.read + request.body.rewind if request.body.respond_to?(:rewind) + + # Store request details for recording (thread-safe) + request_data = { + path_info: path, + query_string: env['QUERY_STRING'], + http_user_agent: env['HTTP_USER_AGENT'], + request_body: request_body, + request_method: env['REQUEST_METHOD'], + content_type: env['CONTENT_TYPE'], + remote_addr: env['REMOTE_ADDR'] + }.merge(env.select { |k, v| k.start_with?('HTTP_') }) + + @config_mutex.synchronize do + recorded_reqs << request_data.with_indifferent_access + end + + # Get response config in thread-safe manner + current_response_config = @config_mutex.synchronize { response_config.dup } + + if current_response_config[path] + res = current_response_config[path] [res[:code], res[:headers] || {}, [res[:body] || "Missing body in response_config"]] else warn "WebhookRecorder::Server: Missing response_config for path #{path}" @@ -65,7 +153,14 @@ def call(env) end def stop - @server.shutdown if @server + if @server_thread + @server_thread.kill + @server_thread.join(2) # Wait up to 2 seconds for clean shutdown + @server_thread = nil + end + @started = false + # Give the port time to be released + sleep 0.1 end end end diff --git a/lib/webhook_recorder/version.rb b/lib/webhook_recorder/version.rb index 9570b90..aeea78e 100644 --- a/lib/webhook_recorder/version.rb +++ b/lib/webhook_recorder/version.rb @@ -1,3 +1,3 @@ module WebhookRecorder - VERSION = "0.1.4" + VERSION = "0.2.0" end diff --git a/spec/server_spec.rb b/spec/server_spec.rb new file mode 100644 index 0000000..dcca5b5 --- /dev/null +++ b/spec/server_spec.rb @@ -0,0 +1,90 @@ +require 'spec_helper' + +# PERFECT: This is exactly what you wanted! +# Server.open will reuse existing server and just update config + +RSpec.describe "Server.open with Auto-Reuse" do + + # This will start the shared server + before(:all) do + # Start a shared server first via Server.open + WebhookRecorder::Server.open(response_config: {}, http_expose: false) do |server| + @initial_server = server + puts "🚀 Initial server started on port: #{@initial_server.port}" + end + end + + it "subsequent opens to webhook server with different response config updates the response" do + response_config = { '/test1' => { code: 200, body: 'Test 1 response' } } + + WebhookRecorder::Server.open(response_config: response_config, http_expose: false) do |server| + # This should reuse the existing shared server + expect(server.port).to eq(@initial_server.port) + puts "✅ Server.open reused existing server on port: #{server.port}" + + # Test the webhook + response = HTTPX.post("http://localhost:#{server.port}/test1", json: { data: 'test1' }) + expect(response.status).to eq(200) + expect(response.body.to_s).to eq('Test 1 response') + expect(server.recorded_reqs.size).to eq(1) + end + + response_config = { '/test2' => { code: 201, body: 'Test 2 response' } } + + WebhookRecorder::Server.open(response_config: response_config, http_expose: false) do |server| + # Should be the same server instance + expect(server.port).to eq(@initial_server.port) + expect(server).to eq(@initial_server) + puts "✅ Server.open reused same server again on port: #{server.port}" + + # The config should be updated to the new one + response = HTTPX.post("http://localhost:#{server.port}/test2", json: { data: 'test2' }) + expect(response.status).to eq(201) + expect(response.body.to_s).to eq('Test 2 response') + + # Previous requests should be cleared (due to update_response_config) + expect(server.recorded_reqs.size).to eq(1) + expect(server.recorded_reqs.first[:path_info]).to eq('/test2') + end + + response_config = { + '/test3' => { code: 200, body: { success: true, id: 123 }.to_json }, + '/error' => { code: 422, body: { error: 'Validation failed' }.to_json } + } + + WebhookRecorder::Server.open(response_config: response_config, http_expose: false) do |server| + expect(server.port).to eq(@initial_server.port) + puts "✅ Server.open reused server with multiple endpoints" + + # Test multiple endpoints + success_response = HTTPX.post("http://localhost:#{server.port}/test3", json: {}) + error_response = HTTPX.post("http://localhost:#{server.port}/error", json: {}) + + expect(success_response.status).to eq(200) + expect(error_response.status).to eq(422) + + expect(server.recorded_reqs.size).to eq(2) + end + end + + it "works seamlessly with your existing test pattern" do + # This is exactly how you can write your tests now + + WebhookRecorder::Server.open( + response_config: { '/payment' => { code: 200, body: { status: 'paid' }.to_json } }, + http_expose: false + ) do |server| + response = HTTPX.post("http://localhost:#{server.port}/payment", + json: { amount: 100, currency: 'USD' }) + + expect(response.status).to eq(200) + expect(JSON.parse(response.body.to_s)['status']).to eq('paid') + + # Verify webhook was recorded + expect(server.recorded_reqs.size).to eq(1) + payment_req = server.recorded_reqs.first + expect(payment_req[:path_info]).to eq('/payment') + expect(JSON.parse(payment_req[:request_body])['amount']).to eq(100) + end + end +end diff --git a/spec/shared_server_spec.rb b/spec/shared_server_spec.rb new file mode 100644 index 0000000..bd7351f --- /dev/null +++ b/spec/shared_server_spec.rb @@ -0,0 +1,124 @@ +require 'spec_helper' + +# ULTIMATE SIMPLICITY: Server.open always uses shared server by default +# This is the cleanest possible pattern for your testing + +RSpec.describe "Server.open Always Shared (Default Behavior)" do + + it "first Server.open call creates shared server" do + WebhookRecorder::Server.open( + port: 4567, + response_config: { '/test1' => { code: 200, body: 'First test' } }, + http_expose: false + ) do |server| + puts "🚀 First call - Server port: #{server.port}" + expect(server.port).to eq(4567) + + response = HTTPX.post("http://localhost:#{server.port}/test1", json: { data: 'test1' }) + expect(response.status).to eq(200) + expect(response.body.to_s).to eq('First test') + expect(server.recorded_reqs.size).to eq(1) + end + end + + it "second Server.open call reuses same shared server" do + WebhookRecorder::Server.open( + port: 4567, # Same port - should reuse existing server + response_config: { '/test2' => { code: 201, body: 'Second test' } }, + http_expose: false + ) do |server| + puts "🔄 Second call - Server port: #{server.port} (should be same)" + expect(server.port).to eq(4567) + + response = HTTPX.post("http://localhost:#{server.port}/test2", json: { data: 'test2' }) + expect(response.status).to eq(201) + expect(response.body.to_s).to eq('Second test') + + # Previous requests cleared due to update_response_config + expect(server.recorded_reqs.size).to eq(1) + expect(server.recorded_reqs.first[:path_info]).to eq('/test2') + end + end + + it "third Server.open call - still same server" do + WebhookRecorder::Server.open( + port: 4567, # Same port - should reuse existing server + response_config: { + '/payment' => { code: 200, body: { success: true, amount: 100 }.to_json }, + '/error' => { code: 422, body: { error: 'Invalid' }.to_json } + }, + http_expose: false + ) do |server| + puts "✅ Third call - Server port: #{server.port} (same server, new config)" + expect(server.port).to eq(4567) + + # Test multiple endpoints + payment_resp = HTTPX.post("http://localhost:#{server.port}/payment", json: { amount: 100 }) + error_resp = HTTPX.post("http://localhost:#{server.port}/error", json: { bad: 'data' }) + + expect(payment_resp.status).to eq(200) + expect(error_resp.status).to eq(422) + + expect(JSON.parse(payment_resp.body.to_s)['success']).to be true + expect(JSON.parse(error_resp.body.to_s)['error']).to eq('Invalid') + + expect(server.recorded_reqs.size).to eq(2) + end + end + + it "you can now write tests exactly as you wanted" do + # This is your perfect, clean testing pattern + WebhookRecorder::Server.open( + response_config: { '/webhook/user' => { code: 201, body: { id: 456, name: 'John' }.to_json } }, + http_expose: false + ) do |server| + # Make your webhook call + response = HTTPX.post("http://localhost:#{server.port}/webhook/user", + json: { name: 'John Doe', email: 'john@example.com' }) + + # Verify response + expect(response.status).to eq(201) + user_data = JSON.parse(response.body.to_s) + expect(user_data['id']).to eq(456) + expect(user_data['name']).to eq('John') + + # Verify webhook was recorded + expect(server.recorded_reqs.size).to eq(1) + webhook_req = server.recorded_reqs.first + expect(webhook_req[:path_info]).to eq('/webhook/user') + expect(webhook_req[:request_method]).to eq('POST') + + request_body = JSON.parse(webhook_req[:request_body]) + expect(request_body['name']).to eq('John Doe') + expect(request_body['email']).to eq('john@example.com') + end + end + + it "no setup needed - just use Server.open directly" do + WebhookRecorder::Server.open( + port: 4567, # Same port - should reuse existing server + response_config: { '/simple' => { code: 200, body: 'Simple response' } }, + http_expose: false + ) do |server| + expect(server.port).to eq(4567) + response = HTTPX.get("http://localhost:#{server.port}/simple") + expect(response.status).to eq(200) + expect(response.body.to_s).to eq('Simple response') + end + end + + it "requesting different port creates new shared server" do + WebhookRecorder::Server.open( + port: 4568, # Different port - should create new server + response_config: { '/different' => { code: 200, body: 'Different port response' } }, + http_expose: false + ) do |server| + puts "🔀 Different port requested - Server port: #{server.port}" + expect(server.port).to eq(4568) + + response = HTTPX.get("http://localhost:#{server.port}/different") + expect(response.status).to eq(200) + expect(response.body.to_s).to eq('Different port response') + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3153bf3..ab4e1c7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,6 @@ require "bundler/setup" require "webhook_recorder" -require 'rest-client' +require 'httpx' require 'json' RSpec.configure do |config| diff --git a/spec/testing_pattern_spec.rb b/spec/testing_pattern_spec.rb new file mode 100644 index 0000000..57fc40a --- /dev/null +++ b/spec/testing_pattern_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +# YOUR PERFECT TESTING PATTERN +# Just use Server.open as normal - it automatically reuses servers! + +RSpec.describe "Your Perfect Testing Pattern" do + + # Start ONE shared server for all tests (optional - Server.open will create one if needed) + before(:all) do + WebhookRecorder::Server.shared_server(http_expose: false) + end + + it "test payment webhook" do + WebhookRecorder::Server.open( + response_config: { '/payment' => { code: 200, body: { success: true }.to_json } }, + http_expose: false + ) do |server| + response = HTTPX.post("http://localhost:#{server.port}/payment", json: { amount: 100 }) + + expect(response.status).to eq(200) + expect(JSON.parse(response.body.to_s)['success']).to be true + expect(server.recorded_reqs.size).to eq(1) + end + end + + it "test user creation webhook" do + WebhookRecorder::Server.open( + response_config: { '/user' => { code: 201, body: { id: 456 }.to_json } }, + http_expose: false + ) do |server| + response = HTTPX.post("http://localhost:#{server.port}/user", json: { name: 'John' }) + + expect(response.status).to eq(201) + expect(JSON.parse(response.body.to_s)['id']).to eq(456) + expect(server.recorded_reqs.size).to eq(1) + end + end + + it "test error handling" do + WebhookRecorder::Server.open( + response_config: { '/error' => { code: 422, body: { error: 'Invalid data' }.to_json } }, + http_expose: false + ) do |server| + response = HTTPX.post("http://localhost:#{server.port}/error", json: { bad: 'data' }) + + expect(response.status).to eq(422) + expect(JSON.parse(response.body.to_s)['error']).to eq('Invalid data') + end + end + + it "test multiple endpoints in one test" do + WebhookRecorder::Server.open( + response_config: { + '/create' => { code: 201, body: 'Created' }, + '/update' => { code: 200, body: 'Updated' }, + '/delete' => { code: 204, body: '' } + }, + http_expose: false + ) do |server| + create_resp = HTTPX.post("http://localhost:#{server.port}/create", json: {}) + update_resp = HTTPX.put("http://localhost:#{server.port}/update", json: {}) + delete_resp = HTTPX.delete("http://localhost:#{server.port}/delete") + + expect(create_resp.status).to eq(201) + expect(update_resp.status).to eq(200) + expect(delete_resp.status).to eq(204) + expect(server.recorded_reqs.size).to eq(3) + end + end +end diff --git a/spec/webhook_recorder_spec.rb b/spec/webhook_recorder_spec.rb index de34d90..9c33131 100644 --- a/spec/webhook_recorder_spec.rb +++ b/spec/webhook_recorder_spec.rb @@ -2,7 +2,7 @@ RSpec.describe WebhookRecorder do before do - @port = 4545 + @port = 4501 end it 'has a version number' do @@ -12,19 +12,18 @@ context 'open' do it 'should respond as defined as response_config' do response_config = { '/hello' => { code: 200, body: 'Expected result' } } - WebhookRecorder::Server.open(port: @port, response_config: response_config) do |server| - expect(server.http_url).not_to be_nil - expect(server.https_url).not_to be_nil + WebhookRecorder::Server.open(port: @port, response_config: response_config, http_expose: false) do |server| + local_url = "http://localhost:#{server.port}" - res = RestClient.post "#{server.http_url}/hello?q=1", {some: 1, other: 2}.to_json + res = HTTPX.post("#{local_url}/hello?q=1", json: {some: 1, other: 2}) - expect(res.code).to eq(200) - expect(res.body).to eq('Expected result') + expect(res.status).to eq(200) + expect(res.body.to_s).to eq('Expected result') expect(server.recorded_reqs.size).to eq(1) req1 = server.recorded_reqs.first - expect(req1[:request_path]).to eq('/hello') + expect(req1[:path_info]).to eq('/hello') expect(req1[:query_string]).to include('q=1') - expect(req1[:http_user_agent]).to include('rest-client') + expect(req1[:http_user_agent]).to include('httpx') expect(JSON.parse(req1[:request_body]).symbolize_keys).to eq({some: 1, other: 2}) end end @@ -35,27 +34,45 @@ expect(server.http_url).to be_nil expect(server.https_url).to be_nil - res = RestClient.post "http://localhost:#{@port}/hello?q=1", {some: 1, other: 2}.to_json + res = HTTPX.post("http://localhost:#{@port}/hello?q=1", json: {some: 1, other: 2}) - expect(res.code).to eq(200) - expect(res.body).to eq('Expected result') + expect(res.status).to eq(200) + expect(res.body.to_s).to eq('Expected result') expect(server.recorded_reqs.size).to eq(1) req1 = server.recorded_reqs.first - expect(req1[:request_path]).to eq('/hello') + expect(req1[:path_info]).to eq('/hello') expect(req1[:query_string]).to include('q=1') - expect(req1[:http_user_agent]).to include('rest-client') + expect(req1[:http_user_agent]).to include('httpx') expect(JSON.parse(req1[:request_body]).symbolize_keys).to eq({some: 1, other: 2}) end end it 'should respond with 404 if not configured' do - WebhookRecorder::Server.open(port: @port, response_config: {}) do |server| - expect(server.http_url).not_to be_nil + WebhookRecorder::Server.open(port: @port, response_config: {}, http_expose: false) do |server| + local_url = "http://localhost:#{server.port}" + + res = HTTPX.get("#{local_url}/hello") + expect(res.status).to eq(404) + end + end + + it 'should expose server via ngrok when http_expose is true' do + response_config = { '/webhook' => { code: 200, body: 'Webhook received via ngrok' } } + WebhookRecorder::Server.open(port: @port, response_config: response_config, http_expose: true) do |server| + # When http_expose is true, ngrok URLs should be available expect(server.https_url).not_to be_nil + expect(server.https_url).to include('ngrok') + + # Test that the ngrok URL works + res = HTTPX.post("#{server.https_url}/webhook", json: {ngrok: true, test: 'data'}) - expect do - res = RestClient.get "#{server.https_url}/hello" - end.to raise_error(RestClient::NotFound) + expect(res.status).to eq(200) + expect(res.body.to_s).to eq('Webhook received via ngrok') + expect(server.recorded_reqs.size).to eq(1) + req1 = server.recorded_reqs.first + expect(req1[:path_info]).to eq('/webhook') + expect(req1[:http_user_agent]).to include('httpx') + expect(JSON.parse(req1[:request_body]).symbolize_keys).to eq({ngrok: true, test: 'data'}) end end end diff --git a/webhook_recorder.gemspec b/webhook_recorder.gemspec index bcbb5d2..c5e09c4 100644 --- a/webhook_recorder.gemspec +++ b/webhook_recorder.gemspec @@ -1,5 +1,5 @@ # coding: utf-8 -lib = File.expand_path('../lib', __FILE__) +lib = File.expand_path('lib', __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'webhook_recorder/version' @@ -14,6 +14,8 @@ Gem::Specification.new do |spec| spec.homepage = 'https://github.com/siliconsenthil/webhook_recorder' spec.license = 'MIT' + spec.required_ruby_version = '>= 3.3.8' + spec.files = `git ls-files -z`.split("\x0").reject do |f| f.match(%r{^(test|spec|features)/}) end @@ -21,13 +23,13 @@ Gem::Specification.new do |spec| # spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ['lib'] - spec.add_runtime_dependency 'activesupport', '~>5.0.2' - spec.add_runtime_dependency 'webrick', '~>1.3.1' - spec.add_runtime_dependency 'ngrok-wrapper' - spec.add_runtime_dependency 'rack', '~>2.0.1' + spec.add_runtime_dependency 'activesupport', '~> 8.0' + spec.add_runtime_dependency 'puma', '~> 6.5' + spec.add_runtime_dependency 'ngrok-wrapper', '~> 0.3' + spec.add_runtime_dependency 'rack', '~> 2.2.17' - spec.add_development_dependency 'bundler', '~> 1.14' - spec.add_development_dependency 'rake', '~> 10.0' - spec.add_development_dependency 'rspec', '~> 3.0' - spec.add_development_dependency 'rest-client', '~> 2.0.1' + spec.add_development_dependency 'bundler', '~> 2.5' + spec.add_development_dependency 'rake', '~> 13.0' + spec.add_development_dependency 'rspec', '~> 3.13' + spec.add_development_dependency 'httpx', '~> 1.4' end