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