lib/frugality.ex

defmodule Frugality do
  alias Frugality.Core.EntityTagRange
  alias Frugality.Core.Metadata
  alias Frugality.Core.Utils
  alias Frugality.Encoder
  alias Frugality.Generator
  alias Plug.Conn

  @headers [
    "if-match",
    "if-unmodified-since",
    "if-none-match",
    "if-modified-since"
  ]

  defguardp is_safe(method) when method in ["GET", "HEAD"]

  @doc """
  Derives metadata and puts it in the response headers.

  ## Examples

      iex> conn = put_metadata(conn, order: order)
      iex> Plug.Conn.get_resp_header(conn, "etag")
      "W/\\"asd123\\""

  """
  @spec put_metadata(Conn.t(), map() | list()) :: Conn.t() | no_return()
  def put_metadata(conn, data) do
    conn
    |> derive_metadata(data)
    |> then(&Utils.put_metadata(conn, &1))
  end

  @doc """
  Stores the [metadata generator](`Frugality.Generator`) in the
  connection.

  ## Examples

  Can be invoked as a function:

      # Let's imagine you have an HTTP document with orders
      iex> put_generator(conn, OrderMetadata)

  or used as a plug:

      plug :put_generator, OrderMetadata

  """
  @spec put_generator(Conn.t(), Generator.t()) :: Conn.t()
  def put_generator(conn, generator) when is_atom(generator) do
    Conn.put_private(conn, :frugality_generator, generator)
  end

  @doc """
  Stores the [entity-tag encoder](`Frugality.Encoder`) in the
  connection.

  ## Examples

  Can be invoked as a function:

      iex> put_encoder(conn, Frugality.Encoder.MD5)

  or used as a plug:

      plug :put_encoder, Frugality.Encoder.MD5

  """
  @spec put_encoder(Conn.t(), Encoder.t()) :: Conn.t()
  def put_encoder(conn, encoder) when is_atom(encoder) do
    Conn.put_private(conn, :frugality_encoder, encoder)
  end

  @doc """
  Short-circuits the connection when the preconditions are satisfied.

  To short-circuit the connection means to send it and halt the Plug
  pipeline.

  ## Examples

      iex> short_circuit(conn, order: order)

  """
  @spec short_circuit(Conn.t(), map() | list(), (Conn.t() -> Conn.t())) ::
          Conn.t()
  def short_circuit(conn, data, cb \\ fn conn -> conn end) do
    case evaluate_preconditions(conn, data) do
      {:ok, conn} ->
        cb.(conn)

      {:error, conn} ->
        conn
        |> Conn.send_resp()
        |> Conn.halt()
    end
  end

  @doc """
  Evaluates the request preconditions against user-provided data.

  Returns `{:ok, t:Plug.Conn.t/0}` if the preconditions are satisfied
  and the action execution can continue, or `{:error,
  t:Plug.Conn.t/0}` otherwise.

  ## Examples

      iex> evaluate_preconditions(conn, etag: ~s(W/"asd123"))
      {:ok, %Plug.Conn{...}}

      iex> evaluate_preconditions(conn, order: order)
      {:error, %Plug.Conn{status: 412, ...}}

  """
  @spec evaluate_preconditions(Conn.t(), map() | list()) :: {:ok | :error, Conn.t()}
  def evaluate_preconditions(%Conn{method: method} = conn, data)
      when is_map(data) or is_list(data) do
    metadata = derive_metadata(conn, data)

    conn =
      if is_safe(method) do
        Utils.put_metadata(conn, metadata)
      else
        conn
      end

    @headers
    |> Stream.map(&pair_with_values(conn, &1))
    |> Stream.reject(&empty_values?/1)
    |> Stream.transform([], &drop_by_precedence/2)
    |> Stream.map(&parse_values/1)
    |> Stream.map(&evaluate(&1, metadata))
    |> Enum.reduce_while({:ok, conn}, &reducer/2)
  end

  defp to_map(data) when is_map(data), do: data
  defp to_map(data) when is_list(data), do: :maps.from_list(data)

  defp pair_with_values(conn, header) do
    {header, Conn.get_req_header(conn, header)}
  end

  defp empty_values?({_, []}), do: true
  defp empty_values?({_, _}), do: false

  defp drop_by_precedence({"if-unmodified-since", _}, [_] = acc), do: {[], acc}

  defp drop_by_precedence({"if-modified-since", _}, [{"if-none-match", _} | _] = acc),
    do: {[], acc}

  defp drop_by_precedence(precond, acc), do: {[precond], [precond | acc]}

  defp reducer({"if-none-match", false}, {_, %{method: method} = conn}) when is_safe(method),
    do: {:halt, {:error, Utils.not_modified(conn)}}

  defp reducer({"if-modified-since", false}, {_, %{method: method} = conn}) when is_safe(method),
    do: {:halt, {:error, Utils.not_modified(conn)}}

  defp reducer({"if-modified-since", _}, acc), do: {:cont, acc}

  defp reducer({_, false}, {_, conn}), do: {:halt, {:error, Utils.precondition_failed(conn)}}

  defp reducer(_, acc), do: {:cont, acc}

  defp parse_values({header, values})
       when header in ["if-none-match", "if-match"] do
    {header, Utils.parse_entity_tag_ranges(values)}
  end

  defp parse_values({header, values})
       when header in ["if-modified-since", "if-unmodified-since"] do
    datetime =
      values
      |> Enum.map(&Utils.parse_httpdate/1)
      |> Enum.max_by(fn d -> d end, DateTime)

    {header, datetime}
  end

  defp evaluate({"if-match" = h, _}, %Metadata{etag: nil}), do: {h, false}

  defp evaluate({"if-match" = h, range}, %Metadata{etag: etag}),
    do: {h, EntityTagRange.matches_strong?(range, etag)}

  defp evaluate({"if-none-match" = h, _}, %Metadata{etag: nil}), do: {h, true}

  defp evaluate({"if-none-match" = h, range}, %Metadata{etag: etag}) do
    {h, !EntityTagRange.matches_weak?(range, etag)}
  end

  defp evaluate({"if-unmodified-since" = h, _}, %Metadata{last_modified: nil}), do: {h, false}

  defp evaluate({"if-unmodified-since" = h, iums}, %Metadata{last_modified: lm}),
    do: {h, DateTime.compare(lm, iums) in [:lt, :eq]}

  defp evaluate({"if-modified-since" = h, _}, %Metadata{last_modified: nil}), do: {h, false}

  defp evaluate({"if-modified-since" = h, ims}, %Metadata{last_modified: lm}),
    do: {h, DateTime.compare(lm, ims) == :gt}

  defp generator(conn) do
    Map.get(conn.private, :frugality_generator)
  end

  defp encoder(conn) do
    Map.get(conn.private, :frugality_encoder)
  end

  defp derive_metadata(conn, data) do
    generator = generator(conn) || raise "no generator is set"
    encoder = encoder(conn)

    data =
      data
      |> to_map()
      |> Map.put(:conn, conn)

    Generator.generate(
      generator,
      data,
      encoder: encoder
    )
  end
end