Current section
Files
Jump to
Current section
Files
lib/coercion.ex
defmodule Coercion do
@moduledoc """
Rigorous coercion of untrusted values to native primitive types
"""
@supported_types [
:integer,
:boolean,
:string,
:atom,
:map,
:list,
:utc_datetime,
:naive_datetime
]
@type supported_type ::
:integer | :boolean | :string | :atom | :map | :list | :utc_datetime | :naive_datetime
@type output_type ::
String.t()
| integer
| boolean
| map
| list
| Date.t()
| DateTime.t()
| NaiveDateTime.t()
@spec coerce(any, supported_type()) :: {:ok | :invalid | :blank, output_type()}
def supported_types, do: @supported_types
@doc """
Coerce and validate a value to the given type.
## Examples
iex> import Coercion # For demo purposes
Coercion
iex> coerce(" 20 ", :integer)
{:ok, 20}
iex> coerce(" x", :integer)
{:invalid, 0}
iex> coerce(" TRue ", :boolean)
{:ok, true}
iex> coerce(" T ", :boolean)
{:ok, true}
iex> coerce(" y ", :boolean)
{:ok, true}
iex> coerce(" Yes ", :boolean)
{:ok, true}
iex> coerce(" 1 ", :boolean)
{:ok, true}
iex> coerce(" TRu ", :boolean)
{:invalid, false}
iex> coerce(" F ", :boolean)
{:ok, false}
iex> coerce(" N ", :boolean)
{:ok, false}
iex> coerce(" 0 ", :boolean)
{:ok, false}
iex> coerce(" hello ", :string)
{:ok, "hello"}
iex> coerce(" ", :string)
{:blank, ""}
iex> coerce(true, :string)
{:ok, "true"}
iex> coerce(10.5, :string)
{:ok, "10.5"}
iex> :bellow
:bellow
iex> coerce("bellow", :atom)
{:ok, :bellow}
iex> coerce(" bellow ", :atom)
{:ok, :bellow}
iex> coerce(%{"one" => "one"}, :map)
{:ok, %{"one" => "one"}}
iex> coerce("nope", :map)
{:invalid, %{}}
iex> coerce(["one", "two"], :list)
{:ok, ["one", "two"]}
iex> coerce("nope", :list)
{:invalid, []}
iex> coerce("2020-04-02", :date)
{:ok, ~D[2020-04-02]}
iex> coerce(:hello, :date)
{:invalid, nil}
iex> coerce("2020-04-02T12:00:01Z", :utc_datetime)
{:ok, ~U[2020-04-02 12:00:01Z]}
iex> coerce("2020-04-02T12:00:01Z", :naive_datetime)
{:ok, ~N[2020-04-02 12:00:01]}
"""
# Integer
def coerce(value, :integer) when is_integer(value), do: {:ok, value}
def coerce(value, :integer) when is_float(value), do: {:ok, Kernel.round(value)}
def coerce(value, :integer) when is_bitstring(value) do
value
|> String.trim()
|> coerce(:integer, :trimmed)
end
def coerce(_value, :integer), do: {:invalid, 0}
# Boolean
def coerce(value, :boolean) when is_boolean(value), do: {:ok, value}
def coerce(value, :boolean) when is_bitstring(value) do
value
|> String.trim()
|> coerce(:boolean, :trimmed)
end
def coerce(_value, :boolean), do: {:invalid, false}
# Map
def coerce(value, :map) when is_map(value), do: {:ok, value}
def coerce(_value, :map), do: {:invalid, %{}}
# List
def coerce(value, :list) when is_list(value), do: {:ok, value}
def coerce(_value, :list), do: {:invalid, []}
# Date
def coerce(%Date{} = value, :date), do: {:ok, value}
def coerce(value, :date) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date} -> {:ok, date}
{:error, _error} -> {:invalid, nil}
end
end
def coerce(_value, :date) do
{:invalid, nil}
end
# DateTime
def coerce(%DateTime{} = value, :utc_datetime), do: {:ok, value}
def coerce(value, :utc_datetime) when is_binary(value) do
case DateTime.from_iso8601(value) do
# Note: The DateTime.from_iso8601/1 documentation claims that ISO 8601 does not include a proper timezone
# I don't normally use DateTime, so maybe someone else can resolve offset issues.
{:ok, dt, _} -> {:ok, dt}
{:error, _error} -> {:invalid, nil}
end
end
def coerce(_value, :utc_datetime) do
{:invalid, nil}
end
# NaiveDateTime
def coerce(%NaiveDateTime{} = value, :naive_datetime), do: {:ok, value}
def coerce(value, :naive_datetime) when is_binary(value) do
case NaiveDateTime.from_iso8601(value) do
{:ok, dt} -> {:ok, dt}
{:error, _error} -> {:invalid, nil}
end
end
def coerce(_value, :naive_datetime) do
{:invalid, nil}
end
# String
def coerce(value, :string) when is_integer(value), do: coerce(Integer.to_string(value), :string)
def coerce(value, :string) when is_float(value), do: coerce(Float.to_string(value), :string)
def coerce(_value = true, :string), do: coerce("true", :string)
def coerce(_value = false, :string), do: coerce("false", :string)
def coerce(value, :string) when is_nil(value), do: coerce("", :string)
def coerce(value, :string) when is_bitstring(value) do
value
|> String.trim()
|> coerce(:string, :trimmed)
end
def coerce(_value, :string), do: {:invalid, ""}
# Atom
def coerce(value, :atom) when is_bitstring(value) do
atom_value =
value
|> String.trim()
|> String.to_existing_atom()
{:ok, atom_value}
end
def coerce(value, :atom) when is_integer(value), do: coerce(Integer.to_string(value), :atom)
def coerce(_value, :atom), do: {:invalid, ""}
# Value
def value(value, atom) do
{_, value} = coerce(value, atom)
value
end
#
# Private functions
#
# Integer from a trimmed string
defp coerce(_value = "", :integer, :trimmed), do: {:blank, 0}
defp coerce(value, :integer, :trimmed) do
Integer.parse(value, 10)
|> coerce(:integer, :parsed)
end
# From the result of `Integer.parse`
defp coerce(:error, :integer, :parsed), do: {:invalid, 0}
defp coerce({int, ""}, :integer, :parsed), do: {:ok, int}
defp coerce({int, _}, :integer, :parsed), do: {:invalid, int}
# From a trimmed string
defp coerce(_value = "", :string, :trimmed), do: {:blank, ""}
defp coerce(value, :string, :trimmed), do: {:ok, value}
# Parse boolean from trimmed string
defp coerce(value, :boolean, :trimmed), do: coerce(String.downcase(value), :boolean, :downcased)
# Parse boolean from trimmed, downcased string
defp coerce(_value = "true", :boolean, :downcased), do: {:ok, true}
defp coerce(_value = "false", :boolean, :downcased), do: {:ok, false}
defp coerce(_value = "t", :boolean, :downcased), do: {:ok, true}
defp coerce(_value = "f", :boolean, :downcased), do: {:ok, false}
defp coerce(_value = "yes", :boolean, :downcased), do: {:ok, true}
defp coerce(_value = "no", :boolean, :downcased), do: {:ok, false}
defp coerce(_value = "y", :boolean, :downcased), do: {:ok, true}
defp coerce(_value = "n", :boolean, :downcased), do: {:ok, false}
defp coerce(_value = "on", :boolean, :downcased), do: {:ok, true}
defp coerce(_value = "off", :boolean, :downcased), do: {:ok, false}
defp coerce(_value = "1", :boolean, :downcased), do: {:ok, true}
defp coerce(_value = "0", :boolean, :downcased), do: {:ok, false}
defp coerce(_value = "", :boolean, :downcased), do: {:blank, false}
defp coerce(_value, :boolean, :downcased), do: {:invalid, false}
end