Current section
Files
Jump to
Current section
Files
lib/req_cassette/template/matcher.ex
defmodule ReqCassette.Template.Matcher do
@moduledoc """
Matches templated requests by comparing their structure (template markers), not values.
During replay, we create a templated version of the incoming request and compare it
to the cassette's templated request. If the structures match (same template markers
in the same positions), we can use the cassette.
## Key Insight
We match on **template structure** (`{{sku.0}}`), not the actual values
(`0234-3455` vs `6785-9443`).
## Examples
# Recording
Request: "List SKU 0234-3455 and SKU 0344-4456"
Templated: "List SKU {{sku.0}} and SKU {{sku.1}}"
# Replay with different SKUs
Request: "List SKU 6785-9443 and SKU 3488-3234"
Templated: "List SKU {{sku.0}} and SKU {{sku.1}}"
# Match: YES ✅ - Same structure, different values
# Replay with different structure
Request: "List SKU 6785-9443 but exclude SKU 3488-3234"
Templated: "List SKU {{sku.0}} but exclude SKU {{sku.1}}"
# Match: NO ❌ - Different structure ("and" vs "but exclude")
## What Gets Matched
The matcher compares templated request maps on these fields:
- `"method"` - HTTP method
- `"uri"` - Full URI (with template markers if applicable)
- `"query_string"` - Query string (with template markers)
- `"body"` or `"body_json"` - Body content (with template markers)
Headers are NOT compared (to avoid auth token issues).
## Normalization
Both requests should be normalized before templating to ensure consistent
comparison (see `ReqCassette.Template.Normalizer`).
"""
@doc """
Checks if two templated requests match.
Compares the structure of templated requests, ignoring actual values and
only looking at template markers and static text.
## Parameters
- `cassette_request` - Templated request from cassette (with markers like `{{sku.0}}`)
- `incoming_request` - Templated version of incoming request
## Returns
- `:match` if structures are identical
- `{:error, diff}` if they don't match, with diff details
## Examples
iex> cassette_req = %{
...> "method" => "GET",
...> "uri" => "https://api.example.com/sku/{{sku.0}}",
...> "body" => ""
...> }
iex> incoming_req = %{
...> "method" => "GET",
...> "uri" => "https://api.example.com/sku/{{sku.0}}",
...> "body" => ""
...> }
iex> match?(cassette_req, incoming_req)
:match
iex> cassette_req = %{
...> "method" => "GET",
...> "uri" => "https://api.example.com/sku/{{sku.0}}"
...> }
iex> incoming_req = %{
...> "method" => "POST",
...> "uri" => "https://api.example.com/sku/{{sku.0}}"
...> }
iex> match?(cassette_req, incoming_req)
{:error, %{field: "method", expected: "GET", actual: "POST"}}
"""
@spec match?(map(), map(), [atom()]) :: :match | {:error, map()}
def match?(cassette_request, incoming_request, match_on \\ [:method, :uri, :query, :body]) do
# Compare only the fields specified in match_on
# This honors the user's match_requests_on option for templated cassettes
# Return the first mismatch encountered (in match_on order)
Enum.reduce_while(match_on, :match, fn matcher, _acc ->
case check_matcher(matcher, cassette_request, incoming_request) do
:ok -> {:cont, :match}
{:error, _} = error -> {:halt, error}
end
end)
end
# Check a single matcher, returning :ok or {:error, details}
defp check_matcher(:method, cassette_request, incoming_request) do
if methods_match?(cassette_request, incoming_request) do
:ok
else
{:error,
%{
field: "method",
expected: cassette_request["method"],
actual: incoming_request["method"]
}}
end
end
defp check_matcher(:uri, cassette_request, incoming_request) do
if uris_match?(cassette_request, incoming_request) do
:ok
else
{:error,
%{
field: "uri",
expected: cassette_request["uri"],
actual: incoming_request["uri"]
}}
end
end
defp check_matcher(:query, cassette_request, incoming_request) do
if query_strings_match?(cassette_request, incoming_request) do
:ok
else
{:error,
%{
field: "query_string",
expected: cassette_request["query_string"] || "",
actual: incoming_request["query_string"] || ""
}}
end
end
defp check_matcher(:body, cassette_request, incoming_request) do
if bodies_match?(cassette_request, incoming_request) do
:ok
else
{:error,
%{
field: "body",
expected: extract_body_for_comparison(cassette_request),
actual: extract_body_for_comparison(incoming_request)
}}
end
end
# Ignore other matchers (like :headers) for template matching
defp check_matcher(_other, _cassette_request, _incoming_request), do: :ok
# Private helpers
defp methods_match?(req1, req2) do
method1 = req1["method"] || ""
method2 = req2["method"] || ""
String.upcase(method1) == String.upcase(method2)
end
defp uris_match?(req1, req2) do
uri1 = req1["uri"] || ""
uri2 = req2["uri"] || ""
uri1 == uri2
end
defp query_strings_match?(req1, req2) do
qs1 = req1["query_string"] || ""
qs2 = req2["query_string"] || ""
qs1 == qs2
end
defp bodies_match?(req1, req2) do
body1 = extract_body_for_comparison(req1)
body2 = extract_body_for_comparison(req2)
body1 == body2
end
defp extract_body_for_comparison(request) do
cond do
Map.has_key?(request, "body_json") ->
# For JSON bodies, compare the normalized JSON structure
# (already templated and normalized)
Jason.encode!(request["body_json"])
Map.has_key?(request, "body") ->
request["body"] || ""
Map.has_key?(request, "body_blob") ->
request["body_blob"] || ""
true ->
""
end
end
end