diff --git a/README.md b/README.md index 970c0ee..83bc590 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/webvalve/fake_service_config.rb b/lib/webvalve/fake_service_config.rb index 9f70b58..fc8684c 100644 --- a/lib/webvalve/fake_service_config.rb +++ b/lib/webvalve/fake_service_config.rb @@ -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? diff --git a/lib/webvalve/manager.rb b/lib/webvalve/manager.rb index b5158e8..9008a19 100644 --- a/lib/webvalve/manager.rb +++ b/lib/webvalve/manager.rb @@ -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) @@ -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! diff --git a/spec/webvalve/fake_service_config_spec.rb b/spec/webvalve/fake_service_config_spec.rb index d1acb71..391259c 100644 --- a/spec/webvalve/fake_service_config_spec.rb +++ b/spec/webvalve/fake_service_config_spec.rb @@ -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 @@ -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 diff --git a/spec/webvalve/fake_service_spec.rb b/spec/webvalve/fake_service_spec.rb index 3728fa2..6fb6f01 100644 --- a/spec/webvalve/fake_service_spec.rb +++ b/spec/webvalve/fake_service_spec.rb @@ -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 diff --git a/spec/webvalve/manager_spec.rb b/spec/webvalve/manager_spec.rb index b357872..e605045 100644 --- a/spec/webvalve/manager_spec.rb +++ b/spec/webvalve/manager_spec.rb @@ -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')