Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add advanced request matching option to WebValve.register #72

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,32 @@ You will have to restart your application after making this change
because service faking is an initialization time concern and not a
runtime concern.

### Advanced request matching

You can also use the `request_matcher` option when registering a service to specify
additional request matching criteria. This is particularly useful when
you need to mock requests based on the request body or other parameters,
not just the URL.

```ruby
# config/webvalve.rb
WebValve.register(
"FakeBank",
request_matcher: {
body: WebMock::Matchers::HashIncludingMatcher.new(action: 'limit_order')
}
)
```

In this example, the FakeBank service will only be used for requests that
match request body that includes the key `action` with the value `limit_order`.

The `request_matcher` option accepts the same parameters as WebMock's
`with` method, including:
- `:body`
- `:headers`
- `:query`

## Configuring fakes in tests

In order to get WebValve fake services working properly in tests, you
Expand Down
5 changes: 3 additions & 2 deletions lib/webvalve/fake_service_config.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
module WebValve
class FakeServiceConfig
attr_reader :service_class_name
attr_reader :service_class_name, :request_matcher

def initialize(service_class_name:, url: nil)
def initialize(service_class_name:, url: nil, request_matcher: nil)
@service_class_name = service_class_name
@custom_service_url = url
@request_matcher = request_matcher
end

def explicitly_enabled?
Expand Down
28 changes: 22 additions & 6 deletions lib/webvalve/manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,17 @@ def allowlisted_url_regexps
def webmock_service(config)
ensure_non_duplicate_stub(config)

WebMock.stub_request(
:any,
url_to_regexp(config.service_url)
).to_rack(FakeServiceWrapper.new(config))
webmock_stub = WebMock.stub_request(:any, url_to_regexp(config.service_url))

if config.request_matcher
if config.request_matcher.respond_to?(:call)
webmock_stub.with(&config.request_matcher)
else
webmock_stub.with(config.request_matcher)
end
end

webmock_stub.to_rack(FakeServiceWrapper.new(config))
end

def allowlist_service(config)
Expand All @@ -153,8 +160,17 @@ def url_to_regexp(url)
end

def ensure_non_duplicate_stub(config)
raise "Invalid config for #{config.service_class_name}. Already stubbed url #{config.full_url}" if stubbed_urls.include?(config.full_url)
stubbed_urls << config.full_url
already_stubbed_url = stubbed_urls
.select { |stubbed_url| stubbed_url[0] == config.full_url }
.find { |stubbed_url| (stubbed_url[1].blank? || config.request_matcher.blank? || stubbed_url[1] == config.request_matcher.to_s) }

if already_stubbed_url
error_message = "Invalid config for #{config.service_class_name}. Already stubbed url #{config.full_url}"
error_message << " with #{already_stubbed_url[1]}" if already_stubbed_url[1].present?
raise error_message
end

stubbed_urls << [config.full_url, config.request_matcher.to_s]
end

def load_configs!
Expand Down
40 changes: 40 additions & 0 deletions spec/webvalve/fake_service_config_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ def self.name

subject { described_class.new service_class_name: fake_service.name }

describe 'initialization' do
it 'accepts a custom url' do
config = described_class.new(service_class_name: fake_service.name, url: 'http://custom.dev')
expect(config.full_url).to eq 'http://custom.dev'
end

it 'accepts request_matcher' do
request_matcher = { foo: 'bar' }
config = described_class.new(service_class_name: fake_service.name, request_matcher: request_matcher)
expect(config.request_matcher).to eq request_matcher
end
end

describe '.explicitly_enabled?' do
it 'returns false when DUMMY_ENABLED is unset' do
expect(subject.explicitly_enabled?).to eq false
Expand Down Expand Up @@ -112,4 +125,31 @@ def self.name
end
end
end

describe '.full_url' do
it 'returns the custom service url when provided' do
with_env 'DUMMY_API_URL' => 'http://default.dev' do
config = described_class.new(service_class_name: fake_service.name, url: 'http://custom.dev')
expect(config.full_url).to eq 'http://custom.dev'
end
end

it 'returns the default service url when custom url is not provided' do
with_env 'DUMMY_API_URL' => 'http://default.dev' do
expect(subject.full_url).to eq 'http://default.dev'
end
end
end

describe '.request_matcher' do
it 'returns the provided request params' do
params = { foo: 'bar' }
config = described_class.new(service_class_name: fake_service.name, request_matcher: params)
expect(config.request_matcher).to eq params
end

it 'returns nil when no request params are provided' do
expect(subject.request_matcher).to be_nil
end
end
end
100 changes: 100 additions & 0 deletions spec/webvalve/fake_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,105 @@ def self.name
end
end
end

context "when we specify a request matcher as a hash" do
it 'returns the result from the fake when a mapped route is requested' do
with_env 'DUMMY_API_URL' => 'http://dummy.dev' do
WebValve.register subject.name, request_matcher: { query: {action: "foo"} }
WebValve.setup

expect(Net::HTTP.get(URI('http://dummy.dev/widgets?action=foo'))).to eq({ result: 'it works!' }.to_json)
end
end

