lib/cast_param/schema.ex

defmodule CastParams.Schema do
  @moduledoc """
  Defines a params schema for a plug.

  A params schema is just a keyword list where keys are the parameter name
  and the value is either a valid `CastParams.Type` (ending with a `!` to mark the parameter as required).

  ## Example

      CastParams.Schema.init(age: :integer, terms: :boolean!, name: :string, weight: :float)

  """

  alias CastParams.{Error, Param, Type}

  @primitive_types Type.primitive_types()
  @required_to_type Enum.reduce(@primitive_types, %{}, &Map.put(&2, :"#{&1}!", &1))
  @required_names Map.keys(@required_to_type)

  @type t :: [{name :: atom(), Type.t()}]

  @doc """
  Init schema

  ## Examples
      iex> init(age: :integer)
      [%CastParams.Param{names: ["age"], type: :integer}]

      iex> init([age: :integer!])
      [%CastParams.Param{names: ["age"], type: :integer, required: true}]

      iex> init([terms: :boolean!, name: :string, age: :integer])
      [
        %CastParams.Param{names: ["terms"], type: :boolean, required: true},
        %CastParams.Param{names: ["name"], required: false, type: :string},
        %CastParams.Param{names: ["age"], required: false, type: :integer},
      ]
  """
  @spec init(options :: list()) :: [Param.t()]
  def init(options) when is_list(options) do
    options
    |> Enum.reduce([], &init_item(%Param{}, &1, &2))
    |> Enum.reduce([], fn param, acc ->
      updated_item =
        param
        |> Map.update!(:names, &Enum.reverse/1)

      [updated_item | acc]
    end)
  end

  defp init_item(param, {name, type}, acc) when is_atom(name) and is_atom(type) do
    updated_param =
      param
      |> parse_names(name)
      |> parse_type(type)

    [updated_param | acc]
  end

  defp init_item(param, {name, options}, acc) when is_list(options) do
    updated_param = parse_names(param, name)

    options
    |> Enum.reduce(acc, &init_item(updated_param, &1, &2))
  end

  defp parse_names(%{names: names} = param, name) when is_atom(name) do
    parsed_name = to_string(name)
    %{param | names: [parsed_name | names]}
  end

  @spec parse_type(Param.t(), atom()) :: Param.t()
  defp parse_type(param, type) when type in @primitive_types do
    Map.put(param, :type, type)
  end

  defp parse_type(param, raw_type) when raw_type in @required_names do
    param
    |> Map.put(:type, @required_to_type[raw_type])
    |> Map.put(:required, true)
  end

  defp parse_type(param, type) do
    raise Error, "Error invalid `#{type}` type for `#{param.names}` name."
  end
end