README.md

# Reqord

VCR-style HTTP recording and replay for Elixir's [Req](https://hexdocs.pm/req) library, with zero application code changes required.

Reqord integrates seamlessly with `Req.Test` to automatically record HTTP interactions to cassette files and replay them in your tests. Perfect for testing applications that interact with external APIs.

## Features

- **Zero app code changes** - Works entirely through `Req.Test` integration
- **Three modes** - Replay (default), Record, and Auto (record on miss)
- **Smart matching** - Requests matched by method, normalized URL, and body hash
- **Automatic redaction** - Auth headers and query params are automatically redacted
- **Concurrent tests** - Full support for async ExUnit tests with private ownership
- **Spawned processes** - Easy allowance API for Tasks and spawned processes

## Installation

Add `reqord` to your list of dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:req, "~> 0.5"},
    {:reqord, "~> 0.1.0"},
    {:jason, "~> 1.4"}  # Required for default JSON adapter
  ]
end
```

**Note**: `jason` is an optional dependency. If you want to use a different JSON library, configure it as shown in the [Advanced Configuration](#custom-json-library) section.

## Setup

### 1. Configure Req to use Req.Test in your test environment

In `config/test.exs`:

```elixir
# If you're building a library that uses Req
config :my_app,
  req_options: [plug: {Req.Test, MyApp.ReqStub}]

# Then in your application code, use these options:
Req.new(Application.get_env(:my_app, :req_options, []))
```

Or if you're using Req directly in tests:

```elixir
# In your test setup
Req.new(plug: {Req.Test, MyApp.ReqStub})
```

### 2. Use Reqord.Case in your tests

```elixir
defmodule MyApp.APITest do
  use Reqord.Case

  # Your Req.Test stub name (must match the one in config)
  defp default_stub_name, do: MyApp.ReqStub

  test "fetches user data" do
    # This request will be recorded/replayed automatically
    {:ok, response} = Req.get(client(), url: "/users/123")
    assert response.status == 200
    assert response.body["name"] == "John Doe"
  end

  defp client do
    Req.new(Application.get_env(:my_app, :req_options, []))
  end
end
```

## Usage

### Record Modes

Reqord supports Ruby VCR-style record modes. Control via environment variable, config, or test tags.

#### Available Modes

- **`:once`** (default) - Strict replay. Use existing cassette, raise on new requests
- **`:new_episodes`** - Append mode. Replay existing, record new requests  
- **`:all`** - Always re-record. Ignores cassette, hits live network
- **`:none`** - Never record. Must have complete cassette

#### Environment Variable

```bash
# Once mode (default) - strict replay
REQORD=once mix test

# New episodes mode - append new recordings
REQORD=new_episodes mix test

# All mode - always re-record everything
REQORD=all API_TOKEN=xxx mix test

# None mode - never record, never hit network
REQORD=none mix test
```

#### Application Config

```elixir
# config/test.exs
config :reqord, default_mode: :once
```

#### Per-Test Override

```elixir
@tag vcr_mode: :new_episodes
test "allows new recordings" do
  # This test will record new requests
end
```

### Cassette Naming

Cassettes are automatically named based on your test module and test name:

```elixir
defmodule MyApp.UserAPITest do
  use Reqord.Case

  # Creates cassette: test/support/cassettes/UserAPI/fetches_user_list.jsonl
  test "fetches user list" do
    # ...
  end

  # Override with custom name
  @tag vcr: "custom/my_cassette"
  test "with custom cassette" do
    # Creates cassette: test/support/cassettes/custom/my_cassette.jsonl
  end
end
```

### Custom Stub Names

Override the stub name per test if needed:

```elixir
@tag req_stub_name: MyApp.OtherStub
test "with different stub" do
  # Uses MyApp.OtherStub instead of default
end
```

### Working with Spawned Processes

If your test spawns processes that make HTTP requests, allow them access to the stub:

```elixir
test "with spawned task" do
  task = Task.async(fn ->
    Req.get(client(), url: "/data")
  end)

  # Allow the task's process to use the stub
  Reqord.allow(MyApp.ReqStub, self(), task.pid)

  {:ok, response} = Task.await(task)
  assert response.status == 200
