Normandy.Type behaviour (normandy v2.2.0)

View Source

Defines functions and the Normandy.Type behaviour for implementing basic custom types.

Normandy provides two types of custom types: basic types and parameterized types. Basic types are simple, requiring only four callbacks to be implemented, and are enough for most occasions. Parameterized types can be customized on the field definition and provide a wide variety of callbacks.

The definition of basic custom types and all of their callbacks are available in this module. You can learn more about parameterized types in Normandy.ParameterizedType. If in doubt, prefer to use basic custom types and rely on parameterized types if you need the extra functionality.

External vs internal vs serialized representation

The core functionality of a custom type is the mapping between external, internal and serialized representations of a value belonging to the type.

External data comes from the model's JSON or from caller params, internal data is the application's runtime representation, and serialized data is the form dump/1 returns: a term the JSON adapter can encode. Normandy calls dump/1 to check a field's :default when the schema compiles.

stateDiagram-v2
  external: External Data
  internal: Internal Data
  serialized: Serialized Data
  external --> internal: cast/1
  external --> serialized: dump/1
  internal --> serialized: dump/1

Example

Imagine you want to store a URI struct as part of a schema. A custom type is needed to handle the conversion between string input and the URI struct at runtime.

The custom type needs to handle the conversion from external data to runtime data (cast/1) as well as transforming that runtime data into a serializable format (dump/1).

defmodule URIType do
  use Normandy.Type
  def type, do: :map

  # Provide custom casting rules.
  # Cast strings into the URI struct to be used at runtime
  def cast(uri) when is_binary(uri) do
    {:ok, URI.parse(uri)}
  end

  # Accept casting of URI structs as well
  def cast(%URI{} = uri), do: {:ok, uri}

  # Everything else is a failure though
  def cast(_), do: :error

  # When dumping data, we *expect* a URI struct
  # but any value could be inserted into the schema struct at runtime,
  # so we need to guard against them.
  def dump(%URI{} = uri), do: {:ok, Map.from_struct(uri)}
  def dump(_), do: :error
end

Now we can use our new field type in our schemas:

defmodule UrlSchema do
  use Normandy.Schema

  schema do
    field :original_url, URIType
  end
end

Note: nil values are always bypassed and cannot be handled by custom types.

use Normandy.Type

When you use Normandy.Type, it will set @behaviour Normandy.Type and define a default, overridable implementation for equal?/2.

Custom types with validation

Custom types can also include validation logic. For example, an encoded ID type that handles base64 encoding:

defmodule EncodedId do
  use Normandy.Type

  def type, do: :string

  def cast(id) when is_integer(id) do
    {:ok, encode_id(id)}
  end
  def cast(id) when is_binary(id) do
    case Base.decode64(id) do
      {:ok, _} -> {:ok, id}
      :error -> :error
    end
  end
  def cast(_), do: :error

  def dump(id) when is_binary(id) do
    case Base.decode64(id) do
      {:ok, decoded} -> {:ok, String.to_integer(decoded)}
      :error -> :error
    end
  end

  defp encode_id(id) do
    id
    |> Integer.to_string()
    |> Base.encode64()
  end
end

Now you can use this custom type in your schemas:

defmodule ContentSchema do
  use Normandy.Schema

  schema do
    field :id, EncodedId
    field :content, :string
  end
end

Summary

Types

Custom types are represented by user-defined modules.

Primitive types, handled by this module.

t()

A field type, primitive or custom.

Callbacks

Casts the given input to the custom type.

Dumps the given term into its serializable form.

Checks if two terms are semantically equal.

Returns the underlying schema type for the custom type.

Functions

Checks if the given atom can be used as base type.

Casts a value to the given type.

Casts a value to the given type or raises an error.

Checks if the given atom can be used as composite type.

Dumps a value to the given type.

Checks if two terms are equal.

Format type for error messaging and logs.

Checks if collection includes a term.

Returns true when type is the initialized parameterized type of module ({:parameterized, {module, params}}), and false for any other type.

Retrieves the underlying schema type for the given, possibly custom, type.

Types

base()

@type base() ::
  :integer
  | :float
  | :boolean
  | :string
  | :bitstring
  | :map
  | :binary
  | :date
  | :time
  | :binary
  | :any
  | :struct

composite()

@type composite() :: {:array, t()} | {:map, t()} | private_composite()

custom()

@type custom() :: module() | {:parameterized, {module(), term()}}

Custom types are represented by user-defined modules.

primitive()

@type primitive() :: base() | composite()

Primitive types, handled by this module.

t()

@type t() :: primitive() | custom()

A field type, primitive or custom.

Callbacks

cast(term)

@callback cast(term()) :: {:ok, term()} | :error | {:error, keyword()}

Casts the given input to the custom type.

