Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
*.gem
*.rbc
/.config
/.bundle/
/.yardoc
/Gemfile.lock
Expand All @@ -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
1 change: 1 addition & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
--require spec_helper
--format documentation
--color
4 changes: 2 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions circle.yml
Original file line number Diff line number Diff line change
@@ -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
151 changes: 123 additions & 28 deletions lib/webhook_recorder/server.rb
Original file line number Diff line number Diff line change
@@ -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}"
Expand All @@ -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
2 changes: 1 addition & 1 deletion lib/webhook_recorder/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module WebhookRecorder
VERSION = "0.1.4"
VERSION = "0.2.0"
end
90 changes: 90 additions & 0 deletions spec/server_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading