README.md

# Filtr

Parameter validation library for Elixir with Phoenix integration.

Filtr provides a flexible, plugin-based system for validating and casting parameters in Phoenix applications. It offers seamless integration with Phoenix Controllers and LiveViews using an attr-style syntax similar to Phoenix Components.

## Features

- **Phoenix Integration**
- **Plugin System**
- **Nested Schemas**
- **Multiple Error Modes**

## Requirements

- Elixir ~> 1.13
- Phoenix >= 1.6.0 (optional, for Controller/LiveView integration)
- Phoenix LiveView >= 0.20.0 (optional, for LiveView integration)

## Installation

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

```elixir
def deps do
  [
    {:filtr, "~> 0.1.0"}
  ]
end
```

### Formatter

To ensure proper formatting of the `param` macro, add `:filtr` to your `.formatter.exs` configuration:

```elixir
[import_deps: [:filtr]]
```

## Quick Start

### Phoenix Controller

```elixir
defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller
  use Filtr.Controller, error_mode: :raise

  param :name, :string, required: true
  param :age, :integer, min: 18, max: 120
  param :email, :string, required: true, pattern: ~r/@/

  def create(conn, params) do
    # params.name is guaranteed to be a string
    # params.age is guaranteed to be an integer between 18 and 120
    # params.email is guaranteed to be a string containing "@"
    json(conn, %{message: "User #{params.name} created"})
  end

  param :q, :string, default: ""
  param :page, :integer, default: 1, min: 1
  param :category, :string, in: ["books", "movies", "music"], default: "books"

  def search(conn, params) do
    # params.q defaults to ""
    # params.page defaults to 1 and is >= 1
    # params.category is one of the allowed values
    json(conn, %{query: params.q, page: params.page})
  end
end
```

### Phoenix LiveView

```elixir
defmodule MyAppWeb.SearchLive do
  use MyAppWeb, :live_view
  use Filtr.LiveView, error_mode: :raise

  param :query, :string, required: true, min: 1
  param :limit, :integer, default: 10, min: 1, max: 100

  def mount(_params, _session, socket) do
    # socket.assigns.filtr contains validated params
    # socket.assigns.filtr.query - validated query string
    # socket.assigns.filtr.limit - validated limit (defaults to 10)
    {:ok, socket}
  end

  def handle_params(_params, _uri, socket) do
    # Params are automatically revalidated on navigation
    {:noreply, socket}
  end
end
```

### Standalone Usage

```elixir
schema = %{
  name: [type: :string, validators: [required: true, min: 2]],
  age: [type: :integer, validators: [min: 18, max: 120]],
  tags: [type: {:list, :string}, validators: [max: 5]]
}

params = %{
  "name" => "John Doe",
  "age" => "25",
  "tags" => ["elixir", "phoenix"]
}

result = Filtr.run(schema, params, error_mode: :raise)
# %{name: "John Doe", age: 25, tags: ["elixir", "phoenix"]}
```

## Error Modes

Filtr supports three error handling modes:

### Fallback Mode (Default)

Returns default values or nil on validation errors:

```elixir
schema = %{age: [type: :integer, validators: [default: 18]]}
params = %{"age" => "invalid"}

Filtr.run(schema, params, error_mode: :fallback)
# %{age: 18}
```

### Strict Mode

Returns error tuples for invalid values:

```elixir
schema = %{age: [type: :integer, validators: [min: 18]]}
params = %{"age" => "10"}

Filtr.run(schema, params, error_mode: :strict)
# %{age: {:error, ["must be at least 18"]}}
```

### Raise Mode

Raises exceptions on validation errors:

```elixir
schema = %{name: [type: :string, validators: [required: true]]}
params = %{}

Filtr.run(schema, params, error_mode: :raise)
# ** (RuntimeError) Invalid value for name: required
```

### Global Configuration

Set a default error mode in your config:

```elixir
# config/config.exs
config :filtr, error_mode: :strict
```

Or specify per-module:

```elixir
use Filtr.Controller, error_mode: :strict
use Filtr.LiveView, error_mode: :raise
```

## Advanced Features

### Nested Schemas

```elixir
schema = %{
  user: %{
    name: [type: :string, validators: [required: true]],
    email: [type: :string, validators: [required: true]]
  },
  settings: %{
    theme: [type: :string, validators: [in: ["light", "dark"]]],
    notifications: [type: :boolean]
  }
}

params = %{
  "user" => %{
    "name" => "John",
    "email" => "john@example.com"
  },
  "settings" => %{
    "theme" => "dark",
    "notifications" => "true"
  }
}

result = Filtr.run(schema, params)
# %{
#   user: %{name: "John", email: "john@example.com"},
#   settings: %{theme: "dark", notifications: true}
# }
```

### List of Nested Schemas

```elixir
schema = %{
  items: [
    type: {
      :list,
      %{
        name: [type: :string],
        quantity: [type: :integer, validators: [min: 1]]
      }
    }
  ]
}
```

### Custom Cast Functions

```elixir
upcase_cast = fn value, _opts -> {:ok, String.upcase(value)} end

schema = %{name: [type: upcase_cast]}
params = %{"name" => "john"}

Filtr.run(schema, params)
# %{name: "JOHN"}
```

### Custom Validators

```elixir
# 1-arity function
email_validator = fn value ->
  if String.contains?(value, "@"), do: true, else: {:error, "invalid email"}
end

# 2-arity function (receives value and type)
length_validator = fn value, _type ->
  if String.length(value) > 5, do: :ok, else: :error
end

# 3-arity function (receives value, type, and opts)
custom_validator = fn value, _type, opts ->
  max_length = Keyword.get(opts, :max_length, 100)
  if String.length(value) <= max_length, do: :ok, else: {:error, "too long"}
end

schema = %{
  email: [type: :string, validators: [custom: email_validator]],
  name: [type: :string, validators: [custom: length_validator]],
  bio: [type: :string, validators: [custom: custom_validator], max_length: 500]
}
```

### Default Values

```elixir
param :page, :integer, default: 1
param :limit, :integer, default: 10
param :sort, :string, default: "created_at"

# Dynamic defaults with functions
param :timestamp, :integer, default: fn -> System.system_time(:second) end
param :uuid, :string, default: fn -> Ecto.UUID.generate() end
```

### Required Fields

```elixir
param :name, :string, required: true
param :email, :string, required: true, pattern: ~r/@/

# With default, required is basically skipped
param :role, :string, required: true, default: "user"
```

### Error Mode Per Field

You can override the error mode for individual fields, allowing fine-grained control over error handling:

```elixir
defmodule MyAppWeb.UserController do
  use Filtr.Controller, error_mode: :strict

  # Override for specific param - won't raise, will use fallback
  param :optional_field, :string, [default: ""], error_mode: :fallback
  param :required_field, :string, required: true  # Uses :strict from module

  def create(conn, params) do
    # params.optional_field will be "" if invalid or missing
    # params.required_field will be {:error, [...]} if invalid
  end
end
```

**Use cases:**
- **Critical fields** - Use `:raise` or `:strict` for fields that must be valid
- **Optional fields** - Use `:fallback` with defaults for non-critical data
- **Mixed validation** - Combine modes to handle different requirements in the same schema

### Type Passthrough

For parameters that don't need validation:

```elixir
schema = %{
  metadata: [type: nil],           # Passes through any value
  raw_data: [type: :__none__]      # Passes through any value
}
```

## Plugin System

Extend Filtr with custom types and validators:

```elixir
defmodule MyApp.MoneyPlugin do
  use Filtr.Plugin

  @impl true
  def types, do: [:money]

  @impl true
  def cast(value, :money, _opts) when is_binary(value) do
    # Remove currency symbols and parse
    cleaned = String.replace(value, ~r/[$,]/, "")

    case Float.parse(cleaned) do
      {amount, _} -> {:ok, trunc(amount * 100)} # Store as cents
      :error -> {:error, "invalid money format"}
    end
  end

  def cast(value, :money, _opts) when is_integer(value), do: {:ok, value}

  @impl true
  def validate(value, :money, {:min, min}, _opts) do
    if value >= min, do: :ok, else: {:error, "amount too small"}
  end

  def validate(value, :money, {:max, max}, _opts) do
    if value <= max, do: :ok, else: {:error, "amount too large"}
  end
end
```

Register the plugin in your config:

```elixir
# config/config.exs
config :filtr, plugins: [MyApp.MoneyPlugin]
```

Use your custom type:

```elixir
param :price, :money, min_amount: 100, max_amount: 100_000

# Accepts: "$12.99", "1,234.56", 1299 (as cents)
```

#### `types/0`

The `types/0` callback declares which types your plugin handles. This is a required callback that returns a list of atoms:

```elixir
@impl true
def types, do: [:money, :currency, :price]
```

Filtr uses this information to build a type-to-plugin mapping at runtime (cached in `:persistent_term` for performance). When processing a parameter, Filtr looks up which plugins support that type and tries them in order. The mapping is built on first use and cached for subsequent requests, giving near compile-time performance.

#### `cast/3`

The `cast/3` callback is an optional callback that converts raw parameter values into the desired type. It receives the value to cast, the type atom, and options:

```elixir
@impl true
def cast(value, :money, _opts) when is_binary(value) do
  cleaned = String.replace(value, ~r/[$,]/, "")

  case Float.parse(cleaned) do
    {amount, _} -> {:ok, trunc(amount * 100)}
    :error -> {:error, "invalid money format"}
  end
end

def cast(value, :money, _opts) when is_integer(value) do
  {:ok, value}
end
```

**Return values:**

- `{:ok, casted_value}` - Successfully casted the value
- `{:error, error_message}` - Single error message
- `{:error, [error1, error2, ...]}` - Multiple error messages

If your plugin doesn't implement `cast/3` for a specific type or the function clause doesn't match, Filtr will try the next plugin in the chain.

#### `validate/4`

The `validate/4` callback is an optional callback that validates a casted value against specific validation rules. It receives the value, type, validator tuple, and options:

```elixir
@impl true
def validate(value, :money, {:min, min}, _opts) do
  if value >= min, do: :ok, else: {:error, "amount too small"}
end

def validate(value, :money, {:max, max}, _opts) do
  if value <= max, do: :ok, else: {:error, "amount too large"}
end

def validate(value, :money, {:currency, currency}, _opts) do
  # Custom currency validation
  if valid_currency?(value, currency) do
    :ok
  else
    {:error, "invalid currency"}
  end
end
```

**Return values:**

- `:ok` or `true` or `{:ok, any()}` - Validation passed
- `:error` or `false` - Validation failed (returns generic "invalid value" error)
- `{:error, error_message}` - Validation failed with specific message

The validator parameter is a tuple like `{:min, 100}` or `{:in, ["USD", "EUR"]}`. Your plugin only needs to implement validators it supports - if a validator isn't recognized, Filtr will try the next plugin in the chain.

### Plugin Priority

Plugins are processed in reverse order, with later plugins taking precedence:

```elixir
config :filtr, plugins: [PluginA, PluginB]

# PluginB is tried first, then PluginA, then DefaultPlugin
```

This allows you to override built-in types:

```elixir
defmodule MyApp.CustomStringPlugin do
  use Filtr.Plugin

  @impl true
  def types, do: [:string]

  @impl true
  def cast(value, :string, _opts) do
    # Custom string handling
    {:ok, String.trim(value)}
  end

  @impl true
  def validate(value, :string, validator, opts) do
    # Delegate to default string validators
    Filtr.DefaultPlugin.Validate.validate(value, :string, validator, opts)
  end
end
```

