From faff17c331da6aaf3f48c7d14fff9e3bba4b60cd Mon Sep 17 00:00:00 2001 From: Beth Skurrie Date: Wed, 4 Nov 2020 09:33:30 +1100 Subject: [PATCH] fix: parse query string to hash for v2 interactions Fixes: https://github.com/pact-foundation/pact-mock_service/issues/80 --- .../interaction_v2_parser.rb | 9 +- lib/pact/consumer_contract/query.rb | 98 +++++++++++++++++++ .../interaction_v2_parser_spec.rb | 15 +++ spec/lib/pact/consumer_contract/query_spec.rb | 33 +++++++ 4 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 spec/lib/pact/consumer_contract/query_spec.rb diff --git a/lib/pact/consumer_contract/interaction_v2_parser.rb b/lib/pact/consumer_contract/interaction_v2_parser.rb index a1a0165..cfc208c 100644 --- a/lib/pact/consumer_contract/interaction_v2_parser.rb +++ b/lib/pact/consumer_contract/interaction_v2_parser.rb @@ -1,6 +1,7 @@ require 'pact/consumer_contract/request' require 'pact/consumer_contract/response' require 'pact/consumer_contract/provider_state' +require 'pact/consumer_contract/query' require 'pact/symbolize_keys' require 'pact/matching_rules' require 'pact/errors' @@ -15,13 +16,17 @@ def self.call hash, options response = parse_response(hash['response'], options) provider_states = parse_provider_states(hash['providerState'] || hash['provider_state']) metadata = parse_metadata(hash['metadata']) - Interaction.new(symbolize_keys(hash).merge(request: request, - response: response, + Interaction.new(symbolize_keys(hash).merge(request: request, + response: response, provider_states: provider_states, metadata: metadata)) end def self.parse_request request_hash, options + if request_hash['query'].is_a?(String) + request_hash = request_hash.dup + request_hash['query'] = Pact::Query.parse_string(request_hash['query']) + end request_hash = Pact::MatchingRules.merge(request_hash, request_hash['matchingRules'], options) Pact::Request::Expected.from_hash(request_hash) end diff --git a/lib/pact/consumer_contract/query.rb b/lib/pact/consumer_contract/query.rb index ab191d2..c02ec27 100644 --- a/lib/pact/consumer_contract/query.rb +++ b/lib/pact/consumer_contract/query.rb @@ -3,6 +3,9 @@ module Pact class Query + DEFAULT_SEP = /[&;] */n + COMMON_SEP = { ";" => /[;] */n, ";," => /[;,] */n, "&" => /[&] */n } + def self.create query if query.is_a? Hash Pact::QueryHash.new(query) @@ -10,5 +13,100 @@ def self.create query Pact::QueryString.new(query) end end + + def self.parse_string query_string + parsed_query = parse_query(query_string) + + # If Rails nested params... + if parsed_query.keys.any?{ | key| key.include?("[") } + parse_nested_query(query_string) + else + parsed_query.each_with_object({}) do | (key, value), new_hash | + new_hash[key] = [*value] + end + end + end + + # Ripped from Rack to avoid adding an unnecessary dependency, thank you Rack + # https://github.com/rack/rack/blob/649c72bab9e7b50d657b5b432d0c205c95c2be07/lib/rack/utils.rb + def self.parse_query(qs, d = nil, &unescaper) + unescaper ||= method(:unescape) + + params = {} + + (qs || '').split(d ? (COMMON_SEP[d] || /[#{d}] */n) : DEFAULT_SEP).each do |p| + next if p.empty? + k, v = p.split('=', 2).map!(&unescaper) + + if cur = params[k] + if cur.class == Array + params[k] << v + else + params[k] = [cur, v] + end + else + params[k] = v + end + end + + return params.to_h + end + + def self.parse_nested_query(qs, d = nil) + params = {} + + unless qs.nil? || qs.empty? + (qs || '').split(d ? (COMMON_SEP[d] || /[#{d}] */n) : DEFAULT_SEP).each do |p| + k, v = p.split('=', 2).map! { |s| unescape(s) } + + normalize_params(params, k, v) + end + end + + return params.to_h + end + + def self.normalize_params(params, name, v) + name =~ %r(\A[\[\]]*([^\[\]]+)\]*) + k = $1 || '' + after = $' || '' + + if k.empty? + if !v.nil? && name == "[]" + return Array(v) + else + return + end + end + + if after == '' + params[k] = v + elsif after == "[" + params[name] = v + elsif after == "[]" + params[k] ||= [] + raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) + params[k] << v + elsif after =~ %r(^\[\]\[([^\[\]]+)\]$) || after =~ %r(^\[\](.+)$) + child_key = $1 + params[k] ||= [] + raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) + if params_hash_type?(params[k].last) && !params_hash_has_key?(params[k].last, child_key) + normalize_params(params[k].last, child_key, v) + else + params[k] << normalize_params({}, child_key, v) + end + else + params[k] ||= {} + raise ParameterTypeError, "expected Hash (got #{params[k].class.name}) for param `#{k}'" unless params_hash_type?(params[k]) + params[k] = normalize_params(params[k], after, v, depth - 1) + end + + params + end + + def self.unescape(s, encoding = Encoding::UTF_8) + URI.decode_www_form_component(s, encoding) + end end end diff --git a/spec/lib/pact/consumer_contract/interaction_v2_parser_spec.rb b/spec/lib/pact/consumer_contract/interaction_v2_parser_spec.rb index dd8c948..ef4e586 100644 --- a/spec/lib/pact/consumer_contract/interaction_v2_parser_spec.rb +++ b/spec/lib/pact/consumer_contract/interaction_v2_parser_spec.rb @@ -49,6 +49,21 @@ module Pact expect(subject.provider_state).to eq "foo" end end + + describe "query" do + let(:interaction_hash) do + { + "description" => "description", + "request" => { "method" => "GET", "path" => "/", "query" => "foo=bar1&foo=bar2"}, + "response" => { "status" => 200 }, + "providerState" => "foo" + } + end + + it "parses a string query into a hash" do + expect(subject.request.query).to eq Pact::QueryHash.new("foo"=> [ "bar1", "bar2" ]) + end + end end end end diff --git a/spec/lib/pact/consumer_contract/query_spec.rb b/spec/lib/pact/consumer_contract/query_spec.rb new file mode 100644 index 0000000..609c957 --- /dev/null +++ b/spec/lib/pact/consumer_contract/query_spec.rb @@ -0,0 +1,33 @@ +require 'pact/consumer_contract/query' + +module Pact + describe Query do + describe ".parse_string" do + subject { Query.parse_string(query_string) } + + describe "with a non nested query string" do + let(:query_string) { "foo=bar1" } + + it "returns a map of string to array" do + expect(subject).to eq "foo" => ["bar1"] + end + end + + describe "with a non nested query string with multiple params with the same name" do + let(:query_string) { "foo=bar1&foo=bar2" } + + it "returns a map of string to array" do + expect(subject).to eq "foo" => ["bar1", "bar2"] + end + end + + describe "with a rails style nested query" do + let(:query_string) { "foo=bar1&foo=bar2&baz[]=thing1&baz[]=thing2" } + + it "returns a nested map" do + expect(subject).to eq "foo" => "bar2", "baz" => ["thing1", "thing2"] + end + end + end + end +end