ReqCassette.Plug (ReqCassette v0.5.1)
View SourceA Plug that intercepts Req HTTP requests and records/replays them from cassette files.
This module implements the Plug behaviour and is designed to be used with Req's
:plug option to enable VCR-style testing for HTTP clients.
Usage
The easiest way to use this plug is via the ReqCassette.with_cassette/3 function,
but it can also be used directly with Req:
# With with_cassette/3 (recommended)
ReqCassette.with_cassette("my_api_call", [cassette_dir: "test/cassettes"], fn plug ->
Req.get!("https://api.example.com/data", plug: plug)
end)
# Direct usage
Req.get!(
"https://api.example.com/data",
plug: {ReqCassette.Plug, %{
cassette_name: "my_api_call",
cassette_dir: "test/cassettes"
}}
)Cassette Naming Best Practice
Always provide :cassette_name for human-readable, maintainable cassette files.
Without cassette_name (not recommended):
plug: {ReqCassette.Plug, %{cassette_dir: "test/cassettes"}}
# Creates: a1b2c3d4e5f6789012345678901234ab.json
# ❌ Cryptic MD5 hash - hard to identify which test this belongs toWith cassette_name (recommended):
plug: {ReqCassette.Plug, %{cassette_name: "github_user", cassette_dir: "test/cassettes"}}
# Creates: github_user.json
# ✅ Clear, readable - easy to manage and understandThe MD5 hash fallback exists for backward compatibility but should be avoided in new code.
Options
:cassette_name- (Recommended) Human-readable name for the cassette file (e.g.,"github_api"). Createsgithub_api.json. If omitted, generates a cryptic MD5 hash filename based on matching options (:mode,:cassette_dir, and:cassette_nameare excluded from hash). Always provide this option for maintainable tests.:cassette_dir- Directory where cassette files are stored (default:"cassettes"):mode- Recording mode (default::record). See "Recording Modes" below.:match_requests_on- List of criteria for matching requests (default:[:method, :uri, :query, :headers, :body]):filter_sensitive_data- List of{regex, replacement}tuples to filter sensitive data:filter_request_headers- List of request header names to remove (case-insensitive):filter_response_headers- List of response header names to remove (case-insensitive):before_record- Callback function for custom filtering (receives and returns interaction map)
Recording Modes
ReqCassette supports three recording modes that control when cassettes are created/used:
:record (default)
Records new interactions, replays existing ones. Appends to existing cassettes. Ideal for development:
# First run: records interaction to cassette
ReqCassette.with_cassette("api", [], fn plug ->
Req.get!("https://api.example.com/data", plug: plug)
end)
# Subsequent runs: replays from cassette (no network call)
# To re-record: delete cassette file first
File.rm!("test/cassettes/api.json"):replay
Only replays from cassettes. Raises error if cassette or matching interaction not found. Perfect for CI environments to ensure no unexpected network calls:
ReqCassette.with_cassette("api", [mode: :replay], fn plug ->
Req.get!("https://api.example.com/data", plug: plug)
# Raises if cassette doesn't exist or no matching interaction
end):bypass
Ignores cassettes completely, always hits the network. Never saves. Useful for debugging or selectively disabling cassettes:
ReqCassette.with_cassette("api", [mode: :bypass], fn plug ->
Req.get!("https://api.example.com/data", plug: plug)
# Always hits network, never creates cassette
end)Request Matching
By default, requests are matched on all criteria (method, URI, query, headers, body).
You can customize this with :match_requests_on:
# Only match on method and URI (ignore query params and body)
ReqCassette.with_cassette(
"search",
[match_requests_on: [:method, :uri]],
fn plug ->
Req.get!("https://api.example.com/search?q=foo", plug: plug)
# Later: ?q=bar will replay the same response
end
)Available matchers:
:method- HTTP method (GET, POST, etc.):uri- Path without query string:query- Query parameters (order-independent):headers- Request headers (case-insensitive):body- Request body (JSON key order-independent)
Cassette File Format
Cassettes use v1.0 format with pretty-printed JSON and multiple interactions:
{
"version": "1.0",
"interactions": [
{
"request": {
"method": "GET",
"uri": "/api/users/1",
"query_string": "",
"headers": {
"accept": ["application/json"]
},
"body": ""
},
"response": {
"status": 200,
"headers": {
"content-type": ["application/json"]
},
"body_json": {
"id": 1,
"name": "Alice"
}
},
"recorded_at": "2025-10-16T12:00:00Z"
}
]
}Body Types
Responses are stored in one of three formats based on content type:
body_json- JSON responses stored as native objects (pretty-printed)body- Text responses (HTML, XML, CSV) stored as stringsbody_blob- Binary data (images, PDFs) stored as base64
This approach produces compact, human-readable cassette files.
Examples
# Basic GET request with human-readable filename
ReqCassette.with_cassette("github_user", [], fn plug ->
Req.get!("https://api.github.com/users/octocat", plug: plug)
end)
# Creates: test/cassettes/github_user.json
# POST with custom matching (ignore request body)
ReqCassette.with_cassette(
"create_user",
[match_requests_on: [:method, :uri]],
fn plug ->
Req.post!(
"https://api.example.com/users",
json: %{name: "Alice"},
plug: plug
)
end
)
# Filter sensitive data with regex
ReqCassette.with_cassette(
"authenticated",
[
filter_sensitive_data: [
{~r/api_key=[\w-]+/, "api_key=<REDACTED>"}
],
filter_request_headers: ["authorization"]
],
fn plug ->
Req.get!(
"https://api.example.com/data?api_key=secret",
headers: [{"authorization", "Bearer token"}],
plug: plug
)
end
)
# Multiple requests in one cassette
ReqCassette.with_cassette("workflow", [], fn plug ->
user = Req.get!("https://api.example.com/user", plug: plug)
posts = Req.get!("https://api.example.com/posts", plug: plug)
{user, posts}
end)
# Single cassette file contains both interactions
# Custom filtering with callback
ReqCassette.with_cassette(
"custom_filter",
[
before_record: fn interaction ->
put_in(interaction, ["response", "body_json", "email"], "redacted@example.com")
end
],
fn plug ->
Req.get!("https://api.example.com/profile", plug: plug)
end
)Architecture
This plug uses Req's native plug system, which provides:
- ✅ Async-safe: Works with
async: truein ExUnit - ✅ Process-isolated: No global state or process dictionary
- ✅ Adapter-agnostic: Works with any Req adapter (Finch, etc.)
- ✅ No mocking: Uses stable, public APIs
How It Works
Recording Flow (
:recordmode):- Intercepts the outgoing Req request via the plug callback
- Checks if a matching cassette/interaction exists
- If not found, forwards the request to the real server
- Applies filters to remove sensitive data
- Saves the response to a cassette file (pretty-printed JSON)
- Returns the response to the caller
Replay Flow (
:replayor:recordwith existing cassette):- Intercepts the outgoing request
- Finds the matching cassette file by name
- Searches for a matching interaction using configured matchers
- Loads and returns the saved response
- No network call is made
Bypass Flow (
:bypassmode):- Forwards request directly to the network
- Never reads or writes cassettes
- Useful for debugging or selectively disabling recording
Integration with ReqLLM
Works seamlessly with ReqLLM for testing LLM integrations:
ReqCassette.with_cassette("claude_chat", [], fn plug ->
ReqLLM.chat(
"anthropic:claude-sonnet-4-20250514",
[%{role: "user", content: "Hello!"}],
req_http_options: [plug: plug]
)
end)
Summary
Functions
Handles an incoming HTTP request by either replaying from cassette or recording.
Initializes the plug with the given options.
Types
@type opts() :: %{ optional(:cassette_name) => String.t(), cassette_dir: String.t(), mode: :replay | :record | :bypass, match_requests_on: [atom()] }
Options for configuring the cassette plug.
:cassette_dir- Directory where cassette files are stored:cassette_name- Human-readable name for the cassette file:mode- Recording mode (:replay,:record,:bypass):match_requests_on- List of matchers for finding interactions
Functions
@spec call(Plug.Conn.t(), opts()) :: Plug.Conn.t()
Handles an incoming HTTP request by either replaying from cassette or recording.
This is the main entry point for the plug, called by Req for each HTTP request. The behavior depends on the configured mode:
:record(default) - Checks for matching interaction, records if not found:replay- Only uses cassettes, raises error if not found:bypass- Ignores cassettes, always uses network
Parameters
Returns
A Plug.Conn struct with the response set and halted.
Request Matching
When looking for a matching interaction in an existing cassette, the plug uses
the matchers specified in :match_requests_on. For example:
[:method, :uri]- Match only method and path (ignore query params and body)[:method, :uri, :query]- Match method, path, and query params[:method, :uri, :query, :headers, :body]- Match everything (default)
Query parameters and JSON body keys are normalized (order-independent) to ensure consistent matching.
Filtering
Before recording, the plug applies filters in this order:
- Regex filters (
:filter_sensitive_data) - Applied to URI, query string, and bodies - Header filters (
:filter_request_headers,:filter_response_headers) - Removes specified headers - Callback filter (
:before_record) - Custom transformation function
Examples
# Direct plug usage with replay mode (CI environment)
plug_opts = %{
cassette_name: "github_api",
cassette_dir: "test/cassettes",
mode: :replay
}
conn = %Plug.Conn{
method: "GET",
request_path: "/users/octocat",
# ... other fields
}
# Raises if cassette doesn't exist
conn = ReqCassette.Plug.call(conn, plug_opts)
# With custom matching (ignore body differences)
plug_opts = %{
cassette_name: "api_call",
match_requests_on: [:method, :uri, :query]
}
conn = ReqCassette.Plug.call(conn, plug_opts)
# POST requests with different bodies will match the same interaction
# With filtering
plug_opts = %{
cassette_name: "auth_api",
filter_sensitive_data: [
{~r/api_key=[\w-]+/, "api_key=<REDACTED>"}
],
filter_request_headers: ["authorization"]
}
conn = ReqCassette.Plug.call(conn, plug_opts)
# API keys in query string are redacted, authorization headers removedErrors
This function raises in the following cases:
- Mode
:replaywith missing cassette - Mode
:replaywith no matching interaction - Mode
:recordwhen network request fails
The error messages include context to help debug the issue.
Initializes the plug with the given options.
This callback is invoked by Req when the plug is first used. It merges the provided options with default values to create the final configuration.
Parameters
opts- A map of options (seeopts/0)
Returns
The merged options map with defaults applied.
Default Options
cassette_dir: "cassettes"- Directory for storing cassette filesmode: :record- Record new interactions, replay existing onesmatch_requests_on: [:method, :uri, :query, :headers, :body]- Match on all criteria
Examples
# Minimal options (uses defaults)
opts = %{cassette_name: "my_api"}
ReqCassette.Plug.init(opts)
#=> %{
# cassette_name: "my_api",
# cassette_dir: "cassettes",
# mode: :record,
# match_requests_on: [:method, :uri, :query, :headers, :body]
# }
# Custom options override defaults
opts = %{
cassette_name: "my_api",
mode: :replay,
match_requests_on: [:method, :uri]
}
ReqCassette.Plug.init(opts)
#=> %{
# cassette_name: "my_api",
# cassette_dir: "cassettes",
# mode: :replay,
# match_requests_on: [:method, :uri]
# }