lib/mojito/headers.ex

defmodule Mojito.Headers do
  @moduledoc ~S"""
  Functions for working with HTTP request and response headers, as described
  in the [HTTP 1.1 specification](https://www.w3.org/Protocols/rfc2616/rfc2616.html).

  Headers are represented in Elixir as a list of `{"header_name", "value"}`
  tuples.  Multiple entries for the same header name are allowed.

  Capitalization of header names is preserved during insertion,
  however header names are handled case-insensitively during
  lookup and deletion.
  """

  @type headers :: Mojito.headers()

  @doc ~S"""
  Returns the value for the given HTTP request or response header,
  or `nil` if not found.

  Header names are matched case-insensitively.

  If more than one matching header is found, the values are joined with
  `","` as specified in [RFC 2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2).

  Example:

      iex> headers = [
      ...>   {"header1", "foo"},
      ...>   {"header2", "bar"},
      ...>   {"Header1", "baz"}
      ...> ]
      iex> Mojito.Headers.get(headers, "header2")
      "bar"
      iex> Mojito.Headers.get(headers, "HEADER1")
      "foo,baz"
      iex> Mojito.Headers.get(headers, "header3")
      nil
  """
  @spec get(headers, String.t()) :: String.t() | nil
  def get(headers, name) do
    case get_values(headers, name) do
      [] -> nil
      values -> values |> Enum.join(",")
    end
  end

  @doc ~S"""
  Returns all values for the given HTTP request or response header.
  Returns an empty list if none found.

  Header names are matched case-insensitively.

  Example:

      iex> headers = [
      ...>   {"header1", "foo"},
      ...>   {"header2", "bar"},
      ...>   {"Header1", "baz"}
      ...> ]
      iex> Mojito.Headers.get_values(headers, "header2")
      ["bar"]
      iex> Mojito.Headers.get_values(headers, "HEADER1")
      ["foo", "baz"]
      iex> Mojito.Headers.get_values(headers, "header3")
      []
  """
  @spec get_values(headers, String.t()) :: [String.t()]
  def get_values(headers, name) do
    get_values(headers, String.downcase(name), [])
  end

  defp get_values([], _name, values), do: values

  defp get_values([{key, value} | rest], name, values) do
    new_values =
      if String.downcase(key) == name do
        values ++ [value]
      else
        values
      end

    get_values(rest, name, new_values)
  end

  @doc ~S"""
  Puts the given header `value` under `name`, removing any values previously
  stored under `name`.  The new header is placed at the end of the list.

  Header names are matched case-insensitively, but case of `name` is preserved
  when adding the header.

  Example:

      iex> headers = [
      ...>   {"header1", "foo"},
      ...>   {"header2", "bar"},
      ...>   {"Header1", "baz"}
      ...> ]
      iex> Mojito.Headers.put(headers, "HEADER1", "quux")
      [{"header2", "bar"}, {"HEADER1", "quux"}]
  """
  @spec put(headers, String.t(), String.t()) :: headers
  def put(headers, name, value) do
    delete(headers, name) ++ [{name, value}]
  end

  @doc ~S"""
  Removes all instances of the given header.

  Header names are matched case-insensitively.

  Example:

      iex> headers = [
      ...>   {"header1", "foo"},
      ...>   {"header2", "bar"},
      ...>   {"Header1", "baz"}
      ...> ]
      iex> Mojito.Headers.delete(headers, "HEADER1")
      [{"header2", "bar"}]
  """
  @spec delete(headers, String.t()) :: headers
  def delete(headers, name) do
    name = String.downcase(name)
    Enum.filter(headers, fn {key, _value} -> String.downcase(key) != name end)
  end

  @doc ~S"""
  Returns an ordered list of the header names from the given headers.
  Header names are returned in lowercase.

  Example:

      iex> headers = [
      ...>   {"header1", "foo"},
      ...>   {"header2", "bar"},
      ...>   {"Header1", "baz"}
      ...> ]
      iex> Mojito.Headers.keys(headers)
      ["header1", "header2"]
  """
  @spec keys(headers) :: [String.t()]
  def keys(headers) do
    keys(headers, [])
  end

  defp keys([], names), do: Enum.reverse(names)

  defp keys([{name, _value} | rest], names) do
    name = String.downcase(name)

    if name in names do
      keys(rest, names)
    else
      keys(rest, [name | names])
    end
  end

  @doc ~S"""
  Returns a copy of the given headers where all header names are lowercased
  and multiple values for the same header have been joined with `","`.

  Example:

      iex> headers = [
      ...>   {"header1", "foo"},
      ...>   {"header2", "bar"},
      ...>   {"Header1", "baz"}
      ...> ]
      iex> Mojito.Headers.normalize(headers)
      [{"header1", "foo,baz"}, {"header2", "bar"}]
  """
  @spec normalize(headers) :: headers
  def normalize(headers) do
    headers_map =
      Enum.reduce(headers, %{}, fn {name, value}, acc ->
        name = String.downcase(name)
        values = Map.get(acc, name, [])
        Map.put(acc, name, values ++ [value])
      end)

    headers
    |> keys
    |> Enum.map(fn name ->
      {name, Map.get(headers_map, name) |> Enum.join(",")}
    end)
  end

  @doc ~S"""
  Returns an HTTP Basic Auth header from the given username and password.

  Example:

      iex> Mojito.Headers.auth_header("hello", "world")
      {"authorization", "Basic aGVsbG86d29ybGQ="}
  """
  @spec auth_header(String.t(), String.t()) :: Mojito.header()
  def auth_header(username, password) do
    auth64 = "#{username}:#{password}" |> Base.encode64()
    {"authorization", "Basic #{auth64}"}
  end

  @doc ~S"""
  Convert non string values to string where is possible.

  Example:

      iex> Mojito.Headers.convert_values_to_string([{"content-length", 0}])
      [{"content-length", "0"}]
  """
  @spec convert_values_to_string(headers) :: headers
  def convert_values_to_string(headers) do
    convert_values_to_string(headers, [])
  end

  defp convert_values_to_string([], converted_headers),
    do: Enum.reverse(converted_headers)

  defp convert_values_to_string([{name, value} | rest], converted_headers)
       when is_number(value) or is_atom(value) do
    convert_values_to_string(rest, [
      {name, to_string(value)} | converted_headers
    ])
  end

  defp convert_values_to_string([headers | rest], converted_headers) do
    convert_values_to_string(rest, [headers | converted_headers])
  end
end