lib/normalizer.ex

defmodule Normalizer do
  @moduledoc """
  # Normalizer

  Normalizes string-keyed maps to atom-keyed maps while converting values
  according to a given schema. Particularly useful when working with param maps.

  ### Usage

      schema = %{
        user_id: {:number, required: true},
        name: :string,
        admin: {:boolean, default: false},
        languages: [:string]
      }

      params = %{
        "user_id" => "42",
        "name" => "Neo",
        "languages" => ["en"],
        "age" => "55"
      }

      > Normalizer.normalize(params, schema)
      %{
        user_id: 42,
        name: "Neo",
        admin: false,
        languages: ["en"]
      }

  ### Properties

  * **Converts** types whenever possible and reasonable;
  * **Ensures** required values are given;
  * **Filters** keys and values not in the schema;
  * **Supports** basic types, lists, maps, and nested lists/maps.

  See `Normalizer.normalize/2` for more details.
  """

  alias Normalizer.MissingValue

  @type value_type :: atom() | [atom()] | map()

  @type value_options :: [
          required: boolean(),
          default: any(),
          with_offset: boolean()
        ]

  @type value_schema :: value_type() | {value_type(), value_options()}

  @type schema :: %{
          required(atom()) => value_schema()
        }

  defmacrop is_type(type) do
    quote do
      is_atom(unquote(type)) or is_list(unquote(type)) or is_map(unquote(type))
    end
  end

  defmacrop is_schema(schema) do
    quote do
      is_type(unquote(schema)) or is_tuple(unquote(schema))
    end
  end

  @doc """
  Normalizes a string-map using the given schema.

  The schema is expected to be a map where each key is an atom representing an
  expected string key in `params`, pointing to the type to which the respective
  value in the params should be normalized.

  The return is a normalized map, in case of success, or a map with each
  erroring key and a description, in case of failure.

  ## Types

  Types can be one of:
  * **Primitives**: `:string`, `:number`, `:boolean`.
  * **Parseable values**: `:datetime`, `:date`.
  * **Maps**: a nested schema for a nested map.
  * **Lists**: one-element lists that contain any of the other types.
  * **Tuples**: two-element tuples where the first element is one of the other types, and the second element a keyword list of options.

  ### Primitives

  **Strings** are somewhat of a catch all that ensures anything but lists and
  maps are converted to strings:

      iex> Normalizer.normalize(%{"key" => 42}, %{key: :string})
      {:ok, %{key: "42"}}

  **Numbers** are kept as is, if possible, or parsed:

      iex> Normalizer.normalize(%{"key" => 42}, %{key: :number})
      {:ok, %{key: 42}}

      iex> Normalizer.normalize(%{"key" => "42.5"}, %{key: :number})
      {:ok, %{key: 42.5}}

  **Booleans** accept native values, "true" and "false" strings, and "1" and "0"
  strings:

      iex> Normalizer.normalize(%{"key" => true}, %{key: :boolean})
      {:ok, %{key: true}}

      iex> Normalizer.normalize(%{"key" => "false"}, %{key: :boolean})
      {:ok, %{key: false}}

  ### Parseable Values

  Only `:datetime` and `:date` are supported right now.

      iex> Normalizer.normalize(%{"key" => "2020-02-11T00:00:00+0100"}, %{key: :datetime})
      {:ok, %{key: ~U[2020-02-10T23:00:00Z]}}

      iex> Normalizer.normalize(%{"key" => "2020-02-11"}, %{key: :date})
      {:ok, %{key: ~D[2020-02-11]}}

  The offset can be extracted as well by passing the `with_offset` option:

      iex> Normalizer.normalize(
      ...>   %{"key" => "2020-02-11T00:00:00+0100"},
      ...>   %{key: {:datetime, with_offset: true}}
      ...> )
      {:ok, %{key: {~U[2020-02-10T23:00:00Z], 3600}}}

  ### Maps

  Nested schemas are supported:

      iex> Normalizer.normalize(
      ...>   %{"key" => %{"age" => "42"}},
      ...>   %{key: %{age: :number}}
      ...> )
      {:ok, %{key: %{age: 42}}}

  ### Lists

  Lists are represented in the schema by a single-element list:

      iex> Normalizer.normalize(
      ...>   %{"key" => ["42", 52]},
      ...>   %{key: [:number]}
      ...> )
      {:ok, %{key: [42, 52]}}

  We can normalize lists of any one of the other types.

  ## Options

  Per-value options can be specified by passing a two-element tuple in the type
  specification. The three available options are `:required`, `:default`, and
  `:with_offset`.

  `:required` fails the validation process if the key is missing or nil:

      iex> Normalizer.normalize(%{"key" => nil}, %{key: :number})
      {:ok, %{key: nil}}

      iex> Normalizer.normalize(%{"key" => nil}, %{key: {:number, required: true}})
      {:error, %{key: "required number, got nil"}}

      iex> Normalizer.normalize(%{}, %{key: {:number, required: true}})
      {:error, %{key: "required number"}}

  `:default` ensures a value in a given key, if nil or missing:

      iex> Normalizer.normalize(%{}, %{key: {:number, default: 42}})
      {:ok, %{key: 42}}

      iex> Normalizer.normalize(%{"key" => 24}, %{key: {:number, default: 42}})
      {:ok, %{key: 24}}

  `:with_offset` is explained in the `:datetime` type above.
  """
  @spec normalize(params :: %{String.t() => any()}, schema :: schema()) ::
          {:ok, %{required(atom()) => any()}} | {:error, String.t()}
  def normalize(params, schema) do
    case apply_schema(params, schema) do
      %{errors: errors, normalized: normalized} when map_size(errors) == 0 -> {:ok, normalized}
      %{errors: errors} -> {:error, errors}
    end
  end

  defp apply_schema(params, schema) do
    for {key, value_schema} <- schema, reduce: %{normalized: %{}, errors: %{}} do
      %{normalized: normalized, errors: errors} ->
        value = Map.get(params, Atom.to_string(key), %MissingValue{})

        case normalize_value(value, value_schema) do
          {:ok, %MissingValue{}} ->
            %{normalized: normalized, errors: errors}

          {:ok, normalized_value} ->
            %{normalized: Map.put(normalized, key, normalized_value), errors: errors}

          {:error, value_error} ->
            %{normalized: normalized, errors: Map.put(errors, key, value_error)}
        end
    end
  end

  defp normalize_value(value, {type, options}) when is_type(type) and is_list(options) do
    with {:ok, value} <- convert_type(value, type),
         {:ok, value} <- apply_options(value, type, options),
         do: {:ok, value}
  end

  defp normalize_value(value, type) when is_type(type),
    do: normalize_value(value, {type, []})

  # Return nil for all types if value is nil:
  defp convert_type(nil, _type),
    do: {:ok, nil}

  # Pass-through for a missing value:
  defp convert_type(%MissingValue{}, _type),
    do: {:ok, %MissingValue{}}

  # Convert strings:
  defp convert_type(value, :string) when is_binary(value),
    do: {:ok, value}

  defp convert_type(value, :string) when not is_map(value) and not is_list(value),
    do: {:ok, to_string(value)}

  defp convert_type(_value, :string),
    do: {:error, "expected string"}

  # Convert numbers:
  defp convert_type(value, :number) when is_number(value),
    do: {:ok, value}

  defp convert_type(value, :number) when is_binary(value) do
    ret =
      if String.contains?(value, "."),
        do: Float.parse(value),
        else: Integer.parse(value)

    case ret do
      {value, ""} -> {:ok, value}
      _ -> {:error, "expected number"}
    end
  end

  # Convert booleans:
  defp convert_type(value, :boolean) when is_boolean(value),
    do: {:ok, value}

  defp convert_type(value, :boolean) when is_binary(value) do
    case value do
      truthy when truthy in ["1", "true"] -> {:ok, true}
      falsy when falsy in ["0", "false"] -> {:ok, false}
      _ -> {:error, "expected boolean"}
    end
  end

  # Convert datetimes:
  defp convert_type(value, :datetime) when is_binary(value) do
    case DateTime.from_iso8601(value) do
      {:ok, datetime, offset} -> {:ok, {datetime, offset}}
      {:error, error} -> {:error, "expected datetime (#{error})"}
    end
  end

  # Convert dates:
  defp convert_type(value, :date) when is_binary(value) do
    case Date.from_iso8601(value) do
      {:ok, date} -> {:ok, date}
      {:error, error} -> {:error, "expected date (#{error})"}
    end
  end

  # Convert lists:
  defp convert_type(values, [schema]) when is_list(values) and is_schema(schema) do
    values
    |> Enum.reduce_while([], fn value, out ->
      case normalize_value(value, schema) do
        {:ok, normalized} -> {:cont, [normalized | out]}
        {:error, error} -> {:halt, error <> " list"}
      end
    end)
    |> case do
      out when is_list(out) -> {:ok, Enum.reverse(out)}
      error when is_binary(error) -> {:error, error}
    end
  end

  # Convert maps (just recurse):
  defp convert_type(map, map_schema) when is_map(map) and is_map(map_schema),
    do: normalize(map, map_schema)

  defp convert_type(_value, type),
    do: {:error, "expected #{type_string(type)}"}

  defp apply_options(value, type, options) when is_list(options) and is_type(type) do
    options
    |> full_type_options(type)
    |> Enum.reduce_while({:ok, value}, fn {opt_key, opt_value}, {:ok, value} ->
      case apply_option(value, type, opt_key, opt_value) do
        {:ok, value} -> {:cont, {:ok, value}}
        {:error, error} -> {:halt, {:error, error}}
      end
    end)
  end

  defp apply_option(nil, type, :required, true),
    do: {:error, "required #{type_string(type)}, got nil"}

  defp apply_option(%MissingValue{}, type, :required, true),
    do: {:error, "required #{type_string(type)}"}

  defp apply_option(nil, _type, :default, value),
    do: {:ok, value}

  defp apply_option(%MissingValue{}, _type, :default, value),
    do: {:ok, value}

  defp apply_option(datetime_with_offset, :datetime, :with_offset, true),
    do: {:ok, datetime_with_offset}

  defp apply_option({datetime, _offset}, :datetime, :with_offset, false),
    do: {:ok, datetime}

  defp apply_option(value, _type, _option, _option_value),
    do: {:ok, value}

  defp full_type_options(options, :datetime), do: Keyword.put_new(options, :with_offset, false)
  defp full_type_options(options, _type), do: options

  defp type_string(type) when is_atom(type),
    do: "#{type}"

  defp type_string([type]) when is_atom(type),
    do: "#{type} list"

  defp type_string([{type, _options}]) when is_atom(type),
    do: "#{type} list"

  defp type_string(type) when is_map(type),
    do: "map"
end