This callback is called on external input (the model's JSON, caller params) by Normandy.Validate.cast/4 and can return any type, as long as the dump/1 function is able to convert the returned value into a serializable one.

You can return :error if the given term cannot be cast. A default error message of "is invalid" will be added to the validation errors.

You may also return {:error, keyword()} to customize the error message and its metadata. Passing a :message key, will override the default message. It is not possible to override the :type key.

For {:array, CustomType} or {:map, CustomType} the returned keyword list will be erased and the default error will be shown.

dump(term)

@callback dump(term()) :: {:ok, term()} | :error

Dumps the given term into its serializable form.

It receives a term stored in the struct (Normandy calls it to check a field's :default at compile time) and must validate it and convert it to a term the JSON adapter can encode.

equal?(term, term)

@callback equal?(term(), term()) :: boolean()

Checks if two terms are semantically equal.

This callback is used by Normandy.Type.equal?/3 and include?/3.

By default the terms are compared with the equal operator ==/2.

type()

@callback type() :: t()

Returns the underlying schema type for the custom type.

For example, if you want to provide your own date structures, the type function should return :date.

The returned type decides the field's JSON Schema type.

Functions

base?(atom)

@spec base?(atom()) :: boolean()

Checks if the given atom can be used as base type.

iex> base?(:string)
true
iex> base?(:array)
false
iex> base?(Custom)
false

cast(type, value)

@spec cast(t(), term()) :: {:ok, term()} | {:error, keyword()} | :error

Casts a value to the given type.

cast/2 is used by Normandy.Validate.cast/4 to cast outside values to specific types.

Note that nil can be cast to all primitive types: an unset field is nil.

NaN and infinite decimals are not supported, use custom types instead.

iex> cast(:any, "whatever")
{:ok, "whatever"}

iex> cast(:any, nil)
{:ok, nil}
iex> cast(:string, nil)
{:ok, nil}

iex> cast(:integer, 1)
{:ok, 1}
iex> cast(:integer, "1")
{:ok, 1}
iex> cast(:integer, "1.0")
:error

iex> cast(:float, 1.0)
{:ok, 1.0}
iex> cast(:float, 1)
{:ok, 1.0}
iex> cast(:float, "1")
{:ok, 1.0}
iex> cast(:float, "1.0")
{:ok, 1.0}
iex> cast(:float, "1-foo")
:error

iex> cast(:boolean, true)
{:ok, true}
iex> cast(:boolean, false)
{:ok, false}
iex> cast(:boolean, "1")
{:ok, true}
iex> cast(:boolean, "0")
{:ok, false}
iex> cast(:boolean, "whatever")
:error

iex> cast(:string, "beef")
{:ok, "beef"}
iex> cast(:binary, "beef")
{:ok, "beef"}


iex> cast({:array, :integer}, [1, 2, 3])
{:ok, [1, 2, 3]}
iex> cast({:array, :integer}, ["1", "2", "3"])
{:ok, [1, 2, 3]}
iex> cast({:array, :string}, [1, 2, 3])
:error
iex> cast(:string, [1, 2, 3])
:error

cast!(type, value)

Casts a value to the given type or raises an error.

See cast/2 for more information.

Examples

iex> Normandy.Type.cast!(:integer, "1")
1
iex> Normandy.Type.cast!(:integer, 1)
1
iex> Normandy.Type.cast!(:integer, nil)
nil

iex> Normandy.Type.cast!(:integer, 1.0)
** (Normandy.CastError) cannot cast 1.0 to :integer

composite?(atom)

@spec composite?(atom()) :: boolean()

Checks if the given atom can be used as composite type.

iex> composite?(:array)
true
iex> composite?(:string)
false

dump(type, value, dumper \\ &dump/2)

@spec dump(t(), term(), (t(), term() -> {:ok, term()} | :error)) ::
  {:ok, term()} | :error

Dumps a value to the given type.

Opposite to casting, dumping requires the returned value to be a term the JSON adapter can encode.

iex> dump(:string, nil)
{:ok, nil}
iex> dump(:string, "foo")
{:ok, "foo"}

iex> dump(:integer, 1)
{:ok, 1}
iex> dump(:integer, "10")
:error

iex> dump(:binary, "foo")
{:ok, "foo"}
iex> dump(:binary, 1)
:error

iex> dump({:array, :integer}, [1, 2, 3])
{:ok, [1, 2, 3]}
iex> dump({:array, :integer}, [1, "2", 3])
:error
iex> dump({:array, :binary}, ["1", "2", "3"])
{:ok, ["1", "2", "3"]}

equal?(type, term1, term2)

@spec equal?(t(), term(), term()) :: boolean()

Checks if two terms are equal.

Depending on the given type performs a structural or semantical comparison.

Examples

iex> equal?(:integer, 1, 1)
true

format(type)

Format type for error messaging and logs.

include?(type, term, collection)

@spec include?(t(), term(), Enum.t()) :: boolean()

Checks if collection includes a term.

Depending on the given type performs a structural or semantical comparison.

Examples

iex> include?(:integer, 1, 1..3)
true

parameterized?(arg1, module)

@spec parameterized?(t(), module()) :: boolean()

Returns true when type is the initialized parameterized type of module ({:parameterized, {module, params}}), and false for any other type.

type(type)

@spec type(t()) :: t()

Retrieves the underlying schema type for the given, possibly custom, type.

iex> type({:array, :string})
{:array, :string}