it "does not return the result from the fake when the request matcher doesn't match" do
with_env 'DUMMY_API_URL' => 'http://dummy.dev' do
WebValve.register subject.name, request_matcher: { query: {action: "bar"} }
WebValve.setup

expect { Net::HTTP.get(URI('http://dummy.dev/widgets?action=foo')) }
.to raise_error(WebMock::NetConnectNotAllowedError, /Real HTTP connections are disabled/)
end
end

context "with another fake service" do
let(:another_fake_service) do
Class.new(described_class) do
def self.name
'FakeAnother'
end

get '/widgets' do
json({ result: 'it works again!' })
end
end
end

before do
stub_const('FakeAnother', another_fake_service)
end

it "does not return the result from the fake when the request matcher doesn't match" do
with_env 'DUMMY_API_URL' => 'http://dummy.dev', 'ANOTHER_API_URL' => 'http://dummy.dev' do
WebValve.register subject.name, request_matcher: { query: { action: "foo" } }
WebValve.register another_fake_service.name, request_matcher: { query: { action: "bar" } }
WebValve.setup

expect(Net::HTTP.get(URI('http://dummy.dev/widgets?action=foo'))).to eq({ result: 'it works!' }.to_json)
expect(Net::HTTP.get(URI('http://dummy.dev/widgets?action=bar'))).to eq({ result: 'it works again!' }.to_json)
end
end
end
end

context "when we specify a request matcher as a proc" do
it 'returns the result from the fake when a mapped route is requested' do
with_env 'DUMMY_API_URL' => 'http://dummy.dev' do
WebValve.register subject.name, request_matcher: ->(request) { URI.decode_www_form(request.uri.query).to_h['action'] == 'foo' }
WebValve.setup

expect(Net::HTTP.get(URI('http://dummy.dev/widgets?action=foo'))).to eq({ result: 'it works!' }.to_json)
end
end

it "does not return the result from the fake when the request matcher doesn't match" do
with_env 'DUMMY_API_URL' => 'http://dummy.dev' do
WebValve.register subject.name, request_matcher: ->(request) { URI.decode_www_form(request.uri.query).to_h['action'] == 'bar' }
WebValve.setup

expect { Net::HTTP.get(URI('http://dummy.dev/widgets?action=foo')) }
.to raise_error(WebMock::NetConnectNotAllowedError, /Real HTTP connections are disabled/)
end
end

context "with another fake service" do
let(:another_fake_service) do
Class.new(described_class) do
def self.name
'FakeAnother'
end

get '/widgets' do
json({ result: 'it works again!' })
end
end
end

before do
stub_const('FakeAnother', another_fake_service)
end

it "does not return the result from the fake when the request matcher doesn't match" do
with_env 'DUMMY_API_URL' => 'http://dummy.dev', 'ANOTHER_API_URL' => 'http://dummy.dev' do
WebValve.register subject.name, request_matcher: ->(request) { URI.decode_www_form(request.uri.query).to_h['action'] == 'foo' }
WebValve.register another_fake_service.name, request_matcher: ->(request) { URI.decode_www_form(request.uri.query).to_h['action'] == 'bar' }
WebValve.setup

expect(Net::HTTP.get(URI('http://dummy.dev/widgets?action=foo'))).to eq({ result: 'it works!' }.to_json)
expect(Net::HTTP.get(URI('http://dummy.dev/widgets?action=bar'))).to eq({ result: 'it works again!' }.to_json)
end
end
end
end
end
end
30 changes: 30 additions & 0 deletions spec/webvalve/manager_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,36 @@
end
end

it 'raises with duplicate stubbed urls with same `request_matcher` parameter' do
service = class_double(WebValve::FakeService, name: 'FakeSomething')
other_service = class_double(WebValve::FakeService, name: 'FakeOtherThing')

subject.register service.name, url: "http://something.dev", request_matcher: { body: 'foo' }
subject.register other_service.name, url: "http://something.dev", request_matcher: { body: 'foo' }

expect { subject.setup }.to raise_error('Invalid config for FakeOtherThing. Already stubbed url http://something.dev with {:body=>"foo"}')
end

it 'raises with duplicate stubbed urls when `request_matcher` parameter is nil' do
service = class_double(WebValve::FakeService, name: 'FakeSomething')
other_service = class_double(WebValve::FakeService, name: 'FakeOtherThing')

subject.register service.name, url: "http://something.dev"
subject.register other_service.name, url: "http://something.dev", request_matcher: { body: 'foo' }

expect { subject.setup }.to raise_error('Invalid config for FakeOtherThing. Already stubbed url http://something.dev')
end

it 'does not raise error with duplicate stubbed urls but different `request_matcher` parameters' do
service = class_double(WebValve::FakeService, name: 'FakeSomething')
other_service = class_double(WebValve::FakeService, name: 'FakeOtherThing')

subject.register service.name, url: "http://something.dev", request_matcher: { body: 'bar' }
subject.register other_service.name, url: "http://something.dev", request_matcher: { body: 'foo' }

expect { subject.setup }.not_to raise_error
end

it 'does not raise with different HTTP auth patterns' do
disabled_service = class_double(WebValve::FakeService, name: 'FakeSomething')
other_disabled_service = class_double(WebValve::FakeService, name: 'FakeOtherThing')
Expand Down