defmodule ExVCR.Handler do
@moduledoc """
Provide operations for request/response.
"""
alias ExVCR.{Recorder, Setting, Util}
alias ExVCR.Actor.Options
@doc """
Get response from either server or cache.
"""
def get_response(nil, request) do
:meck.passthrough(request)
end
def get_response(recorder, request) do
if ignore_request?(request, recorder) do
get_response_from_server(request, recorder, false)
else
get_response_from_cache(request, recorder) ||
ignore_server_fetch!(request, recorder) ||
get_response_from_server(request, recorder, true)
end
end
@doc """
Get response from the cache (pre-recorded cassettes).
"""
def get_response_from_cache(request, recorder) do
recorder_options = Options.get(recorder.options)
adapter = ExVCR.Recorder.options(recorder)[:adapter]
params = adapter.generate_keys_for_request(request)
{response, responses} = find_response(Recorder.get(recorder), params, recorder_options)
response = adapter.hook_response_from_cache(request, response)
case { response, stub_mode?(recorder_options) } do
{ nil, true } ->
raise ExVCR.InvalidRequestError,
message: "response for [#{invalid_request_details(recorder_options, params)}] was not found"
{ nil, false } ->
nil
{ response, _ } ->
ExVCR.Checker.add_cache_count(recorder)
Recorder.set(responses, recorder)
adapter.get_response_value_from_cache(response)
end
end
defp invalid_request_details(recorder_options, params) do
available_match_types = [:headers, :query, :request_body]
match_requests_on = Keyword.get(recorder_options, :match_requests_on, [])
extra_details =
for type <- available_match_types, type in match_requests_on do
String.upcase("#{type}") <> ":#{inspect(params[type])}"
end
([
"URL:#{params[:url]}",
"METHOD:#{params[:method]}"
] ++ extra_details)
|> Enum.join(", ")
end
defp stub_mode?(options) do
options[:custom] == true || options[:stub] != nil
end
defp stub_with_non_empty_request_body?(options) do
options[:stub] != nil && List.first(options[:stub]).request.request_body != ""
end
defp has_match_requests_on(type, options) do
flags = options[:match_requests_on] || []
if is_list(flags) == false do
raise "Invalid match_requests_on option is specified - #{inspect flags}"
end
Enum.member?(flags, type)
end
defp find_response(responses, keys, recorder_options), do: find_response(responses, keys, recorder_options, [])
defp find_response([], _keys, _recorder_options, _acc), do: {nil, nil}
defp find_response([response|tail], keys, recorder_options, acc) do
case match_response(response, keys, recorder_options) do
true -> {response[:response], Enum.reverse(acc) ++ tail ++ [response]}
false -> find_response(tail, keys, recorder_options, [response|acc])
end
end
defp match_response(response, keys, recorder_options) do
match_by_url(response, keys, recorder_options)
and match_by_method(response, keys)
and match_by_request_body(response, keys, recorder_options)
and match_by_headers(response, keys, recorder_options)
and match_by_custom_matchers(response, keys, recorder_options)
end
defp match_by_custom_matchers(response, keys, recorder_options) do
custom_matchers = recorder_options[:custom_matchers] || []
Enum.reduce_while(custom_matchers, true, fn matcher, _acc ->
if matcher.(response, keys, recorder_options), do: {:cont, true}, else: {:halt, false}
end)
end
defp match_by_url(response, keys, recorder_options) do
request_url = response[:request].url
key_url = to_string(keys[:url]) |> ExVCR.Filter.filter_sensitive_data
if stub_mode?(recorder_options) do
if match = Regex.run(~r/~r\/(.+)\//, request_url) do
pattern = Regex.compile!(Enum.at(match, 1))
Regex.match?(pattern, key_url)
else
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)
normalize_url(request_url) == normalize_url(key_url)
end
end
defp match_by_headers(response, keys, options) do
if has_match_requests_on(:headers, options) do
request_headers =
keys[:headers]
|> Util.stringify_keys()
|> Enum.map(fn {key, value} ->
replaced_value = ExVCR.Filter.filter_sensitive_data(value)
replaced_value = ExVCR.Filter.filter_request_header(key, replaced_value)
{key, replaced_value}
end)
response_headers =
response[:request].headers
|> Enum.to_list()
|> Util.stringify_keys()
Keyword.equal?(request_headers, response_headers)
else
true
end
end
defp parse_url(url, options) do
if has_match_requests_on(:query, options) do
to_string(url)
else
to_string(url) |> ExVCR.Filter.strip_query_params
end
end
defp match_by_method(head, params) do
if params[:method] == nil || head[:request].method == nil do
true
else
to_string(params[:method]) == head[:request].method
end
end
defp match_by_request_body(response, keys, recorder_options) do
if stub_with_non_empty_request_body?(recorder_options) || has_match_requests_on(:request_body, recorder_options) do
request_body = response[:request].body || response[:request].request_body
key_body = keys[:request_body] |> to_string |> ExVCR.Filter.filter_sensitive_data
if match = Regex.run(~r/~r\/(.+)\//, request_body) do
pattern = Regex.compile!(Enum.at(match, 1))
Regex.match?(pattern, key_body)
else
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) when is_binary(request_body) do
case JSX.decode(request_body) do
{:ok, decoded} ->
normalize_request_body(decoded)
{:error, _} ->
normalize_query(request_body)
end
end
defp normalize_request_body(request_body) when is_map(request_body) do
request_body
|> Map.to_list()
|> Enum.sort_by(fn {key, _val} -> key end)
|> Enum.map(fn
{key, val} when is_map(val) -> {key, normalize_request_body(val)}
{key, val} when is_list(val) -> {key, normalize_request_body(val)}
{key, val} -> {key, val}
end)
|> URI.encode_query()
end
defp normalize_request_body(request_body) when is_list(request_body) do
request_body
|> Enum.map(fn
val when is_map(val) -> normalize_request_body(val)
val when is_list(val) -> normalize_request_body(val)
val -> val
end)
|> Enum.map_join(&to_string/1)
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)
|> adapter.hook_response_from_server
if record? do
raise_error_if_cassette_already_exists(recorder, inspect(request))
Recorder.append(recorder, adapter.convert_to_string(request, response))
ExVCR.Checker.add_server_count(recorder)
end
response
end
defp ignore_request?(request, recorder) do
ignore_localhost?(request, recorder) ||
ignore_urls?(request, recorder)
end
defp ignore_localhost?(request, recorder) do
ignore_localhost =
Keyword.get(Recorder.options(recorder), :ignore_localhost, Setting.get(:ignore_localhost))
if ignore_localhost do
adapter = ExVCR.Recorder.options(recorder)[:adapter]
params = adapter.generate_keys_for_request(request)
url = to_string(params[:url])
Regex.match?(~r[https?://localhost], url)
else
false
end
end
defp ignore_urls?(request, recorder) do
ignore_urls = ExVCR.Recorder.options(recorder)[:ignore_urls] || ExVCR.Setting.get(:ignore_urls)
if ignore_urls do
adapter = ExVCR.Recorder.options(recorder)[:adapter]
params = adapter.generate_keys_for_request(request)
url = to_string(params[:url])
Enum.any?(ignore_urls, fn r_url ->
Regex.match?(r_url, url)
end)
else
false
end
end
defp ignore_server_fetch!(request, recorder) do
strict_mode =
Keyword.get(Recorder.options(recorder), :strict_mode, Setting.get(:strict_mode))
if strict_mode do
message = """
A matching cassette was not found for this request.
An error was raised, rather than recording a cassette, because the option `strict_mode` is turned on.
Request: #{inspect(request)}
"""
throw(message)
end
false
end
defp raise_error_if_cassette_already_exists(recorder, request_description) do
file_path = ExVCR.Recorder.get_file_path(recorder)
if File.exists?(file_path) do
message = """
Request did not match with any one in the current cassette: #{file_path}.
Delete the current cassette with [mix vcr.delete] and re-record.
Request: #{request_description}
"""
raise ExVCR.RequestNotMatchError, message: message
end
end
end