ReqCassette.Cassette (ReqCassette v0.5.1)

View Source

Handles 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 interactions

Benefits:

  • 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 structures
  • body - Text responses (HTML, XML, CSV) stored as strings
  • body_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=2 matches ?b=2&a=1
  • JSON body keys are order-independent: {"a":1,"b":2} matches {"b":2,"a":1}
  • Headers are case-insensitive: Accept matches accept

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 interactions

See Also

Summary

Types

A single HTTP request/response interaction

HTTP request details

HTTP response details

t()

A cassette file containing multiple interactions

Functions

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

interaction()

@type interaction() :: %{
  request: request(),
  response: response(),
  recorded_at: String.t()
}

A single HTTP request/response interaction

request()

@type request() :: %{
  method: String.t(),
  uri: String.t(),
  query_string: String.t(),
  headers: map(),
  body_type: String.t()
}

HTTP request details

response()

@type response() :: %{status: integer(), headers: map(), body_type: String.t()}

HTTP response details

t()

@type t() :: %{version: String.t(), interactions: [interaction()]}

A cassette file containing multiple interactions

Functions

add_interaction(cassette, filtered_request, response, opts)

@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 (from new/0 or load/1)
  • conn - The Plug.Conn struct with request details (method, URI, headers, etc.)
  • request_body - The raw request body as a binary string
  • response - The Req.Response struct from the HTTP call
  • opts - 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_json field as native Elixir data structures
  • Text bodies (HTML, XML, CSV) are stored in body field as strings
  • Binary bodies (images, PDFs) are base64-encoded in body_blob field

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)

add_interaction(cassette, conn, request_body, response, opts \\ %{})

diagnose_mismatch(cassette, conn, request_body, match_on, filter_opts \\ %{})

@spec diagnose_mismatch(map(), Plug.Conn.t(), binary(), [atom()], map()) :: [map()]

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 map
  • conn - The Plug.Conn struct representing the current request
  • request_body - The raw request body as a binary string
  • match_on - List of matchers to check
  • filter_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])

find_interaction(cassette, filtered_request, match_on)

@spec find_interaction(map(), map(), [atom()]) :: {:ok, map()} | :not_found

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 from load/1 or created with new/0)
  • conn - The Plug.Conn struct representing the current request
  • request_body - The raw request body as a binary string
  • match_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=2 matches ?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}

find_interaction_sequential(cassette, filtered_request, match_on, start_index)

@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 map
  • filtered_request - Pre-filtered request map
  • match_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

format_mismatch_diagnostics(diagnostics, match_on)

@spec format_mismatch_diagnostics([map()], [atom()]) :: String.t()

Formats diagnostic results into a human-readable string.

Parameters

  • diagnostics - List of diagnostic maps from diagnose_mismatch/5
  • match_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"

load(path)

@spec load(String.t()) :: {:ok, map()} | :not_found

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

new()

@spec new() :: map()

Creates a new empty cassette with version 1.0.

Examples

new()
# => %{version: "1.0", interactions: []}

sanitize_filename(name)

@spec sanitize_filename(String.t()) :: String.t()

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"

save(path, cassette)

@spec save(String.t(), map()) :: :ok

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 saved
  • cassette - The cassette map

Examples

save("/path/to/cassette.json", cassette)