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