View Source Peri - Schema Validation Library for Elixir

General Description

Peri is a schema validation library for Elixir, inspired by Clojure's Plumatic Schema. It allows developers to define schemas for validating various data structures, supporting nested schemas, optional fields, and custom validation types. Peri aims to provide an intuitive and flexible way to ensure data integrity in Elixir applications.

Features

  • Simple and intuitive syntax for defining schemas.
  • Validation of data structures against schemas.
  • Support for nested, composable, and recursive schemas.
  • Optional and required fields.
  • Comprehensive error handling with detailed messages.
  • Flexible validation types, including custom and conditional validations.

Installation

To use Peri in your project, add it to your dependencies in mix.exs:

def deps do
  [
    {:peri, "~> 0.2.3"}
  ]
end

Then, run mix deps.get to fetch the dependencies.

Usage

Available Types

Peri supports a variety of types to ensure your data is validated accurately. Below is a table summarizing the available types and their descriptions:

TypeDescription
:stringValidates that the field is a binary (string).
:integerValidates that the field is an integer.
:floatValidates that the field is a float.
:booleanValidates that the field is a boolean.
:atomValidates that the field is an atom.
:anyAllows any datatype.
{:required, type}Marks the field as required and validates it according to the specified type.
:mapValidates that the field is a map without checking nested schema.
{:either, {type_1, type_2}}Validates that the field is either of type_1 or type_2.
{:oneof, types}Validates that the field is at least one of the provided types.
{:list, type}Validates that the field is a list where elements belong to a determined type.
{:tuple, types}Validates that the field is a tuple with a determined size, and each element has its own type validation.
{:custom, anonymous_fun_arity_1}Validates that the field passes the callback. The function needs to return either :ok or {:error, template, info} where template is an EEx string and info is a keyword list or map.
{:custom, {MyModule, :my_validation}}Same as {custom, anonymous_fun_arity_1} but you pass a remote module and a function name as an atom.
{:custom, {MyModule, :my_validation, [arg1, arg2]}}Same as {:custom, {MyModule, :my_validation}} but you can pass extra arguments to your validation function. Note that the value of the field is always the first argument.
{:cond, condition, true_type, else_type}Conditionally validates a field based on the result of a condition function.
{:dependent, field, condition, type}Validates a field based on the value of another field.
{type, {:default, default}}Validates a field exists based on type, if not, return the default value
{type, {:transform, mapper}}Validates a field have valid type, if yes, return the return of the mapper/1 function passing the value

These types provide flexibility and control over how data is validated, enabling robust and precise schema definitions.

Defining and Validating Schemas

Schemas can be defined using the defschema macro. By default, all fields in the schema are optional unless specified as {:required, type}.

Example

defmodule MySchemas do
  import Peri

  defschema :user, %{
    name: :string,
    age: :integer,
    email: {:required, :string},
    address: %{
      street: :string,
      city: :string
    },
    tags: {:list, :string},
    role: {:enum, [:admin, :user, :guest]},
    geolocation: {:tuple, [:float, :float]},
    rating: {:custom, &validate_rating/1}
  }

  defp validate_rating(n) when n < 10, do: :ok
  defp validate_rating(_), do: {:error, "invalid rating", []}
end

user_data = %{
  name: "John",
  age: 30,
  email: "john@example.com",
  address: %{street: "123 Main St", city: "Somewhere"},
  tags: ["science", "funky"],
  role: :admin,
  geolocation: {12.2, 34.2},
  rating: 9
}

case MySchemas.user(user_data) do
  {:ok, valid_data} -> IO.puts("Data is valid!")
  {:error, errors} -> IO.inspect(errors, label: "Validation errors")
end

defschema Macro General Explanation

The defschema macro allows you to define a schema with a given name and schema definition. This macro injects functions that can validate data against the defined schema.

Defining Schemas Without Macro

You can also define schemas without using the defschema macro by directly passing the schema definition to the Peri.validate/2 function.

defmodule MySchemas do
  @raw_user_schema %{age: :integer, name: :string}

  def create_user(data) do
    with {:ok, data} <- Peri.validate(@raw_user_schema, data) do
      # rest of the function ...
    end
  end
end

Dynamic Schemas

Dynamic schemas can be generated based on runtime conditions.

defmodule MySchemas do
  import Peri

  def generate_schema(is_admin) do
    if is_admin do
      %{
        role: {:required, :string},
        permissions: {:list, :string}
      }
    else
      %{
        role: {:required, :string}
      }
    end
  end

  def validate_user(data, is_admin) do
    schema = generate_schema(is_admin)
    Peri.validate(schema, data)
  end
