Skip to content
This repository has been archived by the owner on Dec 5, 2019. It is now read-only.

Commit

Permalink
Merge 17fc2ad into b061db5
Browse files Browse the repository at this point in the history
  • Loading branch information
maxlinc committed Jul 10, 2014
2 parents b061db5 + 17fc2ad commit 9d85288
Show file tree
Hide file tree
Showing 15 changed files with 375 additions and 20 deletions.
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ RSpec/DescribeClass:
Exclude:
- samples/**/*
- spec/integration/**/*
RSpec/MultipleDescribes:
Exclude:
- samples/**/*
7 changes: 7 additions & 0 deletions lib/pacto.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ def contract_registry
@registry ||= ContractRegistry.new
end

# Resets data and metrics only. It usually makes sense to call this between test scenarios.
def reset
Pacto::InvestigationRegistry.instance.reset!
# Pacto::Resettable.reset_all
end

# Resets but also clears configuration, loaded contracts, and plugins.
def clear!
Pacto::Resettable.reset_all
@modes = nil
Expand Down
5 changes: 4 additions & 1 deletion lib/pacto/cops/body_cop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ def self.investigate(_request, response, contract)
end

def self.validate_as_json(schema, body)
body = body.body if body.respond_to? :body
if schema['type'] == 'string'
# Is it better to check body is not nil, or body is a string?
body = body.inspect unless body.nil?
end
JSON::Validator.fully_validate(schema, body, version: :draft3)
end
end
Expand Down
14 changes: 14 additions & 0 deletions lib/pacto/core/pacto_request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,19 @@ def initialize(data)
@method = mash[:method]
@uri = mash.uri
end

def parsed_body
if body.is_a?(String) && content_type == 'application/json'
JSON.parse(body)
else
body
end
rescue
body
end

def content_type
headers['Content-Type']
end
end
end
17 changes: 15 additions & 2 deletions lib/pacto/core/pacto_response.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
module Pacto
class PactoResponse
# FIXME: Need case insensitive header lookup, but case-sensitive storage
attr_accessor :headers, :body, :status
attr_accessor :headers, :body, :status, :parsed_body
attr_reader :parsed_body

def initialize(data)
mash = Hashie::Mash.new data
@headers = mash.headers.nil? ? {} : mash.headers
@body = mash.body
@body = mash.body
@status = mash.status.to_i
end

def parsed_body
if body.is_a?(String) && content_type == 'application/json'
JSON.parse(body)
else
body
end
end

def content_type
headers['Content-Type']
end
end
end
89 changes: 89 additions & 0 deletions lib/pacto/forensics/investigation_filter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
module Pacto
module Forensics
class FilterExhaustedError < StandardError
attr_reader :suspects

def initialize(msg, filter, suspects = [])
@suspects = suspects
if filter.respond_to? :description
msg = "#{msg} #{filter.description}"
else
msg = "#{msg} #{filter}"
end
super(msg)
end
end

class InvestigationFilter
# CaseEquality makes sense for some of the rspec matchers and compound matching behavior
# rubocop:disable Style/CaseEquality
attr_reader :investigations, :filtered_investigations

def initialize(investigations, track_suspects = true)
investigations ||= []
@investigations = investigations.dup
@filtered_investigations = @investigations.dup
@track_suspects = track_suspects
end

def with_name(contract_name)
@filtered_investigations.keep_if do |investigation|
return false if investigation.contract.nil?

contract_name === investigation.contract.name
end
self
end

def with_request(request_constraints)
return self if request_constraints.nil?
[:headers, :body].each do |section|
filter_request_section(section, request_constraints[section])
end
self
end

def with_response(response_constraints)
return self if response_constraints.nil?
[:headers, :body].each do |section|
filter_response_section(section, response_constraints[section])
end
self
end

def successful_investigations
@filtered_investigations.select { |i| i.successful? }
end

def unsuccessful_investigations
@filtered_investigations - successful_investigations
end

protected

