lib/jexon.ex

defmodule Jexon do
  @moduledoc """
  Library to parse JSON and convert structs into maps or json without deriving any encoder.
  """

  @doc """
  Returns a map of the struct or atom map but keeps the key identity

  ## Options
  - `with_struct_info`, `boolean`, default: `true`
    - includes `__struct__` key
  - `keep_key_identity`, `boolean`, default: `false`
    - encodes atom type if key is an atom

  ## Example Usage

      iex> {:ok, datetime} = DateTime.new(~D[2018-07-28], ~T[12:30:00])
      iex> Jexon.to_map(datetime)
      %{
        "calendar" => Calendar.ISO,
        "day" => 28,
        "hour" => 12,
        "microsecond" => {0, 0},
        "minute" => 30,
        "month" => 7,
        "second" => 0,
        "std_offset" => 0,
        "time_zone" => "Etc/UTC",
        "utc_offset" => 0,
        "year" => 2018,
        "zone_abbr" => "UTC",
        "__struct__" => DateTime
      }
  """

  @atom_prefix "__atom__:"

  @spec to_map(data :: struct() | map(), opts :: keyword()) :: map()
  def to_map(data, opts \\ [])

  def to_map(data, opts) do
    with_struct_info = Keyword.get(opts, :with_struct_info, true)
    keep_key_identity = Keyword.get(opts, :keep_key_identity, false)

    data
    |> Map.to_list()
    |> Enum.map(& stringify_keys(&1, keep_key_identity))
    |> Enum.reject(fn {key, _} -> key == "__struct__" and not with_struct_info end)
    |> Enum.map(fn
      {key, value} when is_map(value) or is_struct(value) -> {key, to_map(value, opts)}
      key_val_pair -> key_val_pair
    end)
    |> Map.new()
  end

  @doc """
  Returns JSON of any given datatype

  ## Options
  - `with_struct_info`, `boolean`, default: `true`
    - includes `__struct__` key
  - `with_type_info`, `boolean`, default: `true`
    - convert all non-existing types into lists starting with the elixir type as `__<type>__`
      - e.g. `{1,2,3}` = `["__tuple__", 1, 2, 3]`

  ## Example Usage

      iex> data = %{foo: 1, baz: {2}, bar: :lol}
      iex> Jexon.to_json(data, with_type_info: true)
      {:ok, ~s/{\"__atom__:bar\":[\"__atom__\",\"lol\"],\"__atom__:baz\":[\"__tuple__\",2],\"__atom__:foo\":1}/}
  """
  @spec to_json(data :: any(), opts :: keyword()) ::
          {:ok, json :: String.t()} | {:error, Jason.EncodeError.t() | Exception.t()}
  def to_json(data, opts \\ [])
  def to_json(data, opts) do
    with_type_info = Keyword.get(opts, :with_type_info, true)
    data =
      if with_type_info do
        prepare_value_for_json_encoding(data, opts)
      else
        data
      end

    Jason.encode(data)
  end

  @doc """
  Returns (casted) value on given JSON

  ## Options
  - `raw`, `boolean`, default: `false`
    - perform no type casting and keep keys as strings
  """
  @spec from_json(json :: String.t(), opts :: keyword()) ::
          {:ok, any()} | {:error, Jason.DecodeError.t()}
  def from_json(json, opts \\ [])

  def from_json(json, opts) do
    raw = Keyword.get(opts, :raw, false)
    json = String.replace(json, ~r/[\\]/, "")
    case Jason.decode(json) do
      {:error, _} = err ->
        err

      {:ok, data} ->
        if raw do
          {:ok, data}
        else
          {:ok, cast_type_back(data)}
        end
    end
  end

  defp prepare_value_for_json_encoding(value, opts) when is_map(value) do
    value
    |> to_map(Keyword.merge(opts, keep_key_identity: true))
    |> Enum.map(fn
      {key, val} -> {key, prepare_value_for_json_encoding(val, opts)} end)
    |> Map.new()
  end

  defp prepare_value_for_json_encoding(value, opts) when is_list(value) do
    value
    |> Enum.map(&prepare_value_for_json_encoding(&1, opts))
  end

  defp prepare_value_for_json_encoding(value, opts) when is_tuple(value) do
    ~w(__tuple__) ++ (Tuple.to_list(value) |> Enum.map(&prepare_value_for_json_encoding(&1, opts)))
  end

  defp prepare_value_for_json_encoding(value, _) when is_nil(value) do
    nil
  end

  defp prepare_value_for_json_encoding(value, _) when is_boolean(value) do
    value
  end

  defp prepare_value_for_json_encoding(value, _) when is_atom(value) do
    ~w(__atom__) ++ [to_string(value)]
  end

  defp prepare_value_for_json_encoding(value, _) do
    value
  end

  defp stringify_keys({key, val}, _keep_key_identity = true) do
    if is_atom(key) do
      {@atom_prefix <> to_string(key), val}
    else
      {key, val}
    end
  end
  defp stringify_keys({key, val}, _keep_key_identity = false) do
    {to_string(key), val}
  end

  defp cast_type_back(["__tuple__" | rest]) do
    rest
    |> Enum.map(&cast_type_back/1)
    |> List.to_tuple()
  end

  defp cast_type_back(["__atom__" | [x]]) do
    String.to_atom(x)
  end

  defp cast_type_back(value) when is_map(value) do
    value
    |> Enum.map(fn
      {@atom_prefix <> key, val} ->
        {String.to_atom(key), cast_type_back(val)}
      {key, val} -> {key, cast_type_back(val)}
    end)
    |> Map.new()
    |> cast_struct()
  end

  defp cast_type_back(value) when is_list(value) do
    Enum.map(value, &cast_type_back/1)
  end

  defp cast_type_back(value) do
    value
  end

  defp cast_struct(%{__struct__: _} = data) do
    keys =
      data
      |> Map.keys()
      |> Enum.reject(&(&1 in ~w(__struct__)a))

    struct(data.__struct__, Map.take(data, keys))
  end

  defp cast_struct(map) do
    map
  end
end