end

data = %{role: "admin", permissions: ["read", "write"]}
case MySchemas.validate_user(data, true) do
  {:ok, valid_data} -> IO.puts("Data is valid!")
  {:error, errors} -> IO.inspect(errors, label: "Validation errors")
end

Nested and Composable Schemas

Peri supports nested schemas, allowing for validation of complex data structures.

defmodule MySchemas do
  import Peri

  defschema :address, %{
    street: :string,
    city: :string
  }

  defschema :user, %{
    name: :string,
    email: {:required, :string},
    address: {:custom, &address/1}
  }
end

data = %{name: "John", email: "john@example.com", address: %{street: "123 Main St", city: "Somewhere"}}
case MySchemas.user(data) do
  {:ok, valid_data} -> IO.puts("Data is valid!")
  {:error, errors} -> IO.inspect(errors, label: "Validation errors")
end

Recursive Schemas

Recursive schemas allow you to define schemas that reference themselves, enabling the validation of nested and hierarchical data structures.

defmodule MySchemas do
  import Peri

  defschema :category, %{
    id: :integer,
    name: :string,
    subcategories: {:list, {:custom, &category/1}}
  }

  category_data = %{
    id: 1,
    name: "Electronics",
    subcategories: [
      %{id: 2, name: "Computers", subcategories: []},
      %{id: 3, name: "Phones", subcategories: [%{id: 4, name: "Smartphones", subcategories: []}]}
    ]
  }

  case MySchemas.category(category_data) do
    {:ok, valid_data} -> IO.puts("Category is valid!")
    {:error, errors} -> IO.inspect(errors, label: "Validation errors")
  end
end

Schemas on Raw Data Structures

Peri allows you to define schemas for various data structures, including lists, tuples, keyword lists, and primitive types.

Lists

defmodule MySchemas do
  import Peri

  defschema :string_list, {:list, :string}

  data = ["hello", "world"]
  case MySchemas.string_list(data) do
    {:ok, valid_data} -> IO.puts("Data is valid!")
    {:error, errors} -> IO.inspect(errors, label: "Validation errors")
  end
end

Tuples

defmodule MySchemas do
  import Peri

  defschema :coordinates, {:tuple, [:float, :float]}

  data = {12.34, 56.78}
  case MySchemas.coordinates(data) do
    {:ok, valid_data} -> IO.puts("Data is valid!")
    {:error, errors} -> IO.inspect(errors, label: "Validation errors")
  end
end

Keyword Lists

defmodule MySchemas do
  import Peri

  defschema :settings, [{:key, :string}, {:value, :any}]

  data = [key: "theme", value: "dark"]
  case MySchemas.settings(data) do
    {:ok, valid_data} -> IO.puts("Data is valid!")
    {:error, errors} -> IO.inspect(errors, label: "Validation errors")
  end
end

Error Handling

Peri provides detailed error messages that can be easily inspected and transformed. Each error includes a message, content, path, key, and nested errors for detailed information about nested validation errors.

defmodule MySchemas do
  import Peri

  defschema :user, %{
    name: :string,
    age: {:required, :integer}
  }

  data = %{name: "Jane"}
  case MySchemas.user(data) do
    {:ok, valid_data} -> IO.puts("Data is valid!")
    {:error, errors} -> IO.inspect(errors, label: "Validation errors")
  end
end

InvalidSchema Exception

Peri raises an InvalidSchema exception when an invalid schema is encountered. This exception contains a list of Peri.Error structs, providing a readable message overview of the validation errors.

Comparison with Ecto Schemaless Changesets and Embedded Schemas

Peri

  • Purpose: Designed specifically for schema validation. Focuses on validating raw maps against defined schemas.
  • Flexibility: Allows easy validation of nested structures, optional fields, and dynamic schemas.
  • Simplicity: The syntax for defining schemas is straightforward and intuitive.
  • Use Case: Ideal for validating data structures in contexts where you don't need the full power of a database ORM.

Ecto Schemaless Changesets

  • Purpose: Provides mechanisms for validating and casting data without persisting it to a database.
  • Complexity: More complex due to its integration with Ecto and the need to handle changesets.
  • Schema Definitions: Uses Ecto changesets and embedded schemas, which are typically tied to database schemas.
  • Use Case: Ideal for applications that require validation and casting of data, even when it’s not being persisted to a database.

Summary

  • Use Peri if you need a lightweight, flexible library for validating raw maps and nested data structures without the overhead of database interactions.
  • Use Ecto Schemaless Changesets if you need to validate and cast data in an Ecto-based application, leveraging the full power of Ecto’s changeset functionality.