ReqCassette.Cassette (ReqCassette v0.5.1)
View SourceHandles cassette file format v1.0 with multiple interactions per file.
This module provides the core functionality for creating, loading, saving, and searching cassette files. Cassettes store HTTP request/response pairs ("interactions") in a human-readable JSON format.
Cassette Format v1.0
Each cassette file contains a version field and an array of interactions:
{
"version": "1.0",
"interactions": [
{
"request": {
"method": "GET",
"uri": "https://api.example.com/users/1",
"query_string": "filter=active",
"headers": {
"accept": ["application/json"],
"user-agent": ["req/0.5.15"]
},
"body_type": "text",
"body": ""
},
"response": {
"status": 200,
"headers": {
"content-type": ["application/json"]
},
"body_type": "json",
"body_json": {
"id": 1,
"name": "Alice"
}
},
"recorded_at": "2025-10-16T14:23:45.123456Z"
}
]
}Key Features
Multiple Interactions Per Cassette
Multiple interactions can be stored in a single cassette file with human-readable names:
# All requests in one test go to one cassette
ReqCassette.with_cassette("user_workflow", [], fn plug ->
user = Req.get!("/users/1", plug: plug) # Interaction 1
posts = Req.get!("/posts", plug: plug) # Interaction 2
comments = Req.get!("/comments", plug: plug) # Interaction 3
end)
# Creates: user_workflow.json with 3 interactionsBenefits:
- Related requests grouped together
- Meaningful filenames
- Logical workflow organization
Body Type Discrimination
Bodies are stored in one of three formats based on content type:
body_json- JSON responses stored as native Elixir data structuresbody- Text responses (HTML, XML, CSV) stored as stringsbody_blob- Binary data (images, PDFs) base64-encoded
Example JSON storage:
"body_json": {
"id": 1,
"name": "Alice"
}Benefits:
- Compact cassette files
- No double-encoding/escaping
- Human-readable JSON responses
- Easy to edit or debug
Pretty-Printed JSON
All cassettes are saved with Jason.encode!(cassette, pretty: true) for:
- Git-friendly diffs
- Easy manual inspection
- Debuggability
- Version control readability
Request Matching with Normalization
Requests are matched using configurable criteria with automatic normalization:
- Query parameters are order-independent:
?a=1&b=2matches?b=2&a=1 - JSON body keys are order-independent:
{"a":1,"b":2}matches{"b":2,"a":1} - Headers are case-insensitive:
Acceptmatchesaccept
Examples
# Create a new cassette
cassette = ReqCassette.Cassette.new()
#=> %{"version" => "1.0", "interactions" => []}
# Add an interaction
cassette = add_interaction(cassette, conn, request_body, response)
# Save to disk (pretty-printed)
save("test/cassettes/my_api.json", cassette)
# Load from disk
{:ok, cassette} = load("test/cassettes/my_api.json")
# Find a matching interaction
case find_interaction(cassette, conn, body, [:method, :uri]) do
{:ok, response} -> response
:not_found -> # Record new interaction
end
# Multiple interactions in one cassette
cassette = new()
cassette = add_interaction(cassette, conn1, body1, resp1)
cassette = add_interaction(cassette, conn2, body2, resp2)
cassette = add_interaction(cassette, conn3, body3, resp3)
save("workflow.json", cassette)
# workflow.json now contains 3 interactionsSee Also
ReqCassette.BodyType- Body type detection and encodingReqCassette.Filter- Sensitive data filteringReqCassette.Plug- Main plug that uses this module
Summary
Types
A single HTTP request/response interaction
HTTP request details
HTTP response details
A cassette file containing multiple interactions
Functions
Adds an interaction to a cassette.
Diagnoses why a request didn't match any interaction in the cassette.
Finds a matching interaction in the cassette based on request matching criteria.
Finds a matching interaction starting from a given index (for sequential matching).
Formats diagnostic results into a human-readable string.
Loads a cassette from disk.
Creates a new empty cassette with version 1.0.
Sanitizes a cassette name for use as a filename.
Saves a cassette to disk as pretty-printed JSON in v2.0 format.
Types
A single HTTP request/response interaction
@type request() :: %{ method: String.t(), uri: String.t(), query_string: String.t(), headers: map(), body_type: String.t() }
HTTP request details
HTTP response details
@type t() :: %{version: String.t(), interactions: [interaction()]}
A cassette file containing multiple interactions
Functions
@spec add_interaction(map(), map(), Req.Response.t(), map()) :: map()
Adds an interaction to a cassette.
Creates a new interaction from the given request and response, applies any configured filters, and appends it to the cassette's interactions array.
Parameters
cassette- The cassette map (fromnew/0orload/1)conn- ThePlug.Connstruct with request details (method, URI, headers, etc.)request_body- The raw request body as a binary stringresponse- TheReq.Responsestruct from the HTTP callopts- Optional map of filter options (default:%{})
Filter Options
The opts parameter can include:
:filter_sensitive_data- List of{regex, replacement}tuples:filter_request_headers- List of header names to remove from requests:filter_response_headers- List of header names to remove from responses:before_record- Callback function(interaction -> interaction)
See ReqCassette.Filter for details on filtering.
Returns
Updated cassette map with the new interaction appended to the "interactions" array.
Body Type Detection
This function automatically detects the body type for both request and response:
- JSON bodies are stored in
body_jsonfield as native Elixir data structures - Text bodies (HTML, XML, CSV) are stored in
bodyfield as strings - Binary bodies (images, PDFs) are base64-encoded in
body_blobfield
Timestamp
Each interaction includes a recorded_at field with an ISO8601 UTC timestamp
indicating when the interaction was captured.
Examples
# Basic usage
cassette = new()
cassette = add_interaction(cassette, conn, "", response)
# With filtering
opts = %{
filter_sensitive_data: [
{~r/api_key=[\w-]+/, "api_key=<REDACTED>"}
],
filter_request_headers: ["authorization"]
}
cassette = add_interaction(cassette, conn, body, response, opts)
# Multiple interactions
cassette = new()
cassette = add_interaction(cassette, conn1, body1, resp1)
cassette = add_interaction(cassette, conn2, body2, resp2)
cassette = add_interaction(cassette, conn3, body3, resp3)
# cassette now has 3 interactions
# With callback filter
opts = %{
before_record: fn interaction ->
put_in(interaction, ["response", "body_json", "secret"], "<REDACTED>")
end
}
cassette = add_interaction(cassette, conn, body, response, opts)
Diagnoses why a request didn't match any interaction in the cassette.
Returns detailed diagnostic information for each interaction showing which matchers matched and which didn't, along with the stored and incoming values.
Parameters
cassette- The cassette mapconn- ThePlug.Connstruct representing the current requestrequest_body- The raw request body as a binary stringmatch_on- List of matchers to checkfilter_opts- Optional filter options (default:%{})
Returns
A list of diagnostic maps, one per interaction:
[
%{
index: 0,
results: %{
method: {:match, "POST", "POST"},
uri: {:no_match, "https://stored.url", "https://incoming.url"},
...
}
},
...
]Examples
diagnostics = diagnose_mismatch(cassette, conn, body, [:method, :uri])
formatted = format_mismatch_diagnostics(diagnostics, [:method, :uri])
Finds a matching interaction in the cassette based on request matching criteria.
Searches through all interactions in the cassette to find one where the request
matches the given conn and body according to the specified matchers. Returns
the first matching interaction's response.
Parameters
cassette- The cassette map (loaded fromload/1or created withnew/0)conn- ThePlug.Connstruct representing the current requestrequest_body- The raw request body as a binary stringmatch_on- List of matchers that determine matching criteria
Matchers
The match_on parameter accepts a list of atoms that specify what to match:
:method- HTTP method (GET, POST, etc.) - case-insensitive:uri- Full URI including scheme, host, port, and path:query- Query parameters - order-independent:headers- Request headers - case-insensitive, order-independent:body- Request body - JSON bodies are order-independent
Common matching strategies:
[:method, :uri]- Match only method and path (ignore query, headers, body)[:method, :uri, :query]- Match method, path, and query params[:method, :uri, :query, :body]- Match method, path, query, and body[:method, :uri, :query, :headers, :body]- Match everything (most strict)
Returns
{:ok, response}- Found a matching interaction, returns the response map:not_found- No interaction matches the given criteria
Normalization
To ensure consistent matching, certain fields are normalized:
- Query strings:
?a=1&b=2matches?b=2&a=1 - JSON bodies:
{"a":1,"b":2}matches{"b":2,"a":1} - Headers: Case-insensitive comparison, sorted by key
This allows for flexible matching while maintaining deterministic behavior.
Examples
# Basic matching on method and URI only
case find_interaction(cassette, conn, body, [:method, :uri]) do
{:ok, response} ->
# Found: use the cached response
response
:not_found ->
# Not found: need to record new interaction
make_real_request(conn, body)
end
# Match on method, URI, and query (useful for GET requests with params)
find_interaction(cassette, conn, "", [:method, :uri, :query])
#=> {:ok, %{"status" => 200, "headers" => %{}, ...}}
# Strict matching (all criteria)
find_interaction(cassette, conn, body, [:method, :uri, :query, :headers, :body])
#=> :not_found
# Ignore request body differences (useful for POST with timestamps)
conn = %Plug.Conn{method: "POST", request_path: "/api/users", ...}
body1 = ~s({"name":"Alice","timestamp":"2025-10-16T10:00:00Z"})
body2 = ~s({"name":"Alice","timestamp":"2025-10-16T10:00:01Z"})
# First call records with body1
cassette = add_interaction(cassette, conn, body1, response1)
# Second call with different body but same method/URI matches!
find_interaction(cassette, conn, body2, [:method, :uri])
#=> {:ok, response1}
# Multiple interactions in cassette - finds first match
cassette = new()
|> add_interaction(conn_get, "", resp_get)
|> add_interaction(conn_post, post_body, resp_post)
find_interaction(cassette, conn_get, "", [:method, :uri])
#=> {:ok, resp_get}
find_interaction(cassette, conn_post, post_body, [:method, :uri, :body])
#=> {:ok, resp_post}
@spec find_interaction_sequential(map(), map(), [atom()], non_neg_integer()) :: {:ok, map(), non_neg_integer()} | :not_found
Finds a matching interaction starting from a given index (for sequential matching).
This is used when templates are enabled to ensure requests match interactions in order, rather than always matching the first structurally-compatible interaction.
Parameters
cassette- The cassette mapfiltered_request- Pre-filtered request mapmatch_on- List of matchers (e.g.,[:method, :uri, :body])start_index- The index to start searching from (0-based)
Returns
{:ok, response, matched_index}- Response and the index that matched:not_found- No matching interaction at the exact start_index
Formats diagnostic results into a human-readable string.
Parameters
diagnostics- List of diagnostic maps fromdiagnose_mismatch/5match_on- List of matchers being checked
Returns
A formatted string showing match status and details for mismatches.
Examples
diagnostics = diagnose_mismatch(cassette, conn, body, [:method, :uri])
IO.puts(format_mismatch_diagnostics(diagnostics, [:method, :uri]))
# Output:
# 🟢 :method match
# 🔴 :uri NO match
#
# 🔬 :uri details
#
# Record 1:
# stored: "https://api.example.com/old"
# value: "https://api.example.com/new"
Loads a cassette from disk.
Supports v2.0 (with templates), v1.0, and v0.1 formats for backward compatibility.
Version Handling
- v2.0 - Latest format with template support and sorted JSON (loaded as-is)
- v1.0 - Previous format, normalized to v2.0 in memory (JSON sorted on load)
- v0.1 - Legacy format, migrated to v2.0
Parameters
path- File path to load cassette from
Returns
{:ok, cassette}- Successfully loaded cassette (normalized to v2.0 if needed):not_found- File doesn't exist or can't be parsed
Examples
load("/path/to/cassette.json")
# => {:ok, %{"version" => "2.0", "interactions" => [...]}}
load("/path/to/missing.json")
# => :not_found
@spec new() :: map()
Creates a new empty cassette with version 1.0.
Examples
new()
# => %{version: "1.0", interactions: []}
Sanitizes a cassette name for use as a filename.
Replaces non-word characters (except spaces and hyphens) with underscores, and collapses whitespace to single underscores.
Examples
iex> ReqCassette.Cassette.sanitize_filename("my cassette")
"my_cassette"
iex> ReqCassette.Cassette.sanitize_filename("test/with:special*chars")
"test_with_special_chars"
Saves a cassette to disk as pretty-printed JSON in v2.0 format.
Always saves as v2.0 format with sorted JSON for consistency and git-friendliness.
Parameters
path- File path where cassette should be savedcassette- The cassette map
Examples
save("/path/to/cassette.json", cassette)