def filter_request_section(section, filter)
suspects = []
section = :parsed_body if section == :body
@filtered_investigations.keep_if do |investigation|
candidate = investigation.request.send(section)
suspects << candidate if @track_suspects
filter === candidate
end if filter
fail FilterExhaustedError.new("no requests matched #{section}", filter, suspects) if @filtered_investigations.empty?
end

def filter_response_section(section, filter)
section = :parsed_body if section == :body
suspects = []
@filtered_investigations.keep_if do |investigation|
candidate = investigation.response.send(section)
suspects << candidate if @track_suspects
filter === candidate
end if filter
fail FilterExhaustedError.new("no responses matched #{section}", filter, suspects) if @filtered_investigations.empty?
end

# rubocop:enable Style/CaseEquality
end
end
end
79 changes: 79 additions & 0 deletions lib/pacto/forensics/investigation_matcher.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
RSpec::Matchers.define :have_investigated do |service_name|
match do
investigations = Pacto::InvestigationRegistry.instance.investigations
@service_name = service_name

begin
@investigation_filter = Pacto::Forensics::InvestigationFilter.new(investigations)
@investigation_filter.with_name(@service_name)
.with_request(@request_constraints)
.with_response(@response_constraints)

@matched_investigations = @investigation_filter.filtered_investigations
@unsuccessful_investigations = @investigation_filter.unsuccessful_investigations

!@matched_investigations.empty? && (@allow_citations || @unsuccessful_investigations.empty?)
rescue Pacto::Forensics::FilterExhaustedError => e
@filter_error = e
false
end
end

def describe(obj)
obj.respond_to?(:description) ? obj.description : obj.to_s
end

description do
buffer = StringIO.new
buffer.puts "to have investigated #{@service_name}"
if @request_constraints
buffer.puts ' with request matching'
@request_constraints.each do |k, v|
buffer.puts " #{k}: #{describe(v)}"
end
end
buffer.puts ' and' if @request_constraints && @response_constraints
if @response_constraint
buffer.puts ' with response matching'
@request_constraints.each do |k, v|
buffer.puts " #{k}: #{describe(v)}"
end
end
buffer.string
end

chain :with_request do |request_constraints|
@request_constraints = request_constraints
end

chain :with_response do |response_constraints|
@response_constraints = response_constraints
end

chain :allow_citations do
@allow_citations = true
end

failure_message_for_should do | group |
buffer = StringIO.new
buffer.puts "expected #{group} " + description
if @filter_error
buffer.puts "but #{@filter_error.message}"
unless @filter_error.suspects.empty?
buffer.puts ' suspects:'
@filter_error.suspects.each do |suspect|
buffer.puts " #{suspect}"
end
end
elsif @matched_investigations.empty?
investigated_services = @investigation_filter.investigations.map(&:contract).compact.map(&:name).uniq
buffer.puts "but it was not among the services investigated: #{investigated_services}"
elsif @unsuccessful_investigations
buffer.puts 'but investigation errors were found:'
@unsuccessful_investigations.each do |investigation|
buffer.puts " #{investigation}"
end
end
buffer.string
end
end
1 change: 1 addition & 0 deletions lib/pacto/rake_task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def validate_contracts(host, dir)
total_failed += 1
puts Pacto::UI.red(' FAILED!')
puts Pacto::UI.red(investigation.summary)
puts Pacto::UI.red(investigation.to_s)
end
end

Expand Down
9 changes: 7 additions & 2 deletions lib/pacto/rspec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
raise 'pacto/rspec requires rspec 2 or later'
end

require 'pacto/forensics/investigation_filter'
require 'pacto/forensics/investigation_matcher'

RSpec::Matchers.define :have_unmatched_requests do |_method, _uri|
match do
@unmatched_investigations = Pacto::InvestigationRegistry.instance.unmatched_investigations
Expand Down Expand Up @@ -69,9 +72,11 @@ def successfully?

