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