Skip to main content

lib/types.ex

defmodule Rivet.Utils.Types do
  @moduledoc """
  Helper module for common type handling needs

  Contributors: Brandon Gillespie, Mark Erickson, Lyle Mantooth
  """

  @doc """
  ```elixir
  iex> lookup_address("nope")
  {:error, :nxdomain}
  iex> lookup_address(10)
  {:error, :einval}
  iex> lookup_address('localhost')
  {:ok, {127,0,0,1}}
  iex> lookup_address("localhost")
  {:ok, {127,0,0,1}}
  iex> lookup_address("127.0.0.1")
  {:ok, {127,0,0,1}}
  ```
  """
  @spec lookup_address(String.t() | charlist()) :: {:ok, tuple()} | {:error, term()}
  def lookup_address(address) when is_binary(address),
    do: lookup_address(to_charlist(address))

  def lookup_address(address) when is_list(address) do
    # parse_address/1 returns {:ok, ip} if successful. If there's an error,
    # `address` is probably a hostname.
    with {:error, :einval} <- :inet.parse_address(address),
         {:ok, {:hostent, ^address, _, _, _, [ip | _]}} <- :inet.gethostbyname(address) do
      {:ok, ip}
    end
  end

  def lookup_address(_), do: {:error, :einval}

  @doc ~S"""
  ```elixir
  iex> as_int(10)
  {:ok, 10}
  iex> as_int("10")
  {:ok, 10}
  iex> as_int("tardis")
  {:error, "\"tardis\" is not a number"}
  iex> as_int(:foo)
  {:error, ":foo is not a number"}
  ```
  """
  def as_int(arg) when is_integer(arg), do: {:ok, arg}

  def as_int(arg) when is_binary(arg) do
    {:ok, String.to_integer(arg)}
  rescue
    ArgumentError -> {:error, "#{inspect(arg)} is not a number"}
  end

  def as_int(other), do: {:error, "#{inspect(other)} is not a number"}

  @doc ~S"""
  ```elixir
  iex> as_int!(10)
  10
  iex> as_int!("10")
  10
  iex> as_int!("tardis", 9)
  9
  iex> as_int!(:foo, 8)
  8
  ```
  """
  def as_int!(arg, default \\ 0) do
    case as_int(arg) do
      {:ok, num} -> num
      {:error, _} -> default
    end
  end

  @doc ~S"""
  ```elixir
  iex> as_float(10.52)
  {:ok, 10.52}
  iex> as_float("10.5")
  {:ok, 10.5}
  iex> as_float(".55")
  {:ok, 0.55}
  iex> as_float("tardis")
  {:error, "\"tardis\" is not a number"}
  ```
  """
  def as_float(arg) when is_number(arg), do: {:ok, arg}

  def as_float(arg) when is_binary(arg) do
    {:ok, String.to_float(arg)}
  rescue
    ArgumentError ->
      if String.at(arg, 0) == "." do
        as_float("0#{arg}")
      else
        as_int(arg)
      end
  end

  @doc ~S"""
  ```elixir
  iex> as_float!(10.0)
  10.0
  iex> as_float!("10.0")
  10.0
  iex> as_float!("tardis", 9.0)
  9.0
  ```
  """
  def as_float!(arg, default \\ 0.0) do
    case as_float(arg) do
      {:ok, num} -> num
      {:error, _} -> default
    end
  end

  @doc """
  Iterate a map and merge string & atom keys into just strings.

  Not recursive, only top level.

  Behavior with mixed keys being merged is not guaranteed, as maps are not always
  ordered.

  ## Examples

      iex> string_keys(%{tardis: 1, is: 2, color: "blue"})
      %{"tardis" => 1, "is" => 2, "color" => "blue"}

  """
  def string_keys(map) do
    for {key, val} <- map, into: %{} do
      if is_atom(key),
        do: {to_string(key), val},
        else: {key, val}
    end
  end

  @doc """
  Polymorphic conversion of data into an atom.

  If using, make sure inputs being called are not using user-submitted data, or
  be vulnerable to exhausting the atoms table.

  ```elixir
  iex> as_atom("long ugly thing prolly")
  :"long ugly thing prolly"
  iex> as_atom("as_atom")
  :as_atom
  iex> as_atom(:atom)
  :atom
  iex> as_atom(["as_atom", "another"])
  [:as_atom, :another]
  iex> as_atom({:fugly})
  :"{:fugly}"
  ```
  """
  def as_atom(str) when is_binary(str) do
    String.to_atom(str)
  end

  def as_atom(list) when is_list(list) do
    Enum.map(list, fn key -> as_atom(key) end)
  end

  def as_atom(key) when is_atom(key), do: key

  def as_atom(other) do
    any_to_string(other) |> String.to_atom()
  end

  ################################################################################
  @doc """
  Lowercase, Snakecase atom, from string or atom

  ```elixir
  iex> clean_atom(:value)
  :value
  iex> clean_atom(:Value)
  :value
  iex> clean_atom("valueCamelToSnake")
  :value_camel_to_snake
  iex> clean_atom(:"value-dashed")
  :value_dashed
  iex> clean_atom("Value-Dashed")
  :value_dashed
  iex> clean_atom({:narf})
  {:narf}
  ```
  """
  def clean_atom(value) when is_atom(value) do
    clean_atom(Atom.to_string(value))
  end

  def clean_atom(value) when is_binary(value) do
    value
    |> Transmogrify.snakecase()
    |> as_atom
  end

  def clean_atom(value), do: value

  ################################################################################

  @doc """

  iex> map_to_kvstr(%{a: "b", c: "10", d: "longer with space"})
  "c=10 a=b d=\\"longer with space\\""
  """
  def map_to_kvstr(map) do
    Enum.map_join(map, " ", fn {k, v} ->
      [any_to_string(k), "=", any_to_string(v)]
    end)
  end

  defp json_safe_string(str) when is_binary(str) do
    if String.contains?(str, " ") or String.contains?(str, "\"") do
      "#{inspect(str)}"
    else
      to_string(str)
    end
  end

  # TODO:  how much of this is still needed today?
  defp any_to_string(pid) when is_pid(pid) do
    :erlang.pid_to_list(pid)
    |> to_string()
    |> json_safe_string
  end

  defp any_to_string(ref) when is_reference(ref) do
    '#Ref' ++ rest = :erlang.ref_to_list(ref)

    to_string(rest)
    |> json_safe_string
  end

  defp any_to_string(str) when is_binary(str) do
    json_safe_string(str)
  end

  defp any_to_string(atom) when is_atom(atom) do
    case Atom.to_string(atom) do
      "Elixir." <> rest -> rest
      "nil" -> ""
      binary -> binary
    end
    |> json_safe_string
  end

  defp any_to_string(other) do
    any_to_string(Kernel.inspect(other))
  end

  @doc ~S"""
  Remove any keys not in allowed_keys list
  iex> strip_keys_not_in(%{"this" => 1, "that" => 2}, ["this"])
  %{"this" => 1}
  """
  def strip_keys_not_in(dict, allowed_keys) when is_map(dict) and is_list(allowed_keys) do
    Enum.reduce(Map.keys(dict) -- allowed_keys, dict, fn badkey, acc ->
      Map.delete(acc, badkey)
    end)
  end

  @doc ~S"""
  iex> strip_values_not_is(%{"a" => false, "b" => "not bool"}, &is_boolean/1)
  %{"a" => false}
  """
  def strip_values_not_is(dict, type_test) when is_map(dict) and is_function(type_test) do
    Enum.reduce(Map.keys(dict), dict, fn key, acc ->
      if type_test.(Map.get(acc, key)) do
        acc
      else
        Map.delete(acc, key)
      end
    end)
  end

  @doc ~S"""
  iex> strip_subdict_values_not(%{"sub" => %{"a" => false, "b" => "not bool"}}, "sub", &is_boolean/1)
  %{"sub" => %{"a" => false}}
  """
  def strip_subdict_values_not(dict, key, type_test) do
    Map.put(dict, key, strip_values_not_is(Map.get(dict, key, %{}), type_test))
  end
end