Black and white profile picture of Konstantin Tennhard
Staff Software Developer

Hi, my name is Konstantin! I'm a software developer living in Montreal, Canada. I currently work for Stripe as a Staff Software Developer. Before that I was at Shopify for 7 years.

I'm passionate about science, technology, music production and photography. In my free time, I maintain a handful of open source projects on GitHub, occasionally publish music production tutorials on YouTube and share some of my music on SoundCloud.

Request Interceptor

Request interceptor is a library for simulating foreign APIs using Sinatra applications.

Installation

Add this line to your application's Gemfile:

gem 'request_interceptor'

And then execute:

$ bundle

Or install it yourself as:

$ gem install request_interceptor

Usage

Once installed, request interceptors can be defined as follows:

app = RequestInterceptor.define do
  get "/" do
    content_type "text/plain"
    "Hello World"
  end
end

By default, request interceptors are Sinatra applications, but any Rack compatible application works. To intercept HTTP requests, the code performing the request must be wrapped in an RequestInterceptor#run block:

interceptor = RequestInterceptor.new(/.*example\.com$/ => app)
interceptor.run do
  Net::HTTP.get(URI("http://example.com/")) # => "Hello World"
end

RequestInterceptor instances are initialized with hash mapping hostname patterns to applications. The patterns are later matched against the hostname of the URI associated with a particular request. In case of a match, the corresponding application is used to serve the request. Otherwise, a real HTTP request is performed.

For the sake of convenience, the code above can be shortened using RequestInterceptor.run:

log = RequestInterceptor.run(/.*example\.com$/ => app) do
  Net::HTTP.get(URI("http://example.com/")) # => "Hello World"
end

In both cases, the result is a transaction log. Each entry in the transaction log is a RequestInterceptor::Transaction. A transaction is simply request/response pair. The request can be obtained using the equally named #request method. The #response method returns the response that corresponds to the particular request. The code above would result in a transaction log with one entry:

log.count # => 1
log.first.request # => Rack::MockRequest
log.first.response # => Rack::MockResponse

Pre-configured hostnames and interceptor customization

Interceptors further support pre-configured hostnames and customization of existing interceptors:

customized_app = app.customize do
  host "example.de"

  get "/" do
    content_type "text/plain"
    "Hallo Welt"
  end
end

customized_app.intercept do
  response = Net::HTTP.get(URI("http://example.de/")) # => "Hello World"
  response == "Hallo Welt" # => true
end

These two features are only available for Sinatra based interceptors that inherit from RequestInterceptor::Application, which is the default for all interceptors that have been defined using RequestInterceptor.define if no other template class through RequestInterceptor.template= has been configured.

Constructor argument forwarding

Any arguments provided to the .intercept method are forwarded to the interceptor's constructor:

multilingual_app = RequestInterceptor.define do
  host "example.com"

  attr_reader :language

  def initialize(language = nil)
    @language = language
    super()
  end

  get "/" do
    content_type "text/plain"
    language == :de ? "Hallo Welt" : "Hello World"
  end
end

multilingual_app.intercept(:de) do
  response = Net::HTTP.get(URI("http://example.com/"))
  response == "Hallo Welt" # => true
end

multilingual_app.intercept do
  response = Net::HTTP.get(URI("http://example.com/"))
  response == "Hello World" # => true
end

RSpec Integration

Request Interceptor has built in support for RSpec. The matcher that ships with the gem supports matching

Unless otherwise specified, the matcher uses RSpec's own equality matcher for all comparisons:

hello_world_app = RequestInterceptor.define do
  host "example.com"
  
  get "/" do
    # ...
  end
  
  post "/articles" do
    # ...
  end
end

log = hello_world_app.intercept do
  Net::HTTP.get(URI("http://example.com/"))
end

expect(log).to contain_intercepted_request(:get, "/")

The example above only succeeds if the path is exactly "/". While this is generally desired for matching the path or the request method, it can be too restrictive when matching against the query or the request body. The example below demonstrates how to use with_body in conjunction with RSpec's own including matcher to match against a subset of the request body.

log = hello_world_app.intercept do
  uri = URI("http://example.com/")
  client = Net::HTTP.new(uri.host, uri.port)
  request = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
  request.body = "{title: \"Hello World!\", content: \"Some irrelevant content.\"}"
  client.request(request)
end

expect(log).to contain_intercepted_request(:post, "/articles").with_body(including(title: "Hello World!"))

As the example above indicates, Request Interceptor automatically parses JSON request bodies to make matching easier.

Similar to with_body, the RSpec matcher also provides a with_query method, to match against query parameters:

log = hello_world_app.intercept do
  Net::HTTP.get(URI("http://example.com/?q=hello+world"))
end

expect(log).to contain_intercepted_request(:get, "/").with_query(q: "hello+world")

Lastly, count can be used to specify the number of times a particular request is to be expected. It takes an integer or a range as its argument.

log = hello_world_app.intercept do
  Net::HTTP.get(URI("http://example.com/"))
  Net::HTTP.get(URI("http://example.com/"))
end

expect(log).to contain_intercepted_request(:get, "/").count(2)

Contributing

Bug reports and pull requests are welcome on GitHub at t6d/request_interceptor.

License

The gem is available as open source under the terms of the MIT License.