View Source Parameter (Parameter v0.4.1)

Parameter is a library for dealing with complex datatypes by solving the following problems:

  • Schema creation and validation
  • Input data validation
  • Deserialization
  • Serialization

example

Example

Create a schema

defmodule User do
  use Parameter.Schema
  alias Parameter.Validators

  param do
    field :first_name, :string, key: "firstName", required: true
    field :last_name, :string, key: "lastName"
    field :email, :string, validator: &Validators.email(&1)
    has_one :address, Address  do
      field :city, :string, required: true
      field :street, :string
      field :number, :integer
    end
  end
end

Load (deserialize) the schema against external parameters:

iex> params = %{
      "firstName" => "John",
      "lastName" => "Doe",
      "email" => "john@email.com",
      "address" => %{"city" => "New York", "street" => "York"}
    }
...> Parameter.load(User, params)
{:ok, %{
  first_name: "John",
  last_name: "Doe",
  email: "john@email.com",
  main_address: %{city: "New York", street: "York"}
}}

or Dump (serialize) a populated schema to params:

iex> schema = %{
    first_name: "John",
    last_name: "Doe",
    email: "john@email.com",
    main_address: %{city: "New York", street: "York"}
  }
...> Parameter.dump(User, params)
{:ok,
 %{
    "firstName" => "John",
    "lastName" => "Doe",
    "email" => "john@email.com",
    "address" => %{"city" => "New York", "street" => "York"}
}}

installation

Installation

Add parameter to your list of dependencies in mix.exs:

def deps do
  [
    {:parameter, "~> 0.4.0"}
  ]
end

add :parameter on .formatter.exs:

import_deps: [:ecto, :phoenix, ..., :parameter],

motivation

Motivation

Offer a similar Schema model from Ecto library to deal with complex data schemas. The main use case is to parse response from external apis. Parameter provides a well structured schema model which tries it's best to parse the external data.

schema

Schema

The first step for building a schema for your data is to create a schema definition to model the external data. This can be achieved by using the Parameter.Schema macro. The example below mimics an User model that have one main_address and a list of phones.

defmodule User do
  use Parameter.Schema

  param do
    field :first_name, :string, key: "firstName", required: true
    field :last_name, :string, key: "lastName", required: true, default: ""
    has_one :main_address, Address, key: "mainAddress", required: true
    has_many :phones, Phone
  end
end

defmodule Address do
  use Parameter.Schema

  param do
    field :city, :string, required: true
    field :street, :string
    field :number, :integer
  end
end

defmodule Phone do
  use Parameter.Schema

  param do
    field :country, :string
    field :number, :integer
  end
end

Parameter offers other ways for creating a schema such as nesting the has_one and has_many fields. This require module name as the second parameter using do at the end:

defmodule User do
  use Parameter.Schema

  param do
    field :first_name, :string, key: "firstName", required: true
    field :last_name, :string, key: "lastName", required: true, default: ""

    has_one :main_address, Address, key: "mainAddress", required: true  do
      field :city, :string, required: true
      field :street, :string
      field :number, :integer
    end
    
    has_many :phones, Address  do
      field :country, :string
      field :number, :integer
    end
  end
end

Another possibility is avoiding creating files for a schema at all. This can be done by importing Parameter.Schema and using the param/2 macro. This is useful for adding params in Phoenix controllers. For example:

defmodule MyProjectWeb.UserController do
  use MyProjectWeb, :controller
  import Parameter.Schema

  alias MyProject.Users

  param UserParams do
    field :first_name, :string, required: true
    field :last_name, :string, required: true
  end

  def create(conn, params) do
    with {:ok, user_params} <- Parameter.load(__MODULE__.UserParams, params),
         {:ok, user} <- Users.create_user(user_params) do
      render(conn, "user.json", %{user: user})
    end
  end
end

It's recommended to use this approach when the schema will only be used in a single module.

types

Types

Each field needs to define the type that will be parsed and the options (if any). The available types are:

  • :string
  • :atom
  • :integer
  • :float
  • :boolean
  • :map
  • :array
  • :date
  • :time
  • :datetime
  • :naive_datetime
  • :decimal*
  • module**

* For decimal type add the decimal library into your project.

** Any module that implements the Parameter.Parametrizable behaviour is eligible to be a field in the schema definition.

The options available for the field definition are:

  • key: This is the key on the external source that will be converted to the param definition. As an example, if you receive data from an external source that uses a camel case for mapping first_name, this flag should be key: "firstName". If this parameter is not set it will default to the field name.
  • default: default value of the field.
  • required: defines if the field needs to be present when parsing the input.

After the definition, the schema can be validated and parsed against external parameters using the Parameter.load/3 function.

data-deserialization

Data Deserialization

This is a common requirement when receiving data from an external source that needs validation and deserialization of data to an Elixir definition. This can be achieved using Parameter.load/2 or Parameter.load/3 functions:

iex> params = %{
      "mainAddress" => %{"city" => "New York"},
      "phones" => [%{"country" => "USA", "number" => "123456789"}],
      "firstName" => "John",
      "lastName" => "Doe"
    }
...> Parameter.load(User, params)
{:ok,
 %{
    first_name: "John",
    last_name: "Doe",
    main_address: %{city: "New York"},
    phones: [%{country: "USA", number: 123456789}]
  }}

Return struct fields

