├── .rspec ├── Gemfile ├── circle.yml ├── lib ├── request_interceptor │ ├── version.rb │ ├── webmock_patches.rb │ ├── application.rb │ ├── transaction.rb │ ├── webmock_manager.rb │ └── matchers.rb └── request_interceptor.rb ├── .travis.yml ├── .gitignore ├── bin ├── console └── setup ├── Rakefile ├── spec ├── spec_helper.rb ├── rspec_matchers_spec.rb └── request_interceptor_spec.rb ├── LICENSE.txt ├── request_interceptor.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | ruby: 3 | version: 2.2.2 4 | -------------------------------------------------------------------------------- /lib/request_interceptor/version.rb: -------------------------------------------------------------------------------- 1 | class RequestInterceptor 2 | VERSION = "1.0.0" 3 | end 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.2.2 4 | before_install: gem install bundler -v 1.10.6 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "request_interceptor" 5 | 6 | require "pry" 7 | Pry.start 8 | 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'pry' 3 | 4 | require 'request_interceptor' 5 | require 'request_interceptor/matchers' 6 | -------------------------------------------------------------------------------- /lib/request_interceptor/webmock_patches.rb: -------------------------------------------------------------------------------- 1 | module RequestInterceptor::WebMockPatches 2 | def enable! 3 | @enabled = true 4 | super 5 | end 6 | 7 | def disable! 8 | @enabled = false 9 | super 10 | end 11 | 12 | def enabled? 13 | !!@enabled 14 | end 15 | 16 | def disabled? 17 | !enabled 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/request_interceptor/application.rb: -------------------------------------------------------------------------------- 1 | require "sinatra/base" 2 | 3 | class RequestInterceptor::Application < Sinatra::Base 4 | class << self 5 | def customize(&customizations) 6 | RequestInterceptor.define(self, &customizations) 7 | end 8 | 9 | def intercept(pattern, *args, &test) 10 | RequestInterceptor.run(pattern => self.new(*args), &test) 11 | end 12 | 13 | def match(pattern) 14 | define_singleton_method(:intercept) do |*args, &test| 15 | super(pattern, *args, &test) 16 | end 17 | end 18 | 19 | alias host match 20 | end 21 | 22 | configure do 23 | disable :show_exceptions 24 | enable :raise_errors 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Konstantin Tennhard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /request_interceptor.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'request_interceptor/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "request_interceptor" 8 | spec.version = RequestInterceptor::VERSION 9 | spec.authors = ["Konstantin Tennhard", "Kevin Hughes"] 10 | spec.email = ["me@t6d.de", "kevinhughes27@gmail.com"] 11 | 12 | spec.summary = %q{Sinatra based foreign API simulation} 13 | spec.homepage = "http://github.com/t6d/request_interceptor" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 17 | spec.bindir = "exe" 18 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_runtime_dependency "sinatra" 22 | spec.add_runtime_dependency "rack" 23 | spec.add_runtime_dependency "webmock", "~> 3.0" 24 | spec.add_runtime_dependency "smart_properties", "~> 1.0" 25 | spec.add_runtime_dependency "activesupport", ">= 4.0" 26 | 27 | spec.add_development_dependency "bundler", "~> 1.9" 28 | spec.add_development_dependency "rake", "~> 10.0" 29 | spec.add_development_dependency "rspec" 30 | spec.add_development_dependency "pry" 31 | end 32 | -------------------------------------------------------------------------------- /lib/request_interceptor/transaction.rb: -------------------------------------------------------------------------------- 1 | class RequestInterceptor::Transaction 2 | class HTTPMessage 3 | include SmartProperties 4 | property :body 5 | 6 | def initialize(*args, headers: {}, **kwargs) 7 | @headers = headers.dup 8 | super(*args, **kwargs) 9 | end 10 | 11 | def [](name) 12 | @headers[name] 13 | end 14 | 15 | def []=(name, value) 16 | @headers[name] = value 17 | end 18 | 19 | def headers 20 | @headers.dup 21 | end 22 | end 23 | 24 | class Request < HTTPMessage 25 | property :method, converts: ->(method) { method.to_s.upcase.freeze }, required: true 26 | property :uri, accepts: URI, converts: ->(uri) { URI(uri.to_s) }, required: true 27 | property :body 28 | 29 | def method?(method) 30 | normalized_method = method.to_s.upcase 31 | normalized_method == self.method 32 | end 33 | 34 | def path?(path) 35 | path === self.path 36 | end 37 | 38 | def path 39 | uri.path 40 | end 41 | 42 | def request_uri?(request_uri) 43 | request_uri === self.request_uri 44 | end 45 | 46 | def request_uri 47 | uri.request_uri 48 | end 49 | 50 | def query 51 | Rack::Utils.parse_nested_query(uri.query).deep_symbolize_keys! 52 | end 53 | 54 | def query?(query_matcher) 55 | return true if query_matcher.nil? 56 | query_matcher === self.query 57 | end 58 | 59 | def body?(body_matcher) 60 | return true if body_matcher.nil? 61 | 62 | body = case self["Content-Type"] 63 | when "application/json" 64 | ActiveSupport::JSON.decode(self.body).deep_symbolize_keys! 65 | else 66 | self.body 67 | end 68 | 69 | body_matcher === body 70 | end 71 | end 72 | 73 | class Response < HTTPMessage 74 | property :status_code, required: true 75 | property :body 76 | end 77 | 78 | include SmartProperties 79 | 80 | property :request, accepts: Request 81 | property :response, accepts: Response 82 | end 83 | 84 | -------------------------------------------------------------------------------- /lib/request_interceptor/webmock_manager.rb: -------------------------------------------------------------------------------- 1 | class RequestInterceptor::WebMockManager 2 | WebMockConfigurationCache = Struct.new(:request_stubs, :callbacks, :allow_net_connect, :allow_localhost, :show_body_diff, :show_stubbing_instructions, :enabled_previously) 3 | 4 | def initialize(applications, callback = nil) 5 | @applications = applications 6 | @callback = callback 7 | end 8 | 9 | def run_simulation 10 | original_webmock_configuration = setup 11 | 12 | yield 13 | ensure 14 | teardown(original_webmock_configuration) 15 | end 16 | 17 | protected 18 | 19 | attr_reader :callback 20 | attr_reader :applications 21 | 22 | private 23 | 24 | def setup 25 | original_configuration = WebMockConfigurationCache.new 26 | original_configuration.enabled_previously = WebMock.enabled? 27 | original_configuration.request_stubs = WebMock::StubRegistry.instance.request_stubs.dup || [] 28 | original_configuration.callbacks = WebMock::CallbackRegistry.callbacks.dup || [] 29 | original_configuration.allow_net_connect = WebMock::Config.instance.allow_net_connect 30 | original_configuration.allow_localhost = WebMock::Config.instance.allow_localhost 31 | original_configuration.show_body_diff = WebMock::Config.instance.show_body_diff 32 | original_configuration.show_stubbing_instructions = WebMock::Config.instance.show_stubbing_instructions 33 | 34 | WebMock.after_request(&callback) unless callback.nil? 35 | 36 | applications.each do |application| 37 | WebMock.stub_request(:any, application.pattern).to_rack(application) 38 | end 39 | 40 | WebMock.allow_net_connect! 41 | WebMock.hide_body_diff! 42 | WebMock.hide_stubbing_instructions! 43 | WebMock.enable! 44 | 45 | original_configuration 46 | end 47 | 48 | def teardown(original_configuration) 49 | WebMock::Config.instance.allow_net_connect = original_configuration.allow_net_connect 50 | WebMock::Config.instance.allow_localhost = original_configuration.allow_localhost 51 | WebMock::Config.instance.show_body_diff = original_configuration.show_body_diff 52 | WebMock::Config.instance.show_stubbing_instructions = original_configuration.show_stubbing_instructions 53 | WebMock::CallbackRegistry.reset 54 | original_configuration.callbacks.each do |callback_settings| 55 | WebMock.after_request(callback_settings[:options], &callback_settings[:block]) 56 | end 57 | WebMock::StubRegistry.instance.request_stubs = original_configuration.request_stubs 58 | WebMock.disable! unless original_configuration.enabled_previously 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/request_interceptor.rb: -------------------------------------------------------------------------------- 1 | require "request_interceptor/version" 2 | 3 | require "active_support/core_ext/hash" 4 | require "active_support/json" 5 | require "rack" 6 | require "smart_properties" 7 | require "webmock" 8 | 9 | 10 | class RequestInterceptor 11 | class ApplicationWrapper < SimpleDelegator 12 | attr_reader :pattern 13 | 14 | def initialize(pattern, application) 15 | @pattern = 16 | case pattern 17 | when String 18 | %r{://#{Regexp.escape(pattern)}/} 19 | else 20 | pattern 21 | end 22 | 23 | super(application) 24 | end 25 | 26 | def intercepts?(uri) 27 | !!pattern.match(uri.normalize.to_s) 28 | end 29 | end 30 | 31 | def self.template=(template) 32 | @template = 33 | case template 34 | when Proc 35 | Class.new(Application, &template) 36 | else 37 | template 38 | end 39 | end 40 | 41 | def self.template 42 | @template || Application 43 | end 44 | 45 | def self.define(super_class = nil, &application_definition) 46 | Class.new(super_class || template, &application_definition) 47 | end 48 | 49 | def self.run(applications, &simulation) 50 | new(applications).run(&simulation) 51 | end 52 | 53 | attr_reader :applications 54 | attr_reader :transactions 55 | 56 | def initialize(applications) 57 | @applications = applications.map { |pattern, application| ApplicationWrapper.new(pattern, application) } 58 | @transactions = [] 59 | end 60 | 61 | def run(&simulation) 62 | transactions = [] 63 | 64 | request_logging = ->(request, response) do 65 | next unless applications.any? { |application| application.intercepts?(request.uri) } 66 | transactions << Transaction.new( 67 | request: Transaction::Request.new( 68 | method: request.method, 69 | uri: URI(request.uri.to_s), 70 | headers: request.headers, 71 | body: request.body 72 | ), 73 | response: Transaction::Response.new( 74 | status_code: response.status, 75 | headers: response.headers, 76 | body: response.body 77 | ) 78 | ) 79 | end 80 | 81 | WebMockManager.new(applications, request_logging).run_simulation(&simulation) 82 | 83 | transactions 84 | end 85 | end 86 | 87 | require_relative "request_interceptor/application" 88 | require_relative "request_interceptor/matchers" if defined? RSpec 89 | require_relative "request_interceptor/transaction" 90 | require_relative "request_interceptor/webmock_manager" 91 | require_relative "request_interceptor/webmock_patches" 92 | 93 | WebMock.singleton_class.prepend(RequestInterceptor::WebMockPatches) 94 | -------------------------------------------------------------------------------- /spec/rspec_matchers_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RequestInterceptor::Matchers::InterceptedRequest do 4 | it "matches againest the HTTP method and the request path by default" do 5 | transactions = [ 6 | transaction(:get, "http://example.com/articles"), 7 | transaction(:post, "http://example.com/articles") 8 | ] 9 | 10 | matcher = described_class.new("GET", "/articles") 11 | 12 | expect(matcher.matches?(transactions)).to be(true) 13 | end 14 | 15 | it "supports matching the request count" do 16 | transactions = [ 17 | transaction(:get, "http://example.com/articles"), 18 | transaction(:get, "http://example.com/articles") 19 | ] 20 | 21 | matcher = described_class.new("GET", "/articles").count(2) 22 | expect(matcher.matches?(transactions)).to be(true) 23 | 24 | matcher = described_class.new("GET", "/articles").count(1) 25 | expect(matcher.matches?(transactions)).to be(false) 26 | end 27 | 28 | 29 | it "supports matching the request query parameters" do 30 | transactions = [ 31 | transaction(:get, "http://example.com/articles?param=1"), 32 | transaction(:get, "http://example.com/articles") 33 | ] 34 | 35 | matcher = described_class.new("GET", "/articles").with_query(including(param: "1")) 36 | expect(matcher.matches?(transactions)).to be(true) 37 | 38 | matcher = described_class.new("GET", "/articles").with_query(including(non_existing_param: "1")) 39 | expect(matcher.matches?(transactions)).to be(false) 40 | end 41 | 42 | it "supports matching the request body" do 43 | new_article = {title: "Hello World!", content: "This is my first article."}.to_json 44 | transactions = [ 45 | transaction(:post, "http://example.com/articles", new_article, "Content-Type" => "application/json"), 46 | ] 47 | 48 | matcher = described_class.new("POST", "/articles").with_body(including(title: "Hello World!")) 49 | expect(matcher.matches?(transactions)).to be(true) 50 | 51 | matcher = described_class.new("POST", "/articles").with_body(including(title: "Hello Ruby!")) 52 | expect(matcher.matches?(transactions)).to be(false) 53 | end 54 | 55 | it "includes method and path into the description" do 56 | matcher = described_class.new("POST", "/articles") 57 | expect(matcher.description).to eq("should intercept a POST request to /articles") 58 | end 59 | 60 | it "includes expected method, path, query and body in the failure message" do 61 | matcher = described_class.new("POST", "/articles").with_query(including(id: "1")).with_body(matching("Hello World!")) 62 | expect(matcher.failure_message).to match("expected: POST /articles with query including {:id => \"1\"} and with body matching \"Hello World!\"") 63 | end 64 | 65 | it "include similar requests in the failure message; that is request with matching method and path" do 66 | transactions = [ 67 | transaction(:post, "http://example.com/articles?id=2", "Hello World"), 68 | transaction(:post, "http://example.com/articles?id=1", "Hola Mundo") 69 | ] 70 | 71 | matcher = described_class.new("POST", "/articles").with_query(including(id: 1)).with_body(matching("Hello World!")) 72 | matcher.matches?(transactions) 73 | 74 | expect(matcher.failure_message).to match("got: POST /articles with query {:id=>\"2\"} and with body \"Hello World\"") 75 | expect(matcher.failure_message).to match("POST /articles with query {:id=>\"1\"} and with body \"Hola Mundo\"") 76 | end 77 | 78 | it "outputs if no similar request could be found" do 79 | matcher = described_class.new("POST", "/articles").with_query(including(id: 1)).with_body(matching("Hello World!")) 80 | expect(matcher.failure_message).to match("got: none") 81 | end 82 | 83 | it "includes expected method, path, query and body in the negated failure message" do 84 | matcher = described_class.new("POST", "/articles").with_query(including(id: "1")).with_body(matching("Hello World!")) 85 | expect(matcher.failure_message_when_negated).to match("intercepted a POST request to /articles with query including {:id => \"1\"} and with body matching \"Hello World!\"") 86 | end 87 | 88 | private 89 | 90 | def transaction(method, uri, body = nil, headers = {}) 91 | RequestInterceptor::Transaction.new( 92 | request: RequestInterceptor::Transaction::Request.new(method: method, uri: uri, body: body, headers: headers), 93 | response: RequestInterceptor::Transaction::Response.new(status_code: 200) 94 | ) 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/request_interceptor/matchers.rb: -------------------------------------------------------------------------------- 1 | module RequestInterceptor::Matchers 2 | class MatcherWrapper < SimpleDelegator 3 | def ===(object) 4 | matches?(object) 5 | end 6 | 7 | def to_s 8 | description 9 | end 10 | end 11 | 12 | class InterceptedRequest 13 | attr_reader :method 14 | attr_reader :path 15 | attr_reader :query 16 | attr_reader :body 17 | attr_reader :transactions 18 | 19 | def initialize(method, path) 20 | @method = method 21 | @path = path 22 | @count = (1..Float::INFINITY) 23 | @transactions = [] 24 | end 25 | 26 | ## 27 | # Chains 28 | ## 29 | 30 | def count(count = nil) 31 | return @count if count.nil? 32 | 33 | @count = 34 | case count 35 | when Integer 36 | (count .. count) 37 | when Range 38 | count 39 | else 40 | raise ArgumentError 41 | end 42 | 43 | self 44 | end 45 | 46 | def with_query(query) 47 | query_matcher = 48 | if query.respond_to?(:matches?) && query.respond_to?(:failure_message) 49 | query 50 | else 51 | RSpec::Matchers::BuiltIn::Eq.new(query) 52 | end 53 | 54 | @query = MatcherWrapper.new(query_matcher) 55 | 56 | self 57 | end 58 | 59 | def with_body(body) 60 | body_matcher = 61 | if body.respond_to?(:matches?) && body.respond_to?(:failure_message) 62 | body 63 | else 64 | RSpec::Matchers::BuiltIn::Eq.new(body) 65 | end 66 | 67 | @body = MatcherWrapper.new(body_matcher) 68 | 69 | self 70 | end 71 | 72 | ## 73 | # Rspec Matcher Protocol 74 | ## 75 | 76 | def matches?(transactions) 77 | @transactions = transactions 78 | count.cover?(matching_transactions.count) 79 | end 80 | 81 | def failure_message 82 | expected_request = "#{format_method(method)} #{path}" 83 | expected_request += " with query #{format_object(query)}" if query 84 | expected_request += " and" if query && body 85 | expected_request += " with body #{format_object(body)}" if body 86 | 87 | similar_intercepted_requests = similar_transactions.map.with_index do |transaction, index| 88 | method = format_method(transaction.request.method) 89 | path = transaction.request.path 90 | query = transaction.request.query 91 | body = transaction.request.body 92 | indentation_required = index != 0 93 | 94 | message = "#{method} #{path}" 95 | message += (query.nil? || query.empty?) ? " with no query" : " with query #{format_object(query)}" 96 | message += (body.nil? || body.empty?) ? " and with no body" : " and with body #{format_object(body)}" 97 | message = " " * 10 + message if indentation_required 98 | message 99 | end 100 | 101 | similar_intercepted_requests = ["none"] if similar_intercepted_requests.none? 102 | 103 | "\nexpected: #{expected_request}" \ 104 | "\n got: #{similar_intercepted_requests.join("\n")}" 105 | end 106 | 107 | def failure_message_when_negated 108 | message = "intercepted a #{format_method(method)} request to #{path}" 109 | message += " with query #{format_object(query)}" if query 110 | message += " and" if query && body 111 | message += " with body #{format_object(body)}" if body 112 | message 113 | end 114 | 115 | def description 116 | "should intercept a #{format_method(method)} request to #{path}" 117 | end 118 | 119 | ## 120 | # Helper methods 121 | ## 122 | 123 | private 124 | 125 | def format_method(method) 126 | method.to_s.upcase 127 | end 128 | 129 | def format_object(object) 130 | RSpec::Support::ObjectFormatter.format(object) 131 | end 132 | 133 | def matching_transactions 134 | transactions.select do |transaction| 135 | request = transaction.request 136 | request.method?(method) && 137 | request.path?(path) && 138 | request.query?(query) && 139 | request.body?(body) 140 | end 141 | end 142 | 143 | def similar_transactions 144 | transactions.select do |transaction| 145 | request = transaction.request 146 | request.method?(method) && request.path?(path) 147 | end 148 | end 149 | end 150 | 151 | def have_intercepted_request(*args) 152 | InterceptedRequest.new(*args) 153 | end 154 | alias contain_intercepted_request have_intercepted_request 155 | end 156 | 157 | RSpec.configure do |config| 158 | config.include(RequestInterceptor::Matchers) 159 | end 160 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Request Interceptor 2 | 3 | Request interceptor is a library for simulating foreign APIs using Sinatra applications. 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | ```ruby 10 | gem 'request_interceptor' 11 | ``` 12 | 13 | And then execute: 14 | 15 | $ bundle 16 | 17 | Or install it yourself as: 18 | 19 | $ gem install request_interceptor 20 | 21 | ## Usage 22 | 23 | Once installed, request interceptors can be defined as follows: 24 | 25 | ```ruby 26 | app = RequestInterceptor.define do 27 | get "/" do 28 | content_type "text/plain" 29 | "Hello World" 30 | end 31 | end 32 | ``` 33 | 34 | By default, request interceptors are `Sinatra` applications, but any `Rack` compatible application works. 35 | To intercept HTTP requests, the code performing the request must be wrapped in an `RequestInterceptor#run` block: 36 | 37 | ```ruby 38 | interceptor = RequestInterceptor.new(/.*example\.com$/ => app) 39 | interceptor.run do 40 | Net::HTTP.get(URI("http://example.com/")) # => "Hello World" 41 | end 42 | ``` 43 | 44 | `RequestInterceptor` instances are initialized with hash mapping hostname patterns to applications. 45 | The patterns are later matched against the hostname of the URI associated with a particular request. 46 | In case of a match, the corresponding application is used to serve the request. 47 | Otherwise, a real HTTP request is performed. 48 | 49 | For the sake of convenience, the code above can be shortened using `RequestInterceptor.run`: 50 | 51 | ```ruby 52 | log = RequestInterceptor.run(/.*example\.com$/ => app) do 53 | Net::HTTP.get(URI("http://example.com/")) # => "Hello World" 54 | end 55 | ``` 56 | 57 | In both cases, the result is a transaction log. 58 | Each entry in the transaction log is a `RequestInterceptor::Transaction`. 59 | A transaction is simply request/response pair. 60 | The request can be obtained using the equally named `#request` method. 61 | The `#response` method returns the response that corresponds to the particular request. 62 | The code above would result in a transaction log with one entry: 63 | 64 | ```ruby 65 | log.count # => 1 66 | log.first.request # => Rack::MockRequest 67 | log.first.response # => Rack::MockResponse 68 | ``` 69 | 70 | ### Pre-configured hostnames and interceptor customization 71 | 72 | Interceptors further support pre-configured hostnames and customization of existing interceptors: 73 | 74 | ```ruby 75 | customized_app = app.customize do 76 | host "example.de" 77 | 78 | get "/" do 79 | content_type "text/plain" 80 | "Hallo Welt" 81 | end 82 | end 83 | 84 | customized_app.intercept do 85 | response = Net::HTTP.get(URI("http://example.de/")) # => "Hello World" 86 | response == "Hallo Welt" # => true 87 | end 88 | ``` 89 | 90 | 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. 91 | 92 | ### Constructor argument forwarding 93 | 94 | Any arguments provided to the `.intercept` method are forwarded to the interceptor's constructor: 95 | 96 | ```ruby 97 | multilingual_app = RequestInterceptor.define do 98 | host "example.com" 99 | 100 | attr_reader :language 101 | 102 | def initialize(language = nil) 103 | @language = language 104 | super() 105 | end 106 | 107 | get "/" do 108 | content_type "text/plain" 109 | language == :de ? "Hallo Welt" : "Hello World" 110 | end 111 | end 112 | 113 | multilingual_app.intercept(:de) do 114 | response = Net::HTTP.get(URI("http://example.com/")) 115 | response == "Hallo Welt" # => true 116 | end 117 | 118 | multilingual_app.intercept do 119 | response = Net::HTTP.get(URI("http://example.com/")) 120 | response == "Hello World" # => true 121 | end 122 | ``` 123 | 124 | ### RSpec Integration 125 | 126 | Request Interceptor has built in support for RSpec. 127 | The matcher that ships with the gem supports matching 128 | 129 | * the request method, 130 | * the path 131 | * the query parameters and 132 | * the request body. 133 | 134 | Unless otherwise specified, the matcher uses RSpec's own equality matcher for all comparisons: 135 | 136 | ```ruby 137 | hello_world_app = RequestInterceptor.define do 138 | host "example.com" 139 | 140 | get "/" do 141 | # ... 142 | end 143 | 144 | post "/articles" do 145 | # ... 146 | end 147 | end 148 | 149 | log = hello_world_app.intercept do 150 | Net::HTTP.get(URI("http://example.com/")) 151 | end 152 | 153 | expect(log).to contain_intercepted_request(:get, "/") 154 | ``` 155 | 156 | The example above only succeeds if the path is exactly `"/"`. 157 | 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. 158 | 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. 159 | 160 | ```ruby 161 | log = hello_world_app.intercept do 162 | uri = URI("http://example.com/") 163 | client = Net::HTTP.new(uri.host, uri.port) 164 | request = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json") 165 | request.body = "{title: \"Hello World!\", content: \"Some irrelevant content.\"}" 166 | client.request(request) 167 | end 168 | 169 | expect(log).to contain_intercepted_request(:post, "/articles").with_body(including(title: "Hello World!")) 170 | ``` 171 | 172 | As the example above indicates, Request Interceptor automatically parses JSON request bodies to make matching easier. 173 | 174 | Similar to `with_body`, the RSpec matcher also provides a `with_query` method, to match against query parameters: 175 | 176 | ```ruby 177 | log = hello_world_app.intercept do 178 | Net::HTTP.get(URI("http://example.com/?q=hello+world")) 179 | end 180 | 181 | expect(log).to contain_intercepted_request(:get, "/").with_query(q: "hello+world") 182 | ``` 183 | 184 | Lastly, `count` can be used to specify the number of times a particular request is to be expected. 185 | It takes an integer or a range as its argument. 186 | 187 | ```ruby 188 | log = hello_world_app.intercept do 189 | Net::HTTP.get(URI("http://example.com/")) 190 | Net::HTTP.get(URI("http://example.com/")) 191 | end 192 | 193 | expect(log).to contain_intercepted_request(:get, "/").count(2) 194 | ``` 195 | 196 | ## Contributing 197 | 198 | Bug reports and pull requests are welcome on GitHub at [t6d/request_interceptor](https://github.com/t6d/request_interceptor). 199 | 200 | ## License 201 | 202 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 203 | 204 | -------------------------------------------------------------------------------- /spec/request_interceptor_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RequestInterceptor do 4 | it 'has a version number' do 5 | expect(RequestInterceptor::VERSION).not_to be nil 6 | end 7 | 8 | let(:example) do 9 | RequestInterceptor.define do 10 | before { content_type 'text/plain' } 11 | before { headers["x-counter"] = env["HTTP_X_COUNTER"].to_i + 1 if env["HTTP_X_COUNTER"] } 12 | 13 | get("/") do 14 | "example.com" 15 | end 16 | 17 | post("/") do 18 | status 201 19 | request.body 20 | end 21 | 22 | put("/") do 23 | status 200 24 | request.body 25 | end 26 | 27 | delete("/") do 28 | halt 202 29 | end 30 | end 31 | end 32 | 33 | let(:google) do 34 | custom_super_class = Class.new do 35 | def self.domain 36 | "google.com" 37 | end 38 | end 39 | 40 | RequestInterceptor.define(custom_super_class) do 41 | def self.call(env) 42 | [200, {}, [domain]] 43 | end 44 | end 45 | end 46 | 47 | subject(:interceptor) do 48 | RequestInterceptor.new(/.*\.example\.com/ => example, /.*\.google\.com/ => google) 49 | end 50 | 51 | it 'should keep a log of all requests and responses' do 52 | log = interceptor.run do 53 | Net::HTTP.get(URI("http://test.example.com/?id=1")) 54 | Net::HTTP.get(URI("http://test.google.com/?id=2")) 55 | end 56 | 57 | expect(log.count).to eq(2) 58 | 59 | expect(log.first.request.path).to eq("/") 60 | expect(log.first.request.uri).to eq(URI("http://test.example.com/?id=1")) 61 | 62 | expect(log.last.request.path).to eq("/") 63 | expect(log.last.request.uri).to eq(URI("http://test.google.com/?id=2")) 64 | 65 | expect(log).to have_intercepted_request(:get, "/").count(2) 66 | expect(log).to have_intercepted_request(:get, "/").count(1).with_query(id: "1") 67 | expect(log).to have_intercepted_request(:get, "/").count(1).with_query(including(id: "2")) 68 | end 69 | 70 | it 'should normalize urls to remove redundant port information before matching them against the hostname' do 71 | interceptor = RequestInterceptor.new("example.com" => example) 72 | log = interceptor.run do 73 | uri = URI("http://example.com/some/path") 74 | uri.port = 80 75 | 76 | Net::HTTP.get(uri) 77 | end 78 | 79 | expect(log.count).to eq(1) 80 | end 81 | 82 | it 'should allow to customize existing request interceptors' do 83 | modified_example = example.customize do 84 | get("/") do 85 | "example.io" 86 | end 87 | end 88 | 89 | modified_example.intercept('example.io') do 90 | response = Net::HTTP.get(URI("http://example.io/")) 91 | expect(response).to eq("example.io") 92 | end 93 | end 94 | 95 | it 'should allow to set a pre-configured hostname for an application' do 96 | modified_example = example.customize do 97 | host "example.io" 98 | 99 | get("/") do 100 | "example.io" 101 | end 102 | end 103 | 104 | modified_example.intercept do 105 | response = Net::HTTP.get(URI("http://example.io/")) 106 | expect(response).to eq("example.io") 107 | end 108 | end 109 | 110 | it 'should allow to forward arguments to the application intializer' do 111 | modified_example = example.customize do 112 | host "example.com" 113 | 114 | attr_reader :language 115 | 116 | def initialize(language = nil) 117 | @language = language 118 | super() 119 | end 120 | 121 | get "/" do 122 | case language 123 | when :de 124 | "Hallo Welt" 125 | else 126 | "Hello World" 127 | end 128 | end 129 | end 130 | 131 | modified_example.intercept(:de) do 132 | response = Net::HTTP.get(URI("http://example.com/")) 133 | expect(response).to eq("Hallo Welt") 134 | end 135 | 136 | modified_example.intercept do 137 | response = Net::HTTP.get(URI("http://example.com/")) 138 | expect(response).to eq("Hello World") 139 | end 140 | end 141 | 142 | context 'when using the Net::HTTP convenience methods' do 143 | around do |spec| 144 | interceptor.run { spec.run } 145 | end 146 | 147 | it 'support .get' do 148 | expect(Net::HTTP.get(URI("http://test.example.com"))).to eq("example.com") 149 | expect(Net::HTTP.get(URI("http://test.google.com"))).to eq("google.com") 150 | 151 | Net::HTTP.get(URI("http://test.example.com")) do |response| 152 | expect(response).kind_of?(Net::HTTPOK) 153 | end 154 | end 155 | 156 | it 'supports #post_form' do 157 | Net::HTTP.post_form(URI("http://test.example.com"), {}) do |response| 158 | expect(response).kind_of?(Net::HTTPCreated) 159 | end 160 | end 161 | end 162 | 163 | context 'when sending requests through an Net::HTTP instance' do 164 | let(:uri) { URI.parse("http://test.example.com/") } 165 | let(:http) { Net::HTTP.new(uri.host) } 166 | 167 | around do |spec| 168 | interceptor.run { spec.run } 169 | end 170 | 171 | it 'intercepts GET requests when using Net::HTTP#request directly' do 172 | get_request = Net::HTTP::Get.new(uri) 173 | get_request['x-counter'] = '42' 174 | response = http.request(get_request) 175 | expect(response).to be_kind_of(Net::HTTPOK) 176 | expect(response['x-counter'].to_i).to eq(43) 177 | end 178 | 179 | it 'intercepts GET requests when using Net::HTTP#get' do 180 | response = http.get(uri.path, 'x-counter' => '42') 181 | expect(response).to be_kind_of(Net::HTTPOK) 182 | expect(response['x-counter'].to_i).to eq(43) 183 | end 184 | 185 | it 'intercepts POST request when using Net::HTTP#request directly' do 186 | post_request = Net::HTTP::Post.new(uri) 187 | post_request['x-counter'] = '42' 188 | post_request.body = 'test' 189 | response = http.request(post_request) 190 | 191 | expect(response).to be_kind_of(Net::HTTPCreated) 192 | expect(response.body).to eq(post_request.body) 193 | expect(response['x-counter'].to_i).to eq(43) 194 | end 195 | 196 | it 'intercepts POST request when using Net::HTTP#post' do 197 | body = 'test' 198 | response = http.post(uri.path, body, 'x-counter' => '42') 199 | 200 | expect(response).to be_kind_of(Net::HTTPCreated) 201 | expect(response.body).to eq(body) 202 | expect(response['x-counter'].to_i).to eq(43) 203 | end 204 | 205 | it 'intercepts PUT requests when using NetHTTP#request directly' do 206 | put_request = Net::HTTP::Put.new(uri) 207 | put_request.body = 'test' 208 | put_request['x-counter'] = '42' 209 | response = http.request(put_request) 210 | 211 | expect(response).to be_kind_of(Net::HTTPOK) 212 | expect(response.body).to eq(put_request.body) 213 | expect(response['x-counter'].to_i).to eq(43) 214 | end 215 | 216 | it 'intercepts PUT requests when using Net::HTTP#put' do 217 | body = 'test' 218 | response = http.put(uri.path, body, 'x-counter' => '42') 219 | 220 | expect(response).to be_kind_of(Net::HTTPOK) 221 | expect(response.body).to eq(body) 222 | expect(response['x-counter'].to_i).to eq(43) 223 | end 224 | 225 | it 'intercepts DELETE requests when using Net::HTTP#request directly' do 226 | delete_request = Net::HTTP::Delete.new(uri) 227 | delete_request['x-counter'] = '42' 228 | response = http.request(delete_request) 229 | expect(response).to be_kind_of(Net::HTTPAccepted) 230 | expect(response['x-counter'].to_i).to eq(43) 231 | end 232 | 233 | it 'intercepts DELETE requests when using Net::HTTP#delete' do 234 | response = http.delete(uri.path, 'x-counter' => '42') 235 | expect(response).to be_kind_of(Net::HTTPAccepted) 236 | expect(response['x-counter'].to_i).to eq(43) 237 | end 238 | 239 | it 'runs non-intercepted requests like normal' do 240 | request = Net::HTTP::Get.new(URI.parse("https://example.com/") ) 241 | response = Net::HTTP.new("example.com").request(request) 242 | expect(response.body).to match(/Example Domain/im) 243 | end 244 | end 245 | 246 | context 'with a custom class as application template' do 247 | let(:template) do 248 | Class.new(Sinatra::Application) do 249 | def answer 250 | 42 251 | end 252 | end 253 | end 254 | 255 | around do |spec| 256 | default_template = RequestInterceptor.template 257 | RequestInterceptor.template = template 258 | spec.run 259 | RequestInterceptor.template = default_template 260 | end 261 | 262 | specify 'interceptors should inherit from the custom class template' do 263 | interceptor = RequestInterceptor.define do 264 | get '/' do 265 | content_type 'text/plain' 266 | answer.to_s 267 | end 268 | end 269 | 270 | RequestInterceptor.run("example.com" => interceptor) do 271 | uri = URI.parse("http://example.com/") 272 | expect(Net::HTTP.get(uri)).to eq("42") 273 | end 274 | end 275 | end 276 | 277 | context 'with a proc as application template' do 278 | let(:template) do 279 | proc do 280 | def answer 281 | 42 282 | end 283 | end 284 | end 285 | 286 | around do |spec| 287 | default_template = RequestInterceptor.template 288 | RequestInterceptor.template = template 289 | spec.run 290 | RequestInterceptor.template = default_template 291 | end 292 | 293 | specify "the template should be a subclass of RequestInterceptor::Application" do 294 | RequestInterceptor.template < RequestInterceptor::Application 295 | end 296 | 297 | specify 'interceptors should inherit from the custom class template' do 298 | interceptor = RequestInterceptor.define do 299 | get '/' do 300 | content_type 'text/plain' 301 | answer.to_s 302 | end 303 | end 304 | 305 | RequestInterceptor.run("example.com" => interceptor) do 306 | uri = URI.parse("http://example.com/") 307 | expect(Net::HTTP.get(uri)).to eq("42") 308 | end 309 | end 310 | end 311 | 312 | context 'when nested' do 313 | let(:other_example) do 314 | RequestInterceptor.define do 315 | get "/" do 316 | "hijacked" 317 | end 318 | end 319 | end 320 | 321 | specify 'the inner interceptor should serve the request and unregister itself cleanly afterwards' do 322 | RequestInterceptor.run("example.com" => example) do 323 | RequestInterceptor.run("example.com" => other_example) do 324 | expect(Net::HTTP.get(URI("http://example.com/"))).to eq("hijacked") 325 | end 326 | 327 | expect(Net::HTTP.get(URI("http://example.com/"))).to eq("example.com") 328 | end 329 | end 330 | 331 | specify 'the inner interceptor should not log calls handled by the outer interceptor' do 332 | outer_log = RequestInterceptor.run("outer.com" => example) do 333 | inner_log = RequestInterceptor.run("inner.com" => example) do 334 | Net::HTTP.get(URI("http://outer.com/")) 335 | Net::HTTP.get(URI("http://inner.com/")) 336 | end 337 | 338 | expect(inner_log.count).to eq(1) 339 | end 340 | 341 | expect(outer_log.count).to eq(1) 342 | end 343 | end 344 | end 345 | --------------------------------------------------------------------------------