def contract_matches?
if @contract
validated_contracts = @matching_investigations.map(&:contract)
validated_contracts = @matching_investigations.map(&:contract).compact
# Is there a better option than case equality for string & regex support?
validated_contracts.map(&:file).index { |file| @contract === file } # rubocop:disable CaseEquality
validated_contracts.any? do |contract|
@contract === contract.file || @contract === contract.name # rubocop:disable CaseEquality
end
else
true
end
Expand Down
25 changes: 12 additions & 13 deletions samples/contracts/localhost/api/echo.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,25 @@
"name": "Echo",
"request": {
"headers": {
"Content-Type": "text/plain"
},
"http_method": "get",
"path": "/api/echo"
"http_method": "post",
"path": "/api/echo",
"schema": {
"$schema": "http://json-schema.org/draft-03/schema#",
"type": "string",
"required": true
}
},
"response": {
"headers": {
"Content-Type": "application/json"
"Content-Type": "text/plain"
},
"status": 400,
"status": 201,
"schema": {
"$schema": "http://json-schema.org/draft-03/schema#",
"description": "Generated from http://localhost:9292/api/echo with shasum 8b6da9e052fa786f6c658cc81429bfc6fcbfa473",
"type": "object",
"required": true,
"properties": {
"error": {
"type": "string",
"required": true
}
}
"type": "string",
"required": true
}
}
}
52 changes: 52 additions & 0 deletions samples/forensics.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Pacto has a few RSpec matchers to help you ensure a **consumer** and **producer** are
# interacting properly. First, let's setup the rspec suite.
require 'rspec/autorun' # Not generally needed
require 'pacto/rspec'
WebMock.allow_net_connect!
Pacto.validate!
Pacto.load_contracts('contracts', 'http://localhost:5000').stub_providers

# It's usually a good idea to reset Pacto between each scenario. `Pacto.reset` just clears the
# data and metrics about which services were called. `Pacto.clear!` also resets all configuration
# and plugins.
RSpec.configure do |c|
c.after(:each) { Pacto.reset }
end

# Pacto provides some RSpec matchers related to contract testing, like making sure
# Pacto didn't received any unrecognized requests (`have_unmatched_requests`) and that
# the HTTP requests matched up with the terms of the contract (`have_failed_investigations`).
describe Faraday do
let(:connection) { described_class.new(url: 'http://localhost:5000') }

it 'passes contract tests' do
connection.get '/api/ping'
expect(Pacto).to_not have_failed_investigations
expect(Pacto).to_not have_unmatched_requests
end
end

# There are also some matchers for collaboration testing, so you can make sure each scenario is
# calling the expected services and sending the right type of data.
describe Faraday do
let(:connection) { described_class.new(url: 'http://localhost:5000') }
before(:each) do
connection.get '/api/ping'

connection.post do |req|
req.url '/api/echo'
req.headers['Content-Type'] = 'application/json'
req.body = '{"foo": "bar"}'
end
end

it 'calls the ping service' do
expect(Pacto).to have_validated(:get, 'http://localhost:5000/api/ping').against_contract('Ping')
end

it 'sends data to the echo service' do
expect(Pacto).to have_investigated('Ping').with_response(body: hash_including('ping' => 'pong - from the example!'))
expect(Pacto).to have_investigated('Echo').with_request(body: hash_including('foo' => 'bar'))
expect(Pacto).to have_investigated('Echo').with_response(body: /foo.*bar/)
end
end
Empty file added samples/rspec.rb
Empty file.
4 changes: 2 additions & 2 deletions samples/sample_apis/echo_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# It also illustrates having two services w/ the same endpoint (just different HTTP methods)
module DummyServices
class Echo < Grape::API
format :json
format :txt

helpers do
def echo(message)
Expand All @@ -16,7 +16,7 @@ def echo(message)
echo params[:msg]
end

# curl localhost:5000/api/echo -H 'Content-Type: application/json' -d '{"red fish": "blue fish"}' -vv
# curl localhost:5000/api/echo -H 'Content-Type: text/plain' -d '{"red fish": "blue fish"}' -vv
post '/echo' do
echo env['api.request.body']
end
Expand Down
1 change: 1 addition & 0 deletions spec/fixtures/contracts/strict_contract.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"name": "Strict Contract",
"request": {
"http_method": "GET",
"path": "/strict",
Expand Down
Loading

0 comments on commit 9d85288

Please sign in to comment.