...> Parameter.load(User, params, struct: true)
{:ok,
 %User{
   first_name: "John",
   last_name: "Doe",
   main_address: %Address{city: "New York", number: nil, street: nil},
   phones: [%Phone{country: "USA", number: 123456789}]
 }}

Invalid data should return validation errors:

iex> params = %{
      "mainAddress" => %{"city" => "New York", "number" => "123AB"},
      "phones" => [
        %{
          "country" => "USA", 
          "number" => "123AB"
        }, 
        %{
          "country" => "Brazil", 
          "number" => "Not number"
        }
      ],
      "lastName" => "Doe"
    }
...> Parameter.load(User, params)
{:error,
 %{
   first_name: "is required",
   main_address: %{number: "invalid integer type"},
   phones: [
     "0": %{number: "invalid integer type"},
     "1": %{number: "invalid integer type"}
   ]
 }}

The options for Parameter.load/3 are:

  • struct: If true returns the response with elixir structs. false uses plain maps.
  • unknown: Defines the behaviour when dealing with unknown fields on input data. The options are :ignore and :error

data-serialization

Data Serialization

This is a common requirement when dealing with internal data that needs to be send to an external source. This can be achieved using Parameter.dump/2 function:

iex> loaded_params = %{
  phones: [%{country: "USA", number: 123456789}],
  first_name: "John",
  last_name: "Doe",
  main_address: %{city: "New York"}
}
...> Parameter.dump(User, loaded_params)
{:ok,
 %{
   "firstName" => "John",
   "lastName" => "Doe",
   "mainAddress" => %{"city" => "New York"},
   "phones" => [%{"country" => "USA", "number" => 123456789}]
 }}

custom-types

Custom Types

For implementing custom types create a module that implements the Parameter.Parametrizable behaviour.

Check the following example on how Integer parameter was implemented:

defmodule IntegerCustomType do
  @moduledoc """
  Integer parameter type
  """

  use Parameter.Parametrizable

  @impl true
  # `load/1` should handle deserialization of a value
  def load(value) when is_integer(value) do
    {:ok, value}
  end

  def load(value) when is_binary(value) do
    case Integer.parse(value) do
      {integer, ""} -> {:ok, integer}
      _error -> error_tuple()
    end
  end

  def load(_value) do
    {:error, "invalid integer type"}
  end

  @impl true
  # `dump/1` should handle serialization of a value
  def dump(value) do
    case validate(value) do
      :ok -> {:ok, value}
      error -> error
    end
  end

  @impl true
  # `validate/1` checks the schema during compile time. It verifies the default value if it's passed to the schema validating its type
  def validate(value) when is_integer(value) do
    :ok
  end

  def validate(_value) do
    {:error, "invalid integer type"}
  end
end

Add the custom module on the schema definition:

defmodule User do
  use Parameter.Schema

  param do
    field :age, IntegerCustomType, required: true
  end
end

unknown-fields

Unknown fields

Loading will ignore fields that does not have a matching key in the schema. This behaviour can be changed with the following options:

  • :ignore (default): ignore unknown fields
  • :error: return an error with the unknown fields

Using the same user schema, adding unknow field option to error should return an error:

iex> params = %{"user_token" => "3hgj81312312"}
...> Parameter.load(User, params, unknown: :error)
{:error, %{"user_token" => "unknown field"}}

validation

Validation

Parameter comes with a set of validators to validate the schema after loading. The implemented validators are described in the module Parameter.Validators.

defmodule User do
  use Parameter.Schema
  alias Parameter.Validators

  param do
    field :email, :string, validator: &Validators.email/1
    field :age, :integer, validator: {&Validators.length/2, min: 18, max: 72}
    field :code, :string, validator: {&Validators.regex/2, regex: ~r/code/}
    field :user_code, :string, validator: {&__MODULE__.is_equal/2, to: "0000"}

    field :permission, :atom,
      required: true,
      validator: {&Validators.one_of/2, options: [:admin, :normal]}
  end


  # To add a custom validator create a function with arity 1 or 2.
  # The first parameter is always the field value and the second (and optional)
  # parameter is a `Keyword` list that will be used to pass values on the schema
  # The function must always return `:ok` or `{:error, reason}`
  def is_equal(value, to: to_value) do
    if value == to_value do
      :ok
    else
      {:error, "not equal"}
    end
  end
end

Sending wrong parameters:

iex> params = %{
  "email" => "not email",
  "age" => "12",
  "code" => "asdf",
  "user_code" => "12345",
  "permission" => "super_admin"
}
...> Parameter.load(User, params)
{:error,
 %{
   age: "is invalid",
   code: "is invalid",
   email: "is invalid",
   permission: "is invalid",
   user_code: "not equal"
 }}

Correct input should result in the schema loaded correctly:

iex> params = %{
  "email" => "john@email.com",
  "age" => "22",
  "code" => "code:13234",
  "permission" => "admin",
  "user_code" => "0000"
}
...> Parameter.load(User, params)
{:ok,
 %{
   age: 22,
   code: "code:13234",
   email: "john@email.com",
   permission: :admin,
   user_code: "0000"
 }}

Link to this section Summary

Link to this section Functions

@spec dump(module() | atom(), map()) :: {:ok, any()} | {:error, any()}
Link to this function

load(schema, input, opts \\ [])

View Source
@spec load(module() | atom(), map(), Keyword.t()) :: {:ok, any()} | {:error, any()}