lib/cookie_monster.ex

defmodule CookieMonster do
  @moduledoc """
  A simple HTTP Cookie encoder and decoder written in pure Elixir with zero
  dependencies.

  Follows the standards for cookie headers described in MDN:
  https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies
  """

  use Boundary, deps: [Logger], exports: [Cookie]

  alias CookieMonster.Cookie
  alias CookieMonster.Decoder
  alias CookieMonster.Encoder

  @doc """
  Encodes a cookie struct into a header string

  ## Arguments
  - `cookie`: a string representation of a cookie
  - `opts`: an optional keyword list containing
    - `target: :request` - build a cookie for use in a request (containing only
       name and value pair)

  ## Returns
  When successful, an `{:ok, cookie}` tuple where `cookie` is a string representation of the
  cookie. Dates are formatted using the [RFC 1123 spec](https://www.rfc-editor.org/rfc/rfc2616#section-3.3.1).

  Otherwise, returns an `{:error, atom}`, consult the types for more info.

  ## Examples

      iex> cookie = %CookieMonster.Cookie{
      ...>   name: "id",
      ...>   value: "a3fWa",
      ...>   expires: ~U[2015-10-21 07:28:00Z],
      ...>   http_only: true,
      ...>   secure: true
      ...> }
      iex> CookieMonster.encode(cookie)
      {:ok, "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; HttpOnly; Secure"}


      iex> cookie = %CookieMonster.Cookie{
      ...>   name: "id",
      ...>   value: "a3fWa",
      ...>   expires: ~U[2015-10-21 07:28:00Z],
      ...>   http_only: true,
      ...>   secure: true
      ...> }
      iex> CookieMonster.encode(cookie, target: :request)
      {:ok, "id=a3fWa"}
  """
  @spec encode(Cookie.t() | map(), keyword()) :: Encoder.return_t()
  defdelegate encode(cookie, opts \\ []), to: Encoder

  @doc """
  Similar to `encode/2`, but an `ArgumentError` exception is raised
  if the cookie cannot be encoded.
  """
  @spec encode!(Cookie.t(), keyword()) :: String.t()
  def encode!(cookie, opts \\ []) do
    case encode(cookie, opts) do
      {:ok, encoded_cookie} -> encoded_cookie
      {:error, error} -> raise ArgumentError, Atom.to_string(error)
    end
  end

  @doc """
  Decodes a Set-Cookie header text into an Elixir struct

  Supports all valid expires date formats in the following spec standards:
  - RFC 1123
  - RFC 850 (despite being deprecated it is still considered valid)
    - This format does pose some Y2K concerns due to the use of a 2-digit year
      representation
  - ANSI C `asctime()` format

  For more info see: https://www.rfc-editor.org/rfc/rfc2616#section-3.3.1
      
  ## Returns
  When successful, an `{:ok, cookie}` tuple where `cookie` is a struct representation of the
  cookie string.

  Otherwise, returns an `{:error, atom}`, consult the types for more info.

  ## Examples

      iex> header = "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly"
      ...> {:ok, cookie} = CookieMonster.decode(header)
      ...> cookie
      %CookieMonster.Cookie{
        value: "a3fWa",
        expires: ~U[2015-10-21 07:28:00Z],
        http_only: true,
        name: "id",
        secure: true
      }
  """
  @spec decode(String.t()) :: Decoder.return_t()
  defdelegate decode(cookie_string), to: Decoder

  @doc """
  Decodes a Set-Cookie Header into an Elixir struct

  Similar to `decode/1` except it will unwrap the error tuple and raise an
  `ArgumentError` in case of errors.
  """
  @spec decode!(String.t()) :: Cookie.t()
  def decode!(cookie) do
    case decode(cookie) do
      {:ok, decoded_cookie} -> decoded_cookie
      {:error, error} -> raise ArgumentError, Atom.to_string(error)
    end
  end
end