require 'net/http'
require 'uri'
require 'json'
require 'hashie'
require 'singleton'
require 'logger'
require 'awesome_print'
require 'awesome_print/core_ext/logger' #For some reason we get an error indicating that the method 'ap' is private unless we load this specifically
require 'json/add/regexp'
require 'pact/matchers'
AwesomePrint.defaults = {
indent: -2,
plain: true,
index: false
}
module Pact
module Consumer
class InteractionList
#include Singleton
attr_reader :interactions
attr_reader :unexpected_requests
def initialize
clear
end
# For testing, sigh
def clear
@interactions = []
@matched_interactions = []
@unexpected_requests = []
end
def add interactions
@interactions << interactions
end
def register_matched interaction
@matched_interactions << interaction
end
# Request::Actual
def register_unexpected request
@unexpected_requests << request
end
def all_matched?
interaction_diffs.empty?
end
def missing_interactions
@interactions - @matched_interactions
end
def interaction_diffs
{
:missing_interactions => missing_interactions,
:unexpected_requests => unexpected_requests.collect(&:as_json)
}.inject({}) do | hash, pair |
hash[pair.first] = pair.last if pair.last.any?
hash
end
end
end
module RackHelper
def params_hash env
env["QUERY_STRING"].split("&").collect{| param| param.split("=")}.inject({}){|params, param| params[param.first] = URI.decode(param.last); params }
end
end
class StartupPoll
def initialize name, logger
@name = name
@logger = logger
end
def match? env
env['REQUEST_PATH'] == '/index.html' &&
env['REQUEST_METHOD'] == 'GET'
end
def respond env
@logger.info "#{@name} started up"
[200, {}, ['Started up fine']]
end
end
class CapybaraIdentify
def initialize name, logger
@name = name
@logger = logger
end
def match? env
env["PATH_INFO"] == "/__identify__"
end
def respond env
[200, {}, [object_id.to_s]]
end
end
class InteractionDelete
include RackHelper
def initialize name, logger, interaction_list
@name = name
@logger = logger
@interaction_list = interaction_list
end
def match? env
env['REQUEST_PATH'].start_with?('/interactions') &&
env['REQUEST_METHOD'] == 'DELETE'
end
def respond env
@interaction_list.clear
@logger.info "Cleared interactions before example \"#{params_hash(env)['example_description']}\""
[200, {}, ['Deleted interactions']]
end
end
class InteractionPost
def initialize name, logger, interaction_list
@name = name
@logger = logger
@interaction_list = interaction_list
end
def match? env
env['REQUEST_PATH'] == '/interactions' &&
env['REQUEST_METHOD'] == 'POST'
end
def respond env
interactions = Hashie::Mash.new(JSON.load(env['rack.input'].string))
@interaction_list.add interactions
@logger.info "Added interaction to #{@name}"
@logger.ap interactions
[200, {}, ['Added interactions']]
end
end
module RequestExtractor
REQUEST_KEYS = Hashie::Mash.new({
'REQUEST_METHOD' => :method,
'REQUEST_PATH' => :path,
'QUERY_STRING' => :query,
'rack.input' => :body
})
def request_from env
request = env.inject({}) do |memo, (k, v)|
request_key = REQUEST_KEYS[k]
memo[request_key] = v if request_key
memo
end
mashed_request = Hashie::Mash.new request
mashed_request[:headers] = headers_from env
body_string = mashed_request[:body].read
if body_string.empty?
mashed_request.delete :body
else
body_is_json = mashed_request[:headers]['Content-Type'] =~ /json/
mashed_request[:body] = body_is_json ? JSON.parse(body_string) : body_string
end
mashed_request[:method] = mashed_request[:method].downcase
mashed_request
end
def headers_from env
headers = env.reject{ |key, value| !(key.start_with?("HTTP") || key == 'CONTENT_TYPE')}
headers.inject({}) do | hash, header |
hash[standardise_header(header.first)] = header.last
hash
end
end
def standardise_header header
header.gsub(/^HTTP_/, '').split("_").collect{|word| word[0] + word[1..-1].downcase}.join("-")
end
end
class InteractionReplay
include Pact::Matchers
include RequestExtractor
def initialize name, logger, interaction_list
@name = name
@logger = logger
@interaction_list = interaction_list
end
def match? env
true # default handler
end
def respond env
find_response request_from(env)
end
private
def find_response raw_request
actual_request = Request::Actual.from_hash(raw_request)
@logger.info "#{@name} received request"
@logger.ap actual_request.as_json
candidates = []
matching_interactions = @interaction_list.interactions.select do |interaction|
expected_request = Request::Expected.from_hash(interaction.request.merge(:description => interaction.description))
candidates << expected_request if expected_request.matches_route? actual_request
expected_request.match actual_request
end
if matching_interactions.size > 1
@logger.error "Multiple interactions found on #{@name}:"
@logger.ap matching_interactions
raise "Multiple interactions found for path #{actual_request.path}!"
end
if matching_interactions.empty?
handle_unrecognised_request(actual_request, candidates)
else
response = response_from(matching_interactions.first.response)
@interaction_list.register_matched matching_interactions.first
@logger.info "Found matching response on #{@name}:"
@logger.ap response
response
end
end
def handle_unrecognised_request request, candidates
@interaction_list.register_unexpected request
@logger.error "No interaction found on #{@name} amongst expected requests \"#{candidates.map(&:description).join(', ')}\""
@logger.error 'Interaction diffs for that route:'
interaction_diff = candidates.map do |candidate|
candidate.difference(request)
end.to_a
@logger.ap(interaction_diff, :error)
response = {message: "No interaction found for #{request.path}", interaction_diff: interaction_diff}
[500, {'Content-Type' => 'application/json'}, [response.to_json]]
end
def response_from response
[response.status, (response.headers || {}).to_hash, [render_body(response.body)]]
end
def render_body body
return '' unless body
body.kind_of?(String) ? body.force_encoding('utf-8') : body.to_json
end
def logger_info_ap msg
@logger.info msg
end
end
class MissingInteractionsGet
include RackHelper
def initialize name, logger, interaction_list
@name = name
@logger = logger
@interaction_list = interaction_list
end
def match? env
env['REQUEST_PATH'].start_with?('/number_of_missing_interactions') &&
env['REQUEST_METHOD'] == 'GET'
end
def respond env
number_of_missing_interactions = @interaction_list.missing_interactions.size
@logger.info "Number of missing interactions for mock \"#{@name}\" = #{number_of_missing_interactions}"
[200, {}, ["#{number_of_missing_interactions}"]]
end
end
class VerificationGet
include RackHelper
def initialize name, logger, log_description, interaction_list
@name = name
@logger = logger
@log_description = log_description
@interaction_list = interaction_list
end
def match? env
env['REQUEST_PATH'].start_with?('/verify') &&
env['REQUEST_METHOD'] == 'GET'
end
def respond env
if @interaction_list.all_matched?
@logger.info "Verifying - interactions matched for example \"#{example_description(env)}\""
[200, {}, ['Interactions matched']]
else
@logger.warn "Verifying - actual interactions do not match expected interactions for example \"#{example_description(env)}\". Interaction diffs:"
@logger.ap @interaction_list.interaction_diffs, :warn
[500, {}, ["Actual interactions do not match expected interactions for mock #{@name}. See #{@log_description} for details."]]
end
end
def example_description env
params_hash(env)['example_description']
end
end
class MockService
def initialize options = {}
options = {log_file: STDOUT}.merge options
log_stream = options[:log_file]
@logger = Logger.new log_stream
log_description = if log_stream.is_a? File
File.absolute_path(log_stream).gsub(Dir.pwd + "/", '')
else
"standard out/err"
end
interaction_list = InteractionList.new
@name = options.fetch(:name, "MockService")
@handlers = [
StartupPoll.new(@name, @logger),
CapybaraIdentify.new(@name, @logger),
MissingInteractionsGet.new(@name, @logger, interaction_list),
VerificationGet.new(@name, @logger, log_description, interaction_list),
InteractionPost.new(@name, @logger, interaction_list),
InteractionDelete.new(@name, @logger, interaction_list),
InteractionReplay.new(@name, @logger, interaction_list)
]
end
def to_s
"#{@name} #{super.to_s}"
end
def call env
response = []
begin
relevant_handler = @handlers.detect { |handler| handler.match? env }
response = relevant_handler.respond env
rescue Exception => e
@logger.ap 'Error ocurred in mock service:'
@logger.ap e
@logger.ap e.backtrace
raise e
end
response
end
end
end
end