diff --git a/lib/pact/matching_rules/v3/merge.rb b/lib/pact/matching_rules/v3/merge.rb new file mode 100644 index 0000000..eb8edfa --- /dev/null +++ b/lib/pact/matching_rules/v3/merge.rb @@ -0,0 +1,112 @@ +require 'pact/array_like' +require 'pact/matching_rules/jsonpath' + +module Pact + module MatchingRules + module V3 + class Merge + + def self.call expected, matching_rules, root_path = '$' + new(expected, matching_rules, root_path).call + end + + def initialize expected, matching_rules, root_path + @expected = expected + @matching_rules = standardise_paths(matching_rules) + @root_path = JsonPath.new(root_path).to_s + end + + def call + return @expected if @matching_rules.nil? || @matching_rules.empty? + recurse @expected, @root_path + end + + private + + def standardise_paths matching_rules + return matching_rules if matching_rules.nil? || matching_rules.empty? + matching_rules.each_with_object({}) do | (path, rule), new_matching_rules | + new_matching_rules[JsonPath.new(path).to_s] = rule + end + end + + def recurse expected, path + case expected + when Hash then recurse_hash(expected, path) + when Array then recurse_array(expected, path) + else + expected + end + end + + def recurse_hash hash, path + hash.each_with_object({}) do | (k, v), new_hash | + new_path = path + "['#{k}']" + new_hash[k] = recurse(wrap(v, new_path), new_path) + end + end + + def recurse_array array, path + array_like_children_path = "#{path}[*]*" + parent_match_rule = @matching_rules[path] && @matching_rules[path]['matchers'] && @matching_rules[path]['matchers'].first && @matching_rules[path]['matchers'].first['match'] + children_match_rule = @matching_rules[array_like_children_path] && @matching_rules[array_like_children_path]['matchers'] && @matching_rules[array_like_children_path]['matchers'].first && @matching_rules[array_like_children_path]['matchers'].first['match'] + min = @matching_rules[path] && @matching_rules[path]['matchers'] && @matching_rules[path]['matchers'].first && @matching_rules[path]['matchers'].first['min'] + + if min && (children_match_rule == 'type' || (children_match_rule.nil? && parent_match_rule == 'type')) + warn_when_not_one_example_item(array, path) + # log_ignored_rules(path, @matching_rules[path], {'min' => min}) + Pact::ArrayLike.new(recurse(array.first, "#{path}[*]"), min: min) + else + new_array = [] + array.each_with_index do | item, index | + new_path = path + "[#{index}]" + new_array << recurse(wrap(item, new_path), new_path) + end + new_array + end + end + + def warn_when_not_one_example_item array, path + unless array.size == 1 + Pact.configuration.error_stream.puts "WARN: Only the first item will be used to match the items in the array at #{path}" + end + end + + def wrap object, path + rules = @matching_rules[path] && @matching_rules[path]['matchers'] && @matching_rules[path]['matchers'].first + array_rules = @matching_rules["#{path}[*]*"] && @matching_rules["#{path}[*]*"]['matchers'] && @matching_rules["#{path}[*]*"]['matchers'].first + return object unless rules || array_rules + + if rules['match'] == 'type' && !rules.has_key?('min') + handle_match_type(object, path, rules) + elsif rules['regex'] + handle_regex(object, path, rules) + else + log_ignored_rules(path, rules, {}) + object + end + end + + def handle_match_type object, path, rules + log_ignored_rules(path, rules, {'match' => 'type'}) + Pact::SomethingLike.new(object) + end + + def handle_regex object, path, rules + log_ignored_rules(path, rules, {'match' => 'regex', 'regex' => rules['regex']}) + Pact::Term.new(generate: object, matcher: Regexp.new(rules['regex'])) + end + + def log_ignored_rules path, rules, used_rules + dup_rules = rules.dup + used_rules.each_pair do | used_key, used_value | + dup_rules.delete(used_key) if dup_rules[used_key] == used_value + end + if dup_rules.any? + $stderr.puts "WARN: Ignoring unsupported matching rules #{dup_rules} for path #{path}" + end + end + end + end + end +end diff --git a/spec/fixtures/pact-v3.json b/spec/fixtures/pact-v3.json new file mode 100644 index 0000000..a43aef2 --- /dev/null +++ b/spec/fixtures/pact-v3.json @@ -0,0 +1,27 @@ +{ + "consumer": { + "name": "Consumer" + }, + "provider": { + "name": "Provider" + }, + "messages": [ + { + "contents": { + "foo": "bar" + }, + "description": "Published credit data", + "metaData": { + "contentType": "application/json" + }, + "providerState": "or maybe 'scenario'? not sure about this", + "matchingRules": { + "body": { + "$.foo": { + "matchers": [{"match" : "type"}] + } + } + } + } + ] +} diff --git a/spec/lib/pact/matching_rules/v3/merge_spec.rb b/spec/lib/pact/matching_rules/v3/merge_spec.rb new file mode 100644 index 0000000..728abae --- /dev/null +++ b/spec/lib/pact/matching_rules/v3/merge_spec.rb @@ -0,0 +1,364 @@ +require 'pact/matching_rules/v3/merge' + +module Pact + module MatchingRules + module V3 + describe Merge do + subject { Merge.(expected, matching_rules) } + + before do + allow($stderr).to receive(:puts) + end + + describe "no recognised rules" do + let(:expected) do + { + "_links" => { + "self" => { + "href" => "http://localhost:1234/thing" + } + } + } + end + + let(:matching_rules) do + { + "$._links.self.href" => { + "matchers" => [{ "type" => "unknown" }] + } + } + end + + it "returns the object at that path unaltered" do + expect(subject["_links"]["self"]["href"]).to eq "http://localhost:1234/thing" + end + + it "it logs the rules it has ignored" do + expect($stderr).to receive(:puts) do | message | + expect(message).to include("WARN") + expect(message).to include("type") + expect(message).to include("unknown") + expect(message).to include("$['_links']") + end + subject + end + + end + + describe "with nil rules" do + let(:expected) do + { + "_links" => { + "self" => { + "href" => "http://localhost:1234/thing" + } + } + } + end + + let(:matching_rules) { nil } + + it "returns the example unaltered" do + expect(subject["_links"]["self"]["href"]).to eq "http://localhost:1234/thing" + end + + end + + describe "type based matching" do + let(:expected) do + { + "name" => "Mary" + } + end + + let(:matching_rules) do + { + "$.name" => { + "matchers" => [{ "match" => "type", "ignored" => "matchingrule" }] + } + } + end + + it "creates a SomethingLike at the appropriate path" do + expect(subject['name']).to be_instance_of(Pact::SomethingLike) + end + + it "it logs the rules it has ignored" do + expect($stderr).to receive(:puts).with(/ignored.*matchingrule/) + subject + end + + end + + describe "regular expressions" do + + describe "in a hash" do + let(:expected) do + { + "_links" => { + "self" => { + "href" => "http://localhost:1234/thing" + } + } + } + end + + let(:matching_rules) do + { + "$._links.self.href" => { + "matchers" => [{ "regex" => "http:\\/\\/.*\\/thing", "match" => "regex", "ignored" => "somerule" }] + } + } + end + + it "creates a Pact::Term at the appropriate path" do + expect(subject["_links"]["self"]["href"]).to be_instance_of(Pact::Term) + expect(subject["_links"]["self"]["href"].generate).to eq "http://localhost:1234/thing" + expect(subject["_links"]["self"]["href"].matcher.inspect).to eq "/http:\\/\\/.*\\/thing/" + end + + it "it logs the rules it has ignored" do + expect($stderr).to receive(:puts) do | message | + expect(message).to match /ignored.*"somerule"/ + expect(message).to_not match /regex/ + expect(message).to_not match /"match"/ + end + subject + end + end + + describe "with an array" do + + let(:expected) do + { + "_links" => { + "self" => [{ + "href" => "http://localhost:1234/thing" + }] + } + } + end + + let(:matching_rules) do + { + "$._links.self[0].href" => { + "matchers" => [{ "regex" => "http:\\/\\/.*\\/thing" }] + } + } + end + + it "creates a Pact::Term at the appropriate path" do + expect(subject["_links"]["self"][0]["href"]).to be_instance_of(Pact::Term) + expect(subject["_links"]["self"][0]["href"].generate).to eq "http://localhost:1234/thing" + expect(subject["_links"]["self"][0]["href"].matcher.inspect).to eq "/http:\\/\\/.*\\/thing/" + end + end + + describe "with an array where all elements should match by type and the rule is specified on the parent element and there is no min specified" do + let(:expected) do + { + 'alligators' => [{'name' => 'Mary'}] + } + end + + let(:matching_rules) do + { + "$.alligators" => { + "matchers" => [{ 'match' => 'type' }] + } + } + end + + it "creates a Pact::SomethingLike at the appropriate path" do + expect(subject["alligators"]).to be_instance_of(Pact::SomethingLike) + expect(subject["alligators"].contents).to eq ['name' => 'Mary'] + end + end + + describe "with an array where all elements should match by type and the rule is specified on the child elements" do + let(:expected) do + { + 'alligators' => [{'name' => 'Mary'}] + } + end + + let(:matching_rules) do + { + "$.alligators" => { + "matchers" => [{ 'min' => 2, 'match' => 'type' }] + }, + "$.alligators[*].*" => { + "matchers" => [{ 'match' => 'type'}] + } + } + end + it "creates a Pact::ArrayLike at the appropriate path" do + expect(subject["alligators"]).to be_instance_of(Pact::ArrayLike) + expect(subject["alligators"].contents).to eq 'name' => 'Mary' + expect(subject["alligators"].min).to eq 2 + end + end + + describe "with an array where all elements should match by type and the rule is specified on both the parent element and the child elements" do + let(:expected) do + { + 'alligators' => [{'name' => 'Mary'}] + } + end + + let(:matching_rules) do + { + "$.alligators" => { + "matchers" => [{ 'min' => 2, 'match' => 'type' }] + }, + "$.alligators[*].*" => { + "matchers" => [{ 'match' => 'type' }] + } + } + end + + it "creates a Pact::ArrayLike at the appropriate path" do + expect(subject["alligators"]).to be_instance_of(Pact::ArrayLike) + expect(subject["alligators"].contents).to eq 'name' => 'Mary' + expect(subject["alligators"].min).to eq 2 + end + end + + describe "with an array where all elements should match by type and there is only a match:type on the parent element" do + let(:expected) do + { + 'alligators' => [{'name' => 'Mary'}] + } + end + + let(:matching_rules) do + { + "$.alligators" => { 'matchers' => [{'min' => 2, 'match' => 'type'}] }, + } + end + + it "creates a Pact::ArrayLike at the appropriate path" do + expect(subject["alligators"]).to be_instance_of(Pact::ArrayLike) + expect(subject["alligators"].contents).to eq 'name' => 'Mary' + expect(subject["alligators"].min).to eq 2 + end + end + + describe "with an array where all elements should match by type nested inside another array where all elements should match by type" do + let(:expected) do + { + + 'alligators' => [ + { + 'name' => 'Mary', + 'children' => [ + 'age' => 9 + ] + } + ] + + } + end + + let(:matching_rules) do + { + "$.alligators" => { "matchers" => [{ 'min' => 2, 'match' => 'type' }] }, + "$.alligators[*].children" => { "matchers" => [{ 'min' => 1, 'match' => 'type' }]}, + } + end + + it "creates a Pact::ArrayLike at the appropriate path" do + expect(subject["alligators"].contents['children']).to be_instance_of(Pact::ArrayLike) + expect(subject["alligators"].contents['children'].contents).to eq 'age' => 9 + expect(subject["alligators"].contents['children'].min).to eq 1 + end + end + + describe "with an example array with more than one item" do + let(:expected) do + { + + 'alligators' => [ + {'name' => 'Mary'}, + {'name' => 'Joe'} + ] + + } + end + + let(:matching_rules) do + { + "$.alligators" => { "matchers" => [{'min' => 2, 'match' => 'type'}] } + } + end + + xit "doesn't warn about the min size being ignored" do + expect(Pact.configuration.error_stream).to receive(:puts).once + subject + end + + it "warns that the other items will be ignored" do + allow(Pact.configuration.error_stream).to receive(:puts) + expect(Pact.configuration.error_stream).to receive(:puts).with(/WARN: Only the first item/) + subject + end + end + end + + describe "using bracket notation for a Hash" do + let(:expected) do + { + "name" => "Mary" + } + end + + let(:matching_rules) do + { + "$['name']" => { "matchers" => [{"match" => "type"}] } + } + end + + it "applies the rule" do + expect(subject['name']).to be_instance_of(Pact::SomethingLike) + end + end + + describe "with a dot in the path" do + let(:expected) do + { + "first.name" => "Mary" + } + end + + let(:matching_rules) do + { + "$['first.name']" => { "matchers" => [{ "match" => "type" }] } + } + end + + it "applies the rule" do + expect(subject['first.name']).to be_instance_of(Pact::SomethingLike) + end + end + + describe "with an @ in the path" do + let(:expected) do + { + "@name" => "Mary" + } + end + + let(:matching_rules) do + { + "$['@name']" => { "matchers" => [ { "match" => "type" }] } + } + end + + it "applies the rule" do + expect(subject['@name']).to be_instance_of(Pact::SomethingLike) + end + end + end + end + end +end