zoedsoupe / peri

Elixir library for declarative data description and validation
MIT License
84 stars 2 forks source link
elixir schema-validator

Peri

Peri is a schema validation library for Elixir, inspired by Clojure's Plumatic Schema. It provides a powerful and flexible way to define and validate schemas for your data, ensuring data integrity and consistency throughout your application. Peri supports a variety of types and validation rules, and it can generate sample data based on your schemas.

Features

Installation

Add this line to your mix.exs:

defp deps do
  [
    {:peri, "~> 0.2"}
  ]
end

Available Types

Defining Schemas

Using the Macro

You can define schemas using the defschema macro, which provides a concise syntax for defining and validating schemas.

defmodule MySchemas do
  import Peri

  defschema :user, %{
    name: :string,
    age: {:integer, {:transform, & &1 * 2}},
    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

Without the Macro

You can also define schemas directly without using the macro:

schema = %{
  name: :string,
  age: {:integer, {:transform, & &1 * 2}},
  email: {:required, :string}
}

Peri.validate(schema, %{name: "John", age: 30, email: "john@example.com"})

Composable and Reusable Schemas

Schemas can be composed and reused to build complex data structures.

defmodule MySchemas do
  import Peri

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

  defschema :user, %{
    name: :string,
    age: :integer,
    email: {:required, :string},
    address: get_schema(:address)
  }
end

Conditional Schemas

You can define conditional types for a schema based on a callback condition, let's see an example:

defmodule CondSchema do
  import Peri

  defschema(:details, %{
    email: {:required, :string},
    country: {:required, :string}
  })

  defschema(:info, %{
    name: {:required, :string},
    provide_details: {:required, :boolean},
    details: {:cond, & &1.provide_details, {:required, get_schema(:details)}, nil}
  })
end

In this example we can read the info.details field schema definition as: "if the provide_details field is true then the info.details field should be parsed as the details schema, else, it should be parsed as nil".

Notice that the condition callback should return boolean

Dependent Schemas

You can parse fields that depend on onther fields, let's check some examples

Single field dependency

defmodule UserSchemas do
  import Peri

  defschema :user_registration, %{
    username: {:required, :string},
    password: {:required, :string},
    password_confirmation: {:dependent, :password, &validate_confirmation/2, :string}
  }

  # if confirmation has the same value of password, the validation is ok
  defp validate_confirmation(%{password: password}, password), do: :ok

  defp validate_confirmation(_confirmation, _password) do
    {:error, "confirmation should be equal to password", []}
  end
end

In this example we say that "the user_registration.password_confirmation field should be parsed as string only if it passes the validate_confirmation/2 function, which in this case asserts that the user_registration.password field should be equal to the confirmation one."

The callback passed to this type definition should be a 2 arity function that will receive the current nest level data as first argument and the value of the current field as the second argument and it should return only :ok or {:error, template, context}.

Multiple fields dependencies and custom parsing

A more complex dependent type schema definition would be:

defmodule TypeDependentSchema do
  import Peri

  defschema(:email_details, %{email: {:required, :string}})

  defschema(:country_details, %{country: {:required, :string}})

  defschema(:details, Map.merge(get_schema(:email_details), get_schema(:country_details)))

  defschema(:info, %{
    name: {:required, :string},
    provide_email: {:required, :boolean},
    provide_country: {:required, :boolean},
    details: {:dependent, &verify_details/1}
  })

  defp verify_details(%{data: data}) do
    %{provide_email: pe, provide_country: pc} = data

    provide = {pe, pc}

    case provide do
      {true, true} -> {:ok, {:required, get_schema(:details)}}
      {true, false} -> {:ok, {:required, get_schema(:email_details)}}
      {false, true} -> {:ok, {:required, get_schema(:country_details)}}
      {false, false} -> {:ok, nil}
    end
  end
end

In this example we have different schemas parsing rules based on the structure and values of the given data. Basically this type deifinition could be read as:

Notice that this kind of dependent type definition should return {:ok, type} whereas type is a valid Peri schema, or {:error, template, context}.

Custom Validation Functions

Implement custom validation functions to handle specific validation logic.

The spec of the custom validation function is:

@spec validation(term) :: :ok | {:error, template :: String.t(), context :: map | keyword}

Where template is a template string with the notation of %{value} where value is the name of the variable to be injected on the template. And context is a map or keyword list where the key is the name of the variable that will be injected into the template and the value is the value of this injected variable. Let's see an example:

defmodule MySchemas do
  import Peri

  defschema :user, %{
    name: :string,
    age: {:custom, &validate_age/1}
  }

  defp validate_age(age) when age >= 0 and age <= 120, do: :ok
  defp validate_age(age), do: {:error, "invalid age, received: %{age}", [age: age]}
end

Error Handling with Peri.Error

Peri provides detailed error messages to help identify validation issues. Errors include path information to pinpoint the exact location of the error in the data structure.

case Peri.validate(schema, data) do
  {:ok, valid_data} -> IO.puts("Data is valid!")
  {:error, errors} -> IO.inspect(errors, label: "Validation errors")
end

Data Generation

Peri can generate sample data based on your schemas using StreamData.

For this feature to work, ensures that you application depends on stream_data.

schema = %{
  name: :string,
  age: {:integer, {:gte, 18}},
  active: :boolean
}

sample_data = Peri.generate(schema)
Enum.take(sample_data, 10) # Generates 10 samples of the schema

Perfect for Raw Data Structures

Peri excels in validating raw data structures, such as tuples, strings, lists, and integers, with extensive validation options. This makes it ideal for use cases where you need to enforce strict data integrity rules on a wide variety of data types. Here's how Peri can help you handle these data structures:

Tuples

Tuples can be validated for their structure and content, ensuring each element meets specific criteria.

defmodule MySchemas do
  import Peri

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

data = {12.34, 56.78}
Peri.validate(get_schema(:coordinates), data)
# => {:ok, {12.34, 56.78}}

invalid_data = {12.34, "not a float"}
Peri.validate(get_schema(:coordinates), invalid_data)
# => {:error, [%Peri.Error{message: "expected type of :float received \"not a float\" value"}]}

Strings

Strings can be validated for length, equality, and matching regular expressions.

defmodule MySchemas do
  import Peri

  defschema :username, {:string, {:regex, ~r/^[a-zA-Z0-9_]+$/}}
end

valid_data = %{username: "valid_user"}
Peri.validate(get_schema(:username), valid_data)
# => {:ok, %{username: "valid_user"}}

invalid_data = %{username: "invalid user"}
Peri.validate(get_schema(:username), invalid_data)
# => {:error, [%Peri.Error{message: "should match the ~r/^[a-zA-Z0-9_]+$/ pattern"}]}

Lists

Lists can be validated to ensure all elements are of a specific type and meet certain criteria.

defmodule MySchemas do
  import Peri

  defschema :tags, {:list, :string}
end

valid_data = %{tags: ["elixir", "programming"]}
Peri.validate(get_schema(:tags), valid_data)
# => {:ok, %{tags: ["elixir", "programming"]}}

invalid_data = %{tags: ["elixir", 42]}
Peri.validate(get_schema(:tags), invalid_data)
# => {:error, [%Peri.Error{message: "expected type of :string received 42 value"}]}

Integers

Integers can be validated for equality, inequality, and range constraints.

defmodule MySchemas do
  import Peri

  defschema :age, {:integer, {:range, {18, 65}}}
end

valid_data = %{age: 30}
Peri.validate(get_schema(:age), valid_data)
# => {:ok, %{age: 30}}

invalid_data = %{age: 17}
Peri.validate(get_schema(:age), invalid_data)
# => {:error, [%Peri.Error{message: "should be in the range of 18..65 (inclusive)"}]}

Comprehensive Validation Options

Peri's robust validation capabilities make it suitable for various data types and validation needs:

By supporting these raw data structures and providing detailed error handling, Peri ensures that your data remains consistent and adheres to the defined rules, making it an excellent choice for applications requiring strict data validation.

Comparison with other data validation and mapping libraries

Peri vs. Norm

Norm is another Elixir library for schema and data validation. While it shares some similarities with Peri, there are distinct differences:

Peri vs. Drops

Drops is another Elixir library designed for validating and casting data. Key differences include:

Peri vs. Ecto Schemaless Changesets

Ecto is a powerful data mapping and query generator for Elixir, and it offers schemaless changesets for validating data without defining database schemas.

Peri vs. Ecto Embedded Changesets

Ecto embedded changesets are used for validating and casting nested structures within Ecto schemas.

Summary

While all these libraries offer data validation capabilities, Peri stands out with its flexibility, comprehensive validation options, and integration with StreamData for data generation. Whether you're dealing with raw data structures, need advanced validation features, or want to generate test data, Peri provides a robust and versatile solution tailored to meet these needs.

Why the Name "Peri"?

The name "Peri" is derived from the Greek word "περί" (pronounced "peri"), which means "around" or "about." This name was chosen to reflect the library's primary purpose: to provide comprehensive and flexible schema validation for data structures in Elixir. Just as "peri" suggests encompassing or surrounding something, Peri aims to cover all aspects of data validation, ensuring that data conforms to specified rules and constraints.

The choice of the name "Peri" also hints at the library's ability to handle a wide variety of data types and structures, much like how the term "around" can denote versatility and inclusiveness. Whether it's validating nested maps, complex tuples, or strings with specific patterns, Peri is designed to be a robust tool that can adapt to various validation needs in Elixir programming.