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