Packages

Conditional request (ETag & modified-since) support for Phoenix.

Current section

Files

Jump to
phoenix_etag lib phoenix_etag.ex
Raw

lib/phoenix_etag.ex

defmodule PhoenixETag do
@moduledoc """
Conditional request (ETag & modified-since) support for Phoenix.
## Usage
The library provides a replacement function for `Phoenix.Controller.render/1-4`
called `PhoenixETag.render_if_stale/1-4` accepting exactly the same arguments.
When called the function expects the view to implement an additional callback:
`stale_checks/2` similar to `render/2` that is responsible for returning the
etag value and/or last modified value for the current resource.
Additional helper `PhoenixETag.schema_etag/1` is provided for generating etag
values of of a single or multiple schema structs.
# controller
def show(conn, %{"id" => id}) do
data = MyApp.load_data(id)
PhoenixETag.render_if_stale(conn, :show, data: data)
end
# view
def stale_checks("show." <> _format, %{data: data}) do
[etag: PhoenixETag.schema_etag(data),
last_modified: PhoenixETag.schema_last_modified(data)]
end
Both the `etag` and `last_modified` values are optional. The first one will add
an `etag` header to the response and perform a stale check against the
`if-none-match` header. The second one will add a `last-modified` header to the
response and perform a stale check against the `if-modified-since` header.
If the headers indicate cache is fresh a 304 Not Modified response is triggered,
and rendering of the response is aborted. If headers indicate cache is stale,
render proceeds as normal, except the extra headers are added to the response.
"""
@type schema :: %{__struct__: atom, id: term, updated_at: Calendar.date_time}
@type etag :: String.t
@doc """
Utlity function for generating etag values from schemas.
This function assumes the schema has `id` field of any type and
`updated_at` field of either the `:utc_datetime` or `:naive_datetime` type.
A weak ETag is always produced.
"""
@spec schema_etag(nil | schema | [schema]) :: etag
def schema_etag(nil), do: nil
def schema_etag([]), do: nil
def schema_etag(schema_or_schemas) do
list = Enum.map(List.wrap(schema_or_schemas), fn schema ->
[schema.__struct__, schema.id, NaiveDateTime.to_erl(schema.updated_at)]
end)
binary = :erlang.term_to_binary(list)
"W/ " <> Base.encode16(:crypto.hash(:md5, binary), case: :lower)
end
@doc """
Utility function for obtaining a last modified value form schemas.
This function expects the schema to define a `updated_at` field of either
the `:utc_datetime` or `:naive_datetime` type.
"""
@spec schema_last_modified(nil | schema | [schema]) :: Calendar.date_time
def schema_last_modified(nil), do: nil
def schema_last_modified([]), do: nil
def schema_last_modified(schema_or_schemas) do
schema_or_schemas
|> List.wrap
|> Enum.map(&(&1.updated_at))
|> Enum.max_by(&NaiveDateTime.to_erl/1)
end
@doc """
Render the given template or the default template
specified by the current action with the given assigns.
See `render_if_stale/3` for more information.
"""
@spec render_if_stale(Plug.Conn.t, Keyword.t | map | binary | atom) :: Plug.Conn.t
def render_if_stale(conn, template_or_assigns \\ [])
def render_if_stale(conn, template) when is_binary(template) or is_atom(template) do
render_if_stale(conn, template, %{})
end
def render_if_stale(conn, assigns) when is_map(assigns) or is_list(assigns) do
action = Phoenix.Controller.action_name(conn)
render_if_stale(conn, action, assigns)
end
@doc """
Renders the given `template` and `assigns` based on the `conn` information.
Considers data freshness based on the `if-modified-since` and `if-none-match`
headers and avoids rendering if a `304 Not Modified` response is possible.
Expects the view module to implement an additional callback: `stale_checks/2`.
The function is called with the template name and assigns - exactly like
`render/2`. The callback is expected to return a keyword list with two
possible keys:
* `:etag` - the entity tag for the current resource. Can be generated using
the `schema_etag/1` utility function.
* `:last_modified` - the last modified value for the current resource. Has to
be either a `NaiveDateTime` or a `DateTime`. The value can be obtained with
the `schema_last_modified/1` utility function.
See `Phoenix.Controller.render/3` for more information.
"""
@spec render_if_stale(Plug.Conn.t, binary | atom, Keyword.t | map) :: Plug.Conn.t
def render_if_stale(conn, template, assigns)
when is_atom(template) and (is_list(assigns) or is_map(assigns)) do
format =
Phoenix.Controller.get_format(conn) ||
raise "cannot render template #{inspect template} because conn.params[\"_format\"] is not set. " <>
"Please set `plug :accepts, ~w(html json ...)` in your pipeline."
template = template_name(template, format)
do_render_if_stale(conn, template, assigns)
end
def render_if_stale(conn, template, assigns)
when is_binary(template) and (is_list(assigns) or is_map(assigns)) do
case Path.extname(template) do
"." <> _format ->
# We need to do this check before trying to ask for stale checks,
# otherwise we'll hit FunctionClauseError in view instead of this one
do_render_if_stale(conn, template, assigns)
"" ->
raise "cannot render template #{inspect template} without format. Use an atom if the " <>
"template format is meant to be set dynamically based on the request format"
end
end
def render_if_stale(conn, view, template)
when is_atom(view) and (is_binary(template) or is_atom(template)) do
render_if_stale(conn, view, template, %{})
end
@doc """
A shortcut that renders the given template in the given view.
Equivalent to:
conn
|> put_view(view)
|> render_if_stale(template, assigns)
"""
@spec render_if_stale(Plug.Conn.t, atom, atom | binary, Keyword.t | map) :: Plug.Conn.t
def render_if_stale(conn, view, template, assigns)
when is_atom(view) and (is_binary(template) or is_atom(template)) do
conn
|> Phoenix.Controller.put_view(view)
|> render_if_stale(template, assigns)
end
defp do_render_if_stale(conn, template, assigns) do
view = Phoenix.Controller.view_module(conn) ||
raise "a view module was not specified, set one with put_view/2"
conn
|> prepare_assigns(assigns)
|> if_stale(view, template, &Phoenix.Controller.render(&1, template, &2))
end
defp template_name(name, format) when is_atom(name),
do: Atom.to_string(name) <> "." <> format
defp template_name(name, _format) when is_binary(name),
do: name
defp prepare_assigns(conn, assigns) do
update_in conn.assigns, &Enum.into(assigns, &1)
end
defp if_stale(conn, view, template, fun) do
checks = view.stale_checks(template, conn.assigns)
etag = checks[:etag]
modified = checks[:last_modified]
conn =
conn
|> put_etag(etag)
|> put_last_modified(modified)
if stale?(conn, etag, modified) do
fun.(conn, Map.take(conn.assigns, [:layout]))
else
Plug.Conn.send_resp(conn, 304, "")
end
end
defp put_etag(conn, nil),
do: conn
defp put_etag(conn, etag),
do: Plug.Conn.put_resp_header(conn, "etag", etag)
defp put_last_modified(conn, nil),
do: conn
defp put_last_modified(conn, modified) do
Plug.Conn.put_resp_header(conn, "last-modified", format_date(modified))
end
defp stale?(conn, etag, modified) do
modified_since = List.first Plug.Conn.get_req_header(conn, "if-modified-since")
none_match = List.first Plug.Conn.get_req_header(conn, "if-none-match")
if get_or_head?(conn) and (modified_since || none_match) do
modified_since?(modified_since, modified) or none_match?(none_match, etag)
else
true
end
end
defp get_or_head?(%{method: method}), do: method in ["GET", "HEAD"]
defp modified_since?(header, last_modified) do
if header && last_modified do
modified_since = parse_date(header)
last_modified = to_unix(last_modified)
last_modified > modified_since
else
false
end
end
defp none_match?(none_match, etag) do
if none_match && etag do
none_match = Plug.Conn.Utils.list(none_match)
not(etag in none_match) and not("*" in none_match)
else
false
end
end
defp to_unix(%DateTime{} = dt), do: DateTime.to_unix(dt)
defp to_unix(naive), do: to_unix(DateTime.from_naive!(naive, "Etc/UTC"))
defp format_date(datetime) do
datetime
|> NaiveDateTime.to_erl
|> :phoenix_etag_date.rfc1123
end
defp parse_date(string) do
string
|> :phoenix_etag_date.parse_date
|> NaiveDateTime.from_erl!
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_unix
end
end