Skip to content

Commit

Permalink
Normalizes request body and URL by parsing params to a list and sorti…
Browse files Browse the repository at this point in the history
…ng (#211)

* Normalizes request body by parsing as params, then converting to a list

Before this change, if the request body was a list of params that were
in a different order than the params in the cassette, the request body
match would fail.

As of OTP 26, map key order is not guaranteed, so request bodies that
are created using maps can fail to match since the order of their keys
is not idempotent.

These changes convert the request body to a list of params and sort it
before comparing it to the request body in the cassette. This ensures
cassettes will be matched as long as their request bodies contain the
same set of key-value pairs as the incoming request body.

* Normalizes url by parsing params, converting to a list, and sorting

Before this change, if the url query params were in a different order
than the url params in the cassette, the request body match would fail.

As of OTP 26, map key order is not guaranteed, so url params that are
created using maps can fail to match since the order of their keys is
not idempotent.

These changes convert the url params to a list and sort it before
comparing it to the url in the cassette. This ensures cassettes will be
matched as long as their url params contain the same set of key-value
pairs as the incoming url params (and the rest of the url matches too).
  • Loading branch information
patrickberkeley committed Aug 27, 2023
1 parent 406af2d commit d1c1765
Show file tree
Hide file tree
Showing 6 changed files with 61 additions and 8 deletions.
2 changes: 1 addition & 1 deletion fixture/custom_cassettes/response_mocking_with_param.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[
{
"request": {
"url": "http://example.com?auth_token=123abc",
"url": "http://example.com?auth_token=123abc&another_param=456",
"method": "get"
},
"response": {
Expand Down
2 changes: 1 addition & 1 deletion fixture/vcr_cassettes/different_query_params_on.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"headers": [],
"method": "get",
"options": [],
"url": "http://localhost:34006/server?p=3"
"url": "http://localhost:34006/server?p=3&q=string"
},
"response": {
"body": "test_response_before",
Expand Down
28 changes: 25 additions & 3 deletions lib/exvcr/handler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,13 @@ defmodule ExVCR.Handler do
pattern = Regex.compile!(Enum.at(match, 1))
Regex.match?(pattern, key_url)
else
request_url == key_url
normalize_url(request_url) == normalize_url(key_url)
end
else
request_url = parse_url(request_url, recorder_options)
key_url = parse_url(key_url, recorder_options)

request_url == key_url
normalize_url(request_url) == normalize_url(key_url)
end
end

Expand Down Expand Up @@ -170,13 +170,35 @@ defmodule ExVCR.Handler do
pattern = Regex.compile!(Enum.at(match, 1))
Regex.match?(pattern, key_body)
else
request_body == key_body
normalize_request_body(request_body) == normalize_request_body(key_body)
end
else
true
end
end

defp normalize_url(url) do
original_url = URI.parse(url)

original_url
|> Map.put(:query, normalize_query(original_url.query))
|> URI.to_string()
end

defp normalize_request_body(request_body) do
normalize_query(request_body)
end

defp normalize_query(nil), do: nil

defp normalize_query(query) do
query
|> URI.decode_query()
|> Map.to_list()
|> Enum.sort_by(fn {key, _val} -> key end)
|> URI.encode_query()
end

defp get_response_from_server(request, recorder, record?) do
adapter = ExVCR.Recorder.options(recorder)[:adapter]
response = :meck.passthrough(request)
Expand Down
3 changes: 1 addition & 2 deletions test/handler_custom_mode_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@ defmodule ExVCR.Adapter.HandlerCustomModeTest do

test "query param match succeeds with custom mode" do
use_cassette "response_mocking_with_param", custom: true do
HTTPotion.get("http://example.com?auth_token=123abc", []).body =~ ~r/Custom Response/
HTTPotion.get("http://example.com?another_param=456&auth_token=123abc", []).body =~ ~r/Custom Response/
end
end


test "custom with valid response" do
use_cassette "response_mocking", custom: true do
assert HTTPotion.get("http://example.com", []).body =~ ~r/Custom Response/
Expand Down
2 changes: 1 addition & 1 deletion test/handler_options_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ defmodule ExVCR.Adapter.HandlerOptionsTest do
test "specifying match_requests_on: [:query] matches query params" do
use_cassette "different_query_params_on", match_requests_on: [:query] do
HttpServer.start(path: "/server", port: @port, response: "test_response_before")
assert HTTPotion.get("#{@url}?p=3", []).body =~ ~r/test_response_before/
assert HTTPotion.get("#{@url}?q=string&p=3", []).body =~ ~r/test_response_before/
HttpServer.stop(@port)

# this method call should NOT be mocked as previous "test_response_before" response
Expand Down
32 changes: 32 additions & 0 deletions test/handler_stub_mode_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ defmodule ExVCR.Adapter.HandlerStubModeTest do
end
end

test "url matches as regardless of query param order" do
use_cassette :stub, [url: "http://localhost?param1=10&param2=20&param3=30"] do
{:ok, status_code, _headers, body} = :ibrowse.send_req('http://localhost?param3=30&param1=10&param2=20', [], :get)
assert status_code == '200'
assert to_string(body) =~ ~r/Hello World/
end
end

test "url matches as regex" do
use_cassette :stub, [url: "~r/.+/"] do
{:ok, status_code, _headers, body} = :ibrowse.send_req('http://localhost', [], :get)
Expand All @@ -39,6 +47,14 @@ defmodule ExVCR.Adapter.HandlerStubModeTest do
end
end

test "request_body matches as string" do
use_cassette :stub, [url: 'http://localhost', method: :post, request_body: "some-string", body: "Hello World"] do
{:ok, status_code, _headers, body} = :ibrowse.send_req('http://localhost', [], :post, 'some-string')
assert status_code == '200'
assert to_string(body) =~ ~r/Hello World/
end
end

test "request_body matches as regex" do
use_cassette :stub, [url: 'http://localhost', method: :post, request_body: "~r/param1/", body: "Hello World"] do
{:ok, status_code, _headers, body} = :ibrowse.send_req('http://localhost', [], :post, 'param1=value1&param2=value2')
Expand All @@ -55,6 +71,22 @@ defmodule ExVCR.Adapter.HandlerStubModeTest do
end
end

test "request_body matches as unordered list of params" do
use_cassette :stub, [url: 'http://localhost', method: :post, request_body: "param1=10&param3=30&param2=20", body: "Hello World"] do
{:ok, status_code, _headers, body} = :ibrowse.send_req('http://localhost', [], :post, 'param2=20&param1=10&param3=30')
assert status_code == '200'
assert to_string(body) =~ ~r/Hello World/
end
end

test "request_body mismatches as unordered list of params" do
assert_raise ExVCR.InvalidRequestError, fn ->
use_cassette :stub, [url: 'http://localhost', method: :post, request_body: "param1=10&param3=30&param4=40", body: "Hello World"] do
{:ok, _status_code, _headers, _body} = :ibrowse.send_req('http://localhost', [], :post, 'param2=20&param1=10&param3=30')
end
end
end

test "request_body mismatch should raise error" do
assert_raise ExVCR.InvalidRequestError, fn ->
use_cassette :stub, [url: 'http://localhost', method: :post, request_body: '{"one" => 1}'] do
Expand Down

0 comments on commit d1c1765

Please sign in to comment.