ReqCassette.Plug (ReqCassette v0.5.1)

View Source

A 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 to

With cassette_name (recommended):

plug: {ReqCassette.Plug, %{cassette_name: "github_user", cassette_dir: "test/cassettes"}}
# Creates: github_user.json
# ✅ Clear, readable - easy to manage and understand

The 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"). Creates github_api.json. If omitted, generates a cryptic MD5 hash filename based on matching options (:mode, :cassette_dir, and :cassette_name are 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 strings
  • body_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: true in 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

  1. Recording Flow (:record mode):

    • 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
  2. Replay Flow (:replay or :record with 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
  3. Bypass Flow (:bypass mode):

    • 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

Types

Options for configuring the cassette plug.

Functions

Handles an incoming HTTP request by either replaying from cassette or recording.

Initializes the plug with the given options.

Types

opts()

@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

call(conn, opts)

@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

  • conn - The Plug.Conn struct representing the incoming request
  • opts - The plug options (see opts/0)

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:

  1. Regex filters (:filter_sensitive_data) - Applied to URI, query string, and bodies
  2. Header filters (:filter_request_headers, :filter_response_headers) - Removes specified headers
  3. 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 removed

Errors

This function raises in the following cases:

  • Mode :replay with missing cassette
  • Mode :replay with no matching interaction
  • Mode :record when network request fails

The error messages include context to help debug the issue.

init(opts)

@spec init(opts() | map()) :: opts()

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 (see opts/0)

Returns

The merged options map with defaults applied.

Default Options

  • cassette_dir: "cassettes" - Directory for storing cassette files
  • mode: :record - Record new interactions, replay existing ones
  • match_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]
#   }