#### Cast and Validate Precedence

When multiple plugins support the same type, Filtr tries them in reverse order (later plugins first). If a plugin doesn't implement `cast/3` or `validate/4` for a specific case, or if the function clause doesn't match, Filtr automatically falls through to the next plugin in the chain.

**Example:**

```elixir
# config/config.exs
config :filtr, plugins: [PluginA, PluginB, PluginC]

# Filtr tries plugins in this order:
# 1. PluginC
# 2. PluginB
# 3. PluginA
# 4. DefaultPlugin (always last)
```

**How fallthrough works:**

```elixir
defmodule MyPlugin do
  use Filtr.Plugin

  @impl true
  def types, do: [:string]

  @impl true
  def cast(value, :string, _opts) when is_binary(value) do
    {:ok, String.trim(value)}
  end

  @impl true
  # Only implement :min validator, others fall through
  def validate(value, :string, {:min, min}, _opts) do
    if String.length(value) >= min, do: :ok, else: {:error, "too short"}
  end
end

# When using :max validator, it falls through to DefaultPlugin
param :name, :string, min: 2, max: 50
# :min uses MyPlugin, :max uses DefaultPlugin
```

This allows you to:

- Override specific validators while keeping others
- Add new validators to existing types
- Completely replace type handling when needed

### Default Plugin

Filtr includes a built-in `DefaultPlugin` that provides support for common data types. This plugin is always included and runs last in the plugin chain, so custom plugins can override its behavior.

#### Supported types

- `:string` - Text values
- `:integer` - Whole numbers (parses from strings)
- `:float` - Decimal numbers (parses from strings)
- `:boolean` - True/false values (accepts "true", "false", "1", "0", "yes", "no")
- `:date` - Date values (accepts Date structs, NaiveDateTime structs, DateTime structs, or ISO8601 strings)
- `:datetime` - DateTime values (accepts DateTime structs, NaiveDateTime structs, or ISO8601 strings)
- `:list` - List values (accepts arrays or comma-separated strings)

#### Available validators

**String validators:**

- `min: n` - Minimum length
- `max: n` - Maximum length
- `length: n` - Exact length
- `pattern: regex` - Must match regex pattern
- `starts_with: prefix` - Must start with prefix
- `ends_with: suffix` - Must end with suffix
- `contains: substring` - Must contain substring
- `alphanumeric: true` - Only letters and numbers
- `in: list` - Must be one of the listed values

**Integer/Float validators:**

- `min: n` - Minimum value
- `max: n` - Maximum value
- `positive: true` - Must be > 0
- `negative: true` - Must be < 0
- `in: list` - Must be one of the listed values

**Date/DateTime validators:**

- `min: date` - Must be after or equal to
- `max: date` - Must be before or equal to

**List validators:**

- `min: n` - Minimum number of items
- `max: n` - Maximum number of items
- `length: n` - Exact number of items
- `unique: true` - All items must be unique
- `non_empty: true` - List cannot be empty
- `in: list` - All items must be from the allowed list

You can always delegate to the DefaultPlugin from your custom plugins:

```elixir
@impl true
def validate(value, :upcase, validator, opts) do
  # Use default string validators
  Filtr.DefaultPlugin.Validate.validate(value, :string, validator, opts)
end

@impl true
def cast(value, :upcase, opts) do
  case Filtr.DefaultPlugin.Cast.cast(value, :string, opts) do
    {:ok, value} -> {:ok, String.upcase(value)}
    error -> error
  end
end
```

## TODO

- [ ] Docs in Code
- [ ] More Tests
- [ ] Benchmarks
- [ ] CI/CD
- [ ] Improve README
- [ ] Extarct errors function for strict mode
- [ ] Custom error modes

Distributed under the MIT License.