end
```

## How It Works

### Request Matching

Reqord matches requests using a deterministic key:

```
METHOD NORMALIZED_URL BODY_HASH
```

- **Method**: HTTP method (GET, POST, etc.)
- **Normalized URL**: 
  - Query parameters sorted lexicographically
  - Auth params (`token`, `apikey`) removed
- **Body Hash**: 
  - SHA-256 hash for POST/PUT/PATCH
  - `-` for other methods

This means:
- Query parameter order doesn't affect matching
- Auth parameters don't affect matching
- Different request bodies produce different keys

### Cassette Format

Cassettes are stored as JSONL (JSON Lines) files in `test/support/cassettes/`:

```json
{"key":"GET https://api.example.com/users -","req":{...},"resp":{...}}
{"key":"POST https://api.example.com/users abc123...","req":{...},"resp":{...}}
```

Each line is a JSON object containing:
- `key` - The match key
- `req` - Request details (method, URL, headers)
- `resp` - Response (status, headers, base64-encoded body)

### Redaction

**🔒 Reqord ensures secrets never get committed to git** by automatically redacting sensitive data from cassettes.

#### Built-in Redaction

**Auth headers** (→ `<REDACTED>`):
- `authorization`, `x-api-key`, `x-auth-token`, `cookie`, etc.

**Auth query parameters** (→ `<REDACTED>`):
- `token`, `api_key`, `access_token`, `refresh_token`, `jwt`, etc.

**Response body patterns**:
- Bearer tokens → `Bearer <REDACTED>`
- Long alphanumeric strings (32+ chars) → `<REDACTED>`
- GitHub tokens (`ghp_*`) → `<REDACTED>`
- JSON keys containing "token", "key", "secret", "password" → `<REDACTED>`

**Volatile headers** (removed entirely):
- `date`, `server`, `set-cookie`, `request-id`, etc.

#### Custom Redaction (VCR-style)

For app-specific secrets, configure custom filters:

```elixir
# config/test.exs
config :reqord, :filters, [
  {"<API_KEY>", fn -> System.get_env("API_KEY") end},
  {"<SHOPIFY_TOKEN>", fn -> Application.get_env(:my_app, :shopify_token) end}
]
```

These filters apply to headers, query parameters, and response bodies.

## Example Workflow

```bash
# 1. Write your test using Reqord.Case
# 2. Record cassettes (hits live API)
REQORD=record API_TOKEN=xxx mix test --include vcr

# 3. Commit cassettes to git
git add test/support/cassettes/
git commit -m "Add API cassettes"

# 4. Run tests in replay mode (no network calls)
mix test

# 5. Update cassettes when API changes
REQORD=record API_TOKEN=xxx mix test --include vcr
```

## Integration with Req.Test

Reqord works alongside your existing `Req.Test` stubs and expectations:

```elixir
test "with mixed stubs" do
  # Add a high-priority stub for specific URL
  Req.Test.stub(MyApp.ReqStub, fn
    %{request_path: "/special"} = conn ->
      Req.Test.json(conn, %{special: true})
  end)

  # This request hits your stub
  {:ok, resp1} = Req.get(client(), url: "/special")
  assert resp1.body["special"] == true

  # This request falls through to VCR
  {:ok, resp2} = Req.get(client(), url: "/other")
  # Replayed from cassette or recorded
end
```

## Advanced Configuration

### Configurable Settings

Reqord provides several configuration options to customize its behavior:

```elixir
# config/config.exs
config :reqord,
  # Cassette storage directory
  cassette_dir: "test/support/cassettes",

  # JSON library for encoding/decoding cassettes
  json_library: Reqord.JSON.Jason,

  # Default record mode
  default_mode: :once,

  # Auth parameters to redact from URLs
  auth_params: ~w[token apikey api_key access_token refresh_token jwt bearer password secret],

  # Auth headers to redact
  auth_headers: ~w[authorization auth x-api-key x-auth-token x-access-token cookie],

  # Volatile headers to remove from responses
  volatile_headers: ~w[date server set-cookie request-id x-request-id x-amzn-trace-id],

  # Custom redaction filters
  filters: [
    {"<API_KEY>", fn -> System.get_env("API_KEY") end},
    {"<SHOPIFY_TOKEN>", fn -> Application.get_env(:my_app, :shopify_token) end}
  ]
```

### Custom Cassette Directory

Store cassettes in a different location:

```elixir
# config/test.exs
config :reqord, cassette_dir: "test/vcr_cassettes"
```

### Custom Redaction Lists

Add your own auth parameters and headers to redact:

```elixir
# config/config.exs
config :reqord,
  auth_params: ~w[token apikey api_key my_custom_token],
  auth_headers: ~w[authorization x-api-key x-my-custom-auth],
  volatile_headers: ~w[date server x-trace-id x-my-volatile-header]
```

### Custom JSON Library

By default, Reqord uses Jason for JSON encoding/decoding. You can configure a different JSON library to:

- Use your existing JSON library for consistency across your application
- Take advantage of performance characteristics of different JSON libraries
- Avoid adding Jason as a dependency if you already use another JSON library

```elixir
# config/config.exs
config :reqord, :json_library, MyApp.JSONAdapter
```

Your adapter must implement the `Reqord.JSON` behavior:

```elixir
defmodule MyApp.JSONAdapter do
  @behaviour Reqord.JSON

  @impl Reqord.JSON
  def encode!(data), do: MyJSON.encode!(data)

  @impl Reqord.JSON
  def decode(binary), do: MyJSON.decode(binary)

  @impl Reqord.JSON
  def decode!(binary), do: MyJSON.decode!(binary)
end
```

**Popular JSON libraries you can adapt:**
- `Poison` - Pure Elixir JSON library
- `JSX` - Erlang JSON library
- `jiffy` - Fast NIF-based JSON library

### Custom Default Stub Name

```elixir
defmodule MyApp.APITest do
  use Reqord.Case

  # Override for all tests in this module
  defp default_stub_name, do: MyApp.CustomStub
end
```

### Programmatic Installation

For advanced use cases, you can install VCR manually:

```elixir
setup do
  Reqord.install!(
    name: MyApp.ReqStub,
    cassette: "my_test",
    mode: :replay
  )

  :ok
end
```

## CLI Commands

Reqord provides several Mix tasks to help manage your cassettes:

### `mix reqord.show`

Display cassette contents in a readable format:

```bash
# Show all entries in a cassette
mix reqord.show MyTest/my_test.jsonl

# Filter by URL pattern
mix reqord.show MyTest/my_test.jsonl --grep "/users"

# Filter by HTTP method
mix reqord.show MyTest/my_test.jsonl --method POST

# Show raw JSON
mix reqord.show MyTest/my_test.jsonl --raw

# Decode and pretty-print JSON response bodies
mix reqord.show MyTest/my_test.jsonl --decode-body
```

### `mix reqord.audit`

Audit cassettes for potential issues:

```bash
# Run all audits
mix reqord.audit

# Check for potential secrets only
mix reqord.audit --secrets-only

# Find stale cassettes (older than 90 days)
mix reqord.audit --stale-days 90
```

The audit task reports:
- **Secrets**: Potential sensitive data that should be redacted (tokens, API keys, etc.)
- **Stale cassettes**: Files older than specified days
- **Unused cassettes**: Entries not hit during test runs (requires coverage data)

### `mix reqord.prune`

Clean up cassette files:

```bash
# Preview what would be removed (dry run)
mix reqord.prune --dry-run

# Remove empty cassettes and duplicates
mix reqord.prune

# Remove cassettes older than 180 days
mix reqord.prune --stale-days 180

# Remove only duplicate entries
mix reqord.prune --duplicates-only

# Remove only empty files
mix reqord.prune --empty-only

# Skip confirmation
mix reqord.prune --force
```

### `mix reqord.rename`

Rename or move cassette files:

```bash
# Rename a single cassette
mix reqord.rename old_name.jsonl new_name.jsonl

# Move all cassettes from one module to another
mix reqord.rename --from "OldModule/" --to "NewModule/"

# Preview changes
mix reqord.rename --from "OldModule/" --to "NewModule/" --dry-run

# Migrate cassettes to latest schema (for future schema changes)
mix reqord.rename --migrate
```

## Example API for Testing

This repository includes a test API server (`test_api/`) for demonstrating Reqord's functionality. It's a simple REST API with authentication that's used in the example tests.

### Quick Start

Use the provided script to automatically record example cassettes:

```bash
./scripts/record_cassettes.sh
```

This will:
1. Start the test API server
2. Record all example test cassettes
3. Stop the server

### Running Example Tests

```bash
# Run in replay mode (uses pre-recorded cassettes, no network)
mix test test/example_api_test.exs

# Re-record cassettes
REQORD=all mix test test/example_api_test.exs
```

See `test_api/README.md` for more details on the test API.

## Troubleshooting

### "No cassette entry found" error

This means you're in `:once` mode but the cassette doesn't have a matching entry.

**Solution**: Record the cassette first:

```bash
REQORD=all mix test
```

Or use new_episodes mode to record on misses:

```bash
REQORD=new_episodes mix test
```

### Tests fail with "No Req.Test stub found"

Make sure you've configured `Req.Test` in your test config and are using the correct stub name.

### Spawned processes can't make requests

Use `Reqord.allow/3` to grant access:

```elixir
Reqord.allow(MyApp.ReqStub, self(), spawned_pid)
```

## Limitations

- Response bodies are base64-encoded, not human-readable in cassettes
- Request matching is based on method + URI by default (configurable via `match_on`)

## Contributing

Contributions welcome! Please open an issue or PR on GitHub.

## License

Apache 2.0 - see LICENSE file for details.