diff --git a/.rubocop.yml b/.rubocop.yml index e8d9080..1e991b8 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -27,3 +27,6 @@ RSpec/DescribeClass: Exclude: - samples/**/* - spec/integration/**/* +RSpec/MultipleDescribes: + Exclude: + - samples/**/* diff --git a/lib/pacto.rb b/lib/pacto.rb index d00867b..28c6f82 100644 --- a/lib/pacto.rb +++ b/lib/pacto.rb @@ -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 diff --git a/lib/pacto/cops/body_cop.rb b/lib/pacto/cops/body_cop.rb index fe65cb6..78c116a 100644 --- a/lib/pacto/cops/body_cop.rb +++ b/lib/pacto/cops/body_cop.rb @@ -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 diff --git a/lib/pacto/core/pacto_request.rb b/lib/pacto/core/pacto_request.rb index 02c8593..3ecdc98 100644 --- a/lib/pacto/core/pacto_request.rb +++ b/lib/pacto/core/pacto_request.rb @@ -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 diff --git a/lib/pacto/core/pacto_response.rb b/lib/pacto/core/pacto_response.rb index 5d99a52..43878d4 100644 --- a/lib/pacto/core/pacto_response.rb +++ b/lib/pacto/core/pacto_response.rb @@ -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 diff --git a/lib/pacto/forensics/investigation_filter.rb b/lib/pacto/forensics/investigation_filter.rb new file mode 100644 index 0000000..6c9086a --- /dev/null +++ b/lib/pacto/forensics/investigation_filter.rb @@ -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 diff --git a/lib/pacto/forensics/investigation_matcher.rb b/lib/pacto/forensics/investigation_matcher.rb new file mode 100644 index 0000000..91ec406 --- /dev/null +++ b/lib/pacto/forensics/investigation_matcher.rb @@ -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 diff --git a/lib/pacto/rake_task.rb b/lib/pacto/rake_task.rb index 2c81159..90fdb6f 100644 --- a/lib/pacto/rake_task.rb +++ b/lib/pacto/rake_task.rb @@ -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 diff --git a/lib/pacto/rspec.rb b/lib/pacto/rspec.rb index c3df291..118c015 100644 --- a/lib/pacto/rspec.rb +++ b/lib/pacto/rspec.rb @@ -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 @@ -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 diff --git a/samples/contracts/localhost/api/echo.json b/samples/contracts/localhost/api/echo.json index a5a1400..a25f62e 100644 --- a/samples/contracts/localhost/api/echo.json +++ b/samples/contracts/localhost/api/echo.json @@ -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 } } } diff --git a/samples/forensics.rb b/samples/forensics.rb new file mode 100644 index 0000000..9b98425 --- /dev/null +++ b/samples/forensics.rb @@ -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 diff --git a/samples/rspec.rb b/samples/rspec.rb new file mode 100644 index 0000000..e69de29 diff --git a/samples/sample_apis/echo_api.rb b/samples/sample_apis/echo_api.rb index 7c2c7a8..f97ee2a 100644 --- a/samples/sample_apis/echo_api.rb +++ b/samples/sample_apis/echo_api.rb @@ -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) @@ -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 diff --git a/spec/fixtures/contracts/strict_contract.json b/spec/fixtures/contracts/strict_contract.json index 4450c48..201f354 100644 --- a/spec/fixtures/contracts/strict_contract.json +++ b/spec/fixtures/contracts/strict_contract.json @@ -1,4 +1,5 @@ { + "name": "Strict Contract", "request": { "http_method": "GET", "path": "/strict", diff --git a/spec/integration/forensics/integration_matcher_spec.rb b/spec/integration/forensics/integration_matcher_spec.rb new file mode 100644 index 0000000..935875d --- /dev/null +++ b/spec/integration/forensics/integration_matcher_spec.rb @@ -0,0 +1,89 @@ +require 'pacto/rspec' + +module Pacto + describe '#have_investigated' do + let(:contract_path) { 'spec/fixtures/contracts/simple_contract.json' } + let(:strict_contract_path) { 'spec/fixtures/contracts/strict_contract.json' } + + around :each do |example| + run_pacto do + example.run + end + end + + def expect_to_raise(message_pattern = nil, &blk) + expect { blk.call }.to raise_error(RSpec::Expectations::ExpectationNotMetError, message_pattern) + end + + def json_response(url) + response = Faraday.get(url) do |req| + req.headers = { 'Accept' => 'application/json' } + end + MultiJson.load(response.body) + end + + def play_bad_response + contracts.stub_providers(device_id: 1.5) + Faraday.get('http://dummyprovider.com/strict') do |req| + req.headers = { 'Accept' => 'application/json' } + end + end + + context 'successful investigations' do + let(:contracts) do + Pacto.load_contracts 'spec/fixtures/contracts/', 'http://dummyprovider.com' + end + + before(:each) do + Pacto.configure do |c| + c.strict_matchers = false + c.register_hook Pacto::Hooks::ERBHook.new + end + + contracts.stub_providers(device_id: 42) + Pacto.validate! + + Faraday.get('http://dummyprovider.com/hello') do |req| + req.headers = { 'Accept' => 'application/json' } + end + end + + it 'performs successful assertions' do + # High level assertions + expect(Pacto).to_not have_unmatched_requests + expect(Pacto).to_not have_failed_investigations + + # Increasingly strict assertions + expect(Pacto).to have_investigated('Simple Contract') + expect(Pacto).to have_investigated('Simple Contract').with_request(headers: hash_including('Accept' => 'application/json')) + expect(Pacto).to have_investigated('Simple Contract').with_request(http_method: :get, url: 'http://dummyprovider.com/hello') + end + + it 'supports negative assertions' do + expect(Pacto).to_not have_investigated('Strict Contract') + Faraday.get('http://dummyprovider.com/strict') do |req| + req.headers = { 'Accept' => 'application/json' } + end + expect(Pacto).to have_investigated('Strict Contract') + end + + it 'raises useful error messages' do + # Expected failures + header_matcher = hash_including('Accept' => 'text/plain') + matcher_description = Regexp.quote(header_matcher.description) + expect_to_raise(/but no requests matched headers #{matcher_description}/) { expect(Pacto).to have_investigated('Simple Contract').with_request(headers: header_matcher) } + end + + it 'displays Contract investigation problems' do + play_bad_response + expect_to_raise(/investigation errors were found:/) { expect(Pacto).to have_investigated('Strict Contract') } + end + + it 'displays the Contract file' do + play_bad_response + schema_file_uri = Addressable::URI.convert_path(File.absolute_path strict_contract_path).to_s + expect_to_raise(/in schema #{schema_file_uri}/) { expect(Pacto).to have_investigated('Strict Contract') } + end + end + end +end