lib/kvasir/type/serializer.ex

defmodule Kvasir.Type.Serializer do
  require Logger
  @type field :: {name :: atom, type :: module, opts :: Keyword.t()}

  @spec encode([field], map, Keyword.t()) ::
          {:ok, %{required(atom) => term}} | {:error, reason :: atom}
  def encode(fields, data, opts \\ []), do: do_encode(fields, data, opts[:into] || %{})

  @spec decode([field], map, Keyword.t()) ::
          {:ok, %{required(atom) => term}} | {:error, reason :: atom}
  def decode(fields, data, opts \\ []), do: do_decode(fields, data, opts[:into] || %{})

  ### Helpers ###

  @spec do_encode([field], map, map) :: {:ok, map} | {:error, atom}
  defp do_encode([], _data, acc), do: {:ok, acc}

  defp do_encode([{field, type, opts} | fields], data, acc) do
    with {:ok, value} when value != nil <- Map.fetch(data, field),
         false <- value == opts[:default],
         {:ok, encoded} <- type.dump(value, opts) do
      do_encode(fields, data, Map.put(acc, field, encoded))
    else
      true ->
        do_encode(fields, data, acc)

      {:ok, nil} ->
        do_encode(fields, data, acc)

      :error ->
        if Keyword.has_key?(opts, :default) || opts[:optional],
          do: do_encode(fields, data, acc),
          else: {:error, :"missing_#{field}_field"}

      error = {:error, _} ->
        error
    end
  end

  @spec do_decode([field], map, map) :: {:ok, map} | {:error, atom}
  defp do_decode([], _data, acc), do: {:ok, acc}

  defp do_decode([{field, type, opts} | fields], data, acc) do
    with {:ok, value} <- MapX.fetch(data, field),
         {:ok, parsed_value} <- type.parse(value, opts) do
      do_decode(fields, data, Map.put(acc, field, parsed_value))
    else
      :error ->
        cond do
          Keyword.has_key?(opts, :default) ->
            with {:ok, d} <- default_value(opts[:default], opts),
                 do: do_decode(fields, data, Map.put(acc, field, d))

          opts[:optional] ->
            do_decode(fields, data, acc)

          :missing ->
            {:error, :"missing_#{field}_field"}
        end

      error = {:error, _} ->
        error

      other ->
        Logger.error("Type <#{inspect(type)}> returned invalid response: #{other}")
        {:error, :invalid_type_response}
    end
  end

  @spec default_value(default :: term, opts :: Keyword.t()) :: {:ok, term} | {:error, term}
  defp default_value(default, opts) when is_function(default, 1), do: default.(opts)
  defp default_value(default, _opts) when is_function(default, 0), do: default.()
  defp default_value(default, _opts), do: {:ok, default}
end