who_need_help/lib/who_need_help/pagination.ex

73 lines
1.9 KiB
Elixir

defmodule WhoNeedHelp.Pagination do
@moduledoc """
Small keyset-pagination primitives shared by database-backed lists.
Cursors contain only the final row's UTC timestamp and UUID. They are
validated before being used as query parameters and carry no authorization
decision.
"""
@default_limit 24
@maximum_limit 100
defmodule Page do
@moduledoc false
defstruct entries: [], next_cursor: nil
end
def limit(options, default \\ @default_limit) do
options
|> Keyword.get(:limit, default)
|> normalize_limit(default)
end
def cursor(options) do
case decode(Keyword.get(options, :after)) do
{:ok, cursor} -> cursor
:error -> nil
end
end
def page(rows, limit, cursor_fields) when is_function(cursor_fields, 1) do
entries = Enum.take(rows, limit)
next_cursor =
if length(rows) > limit do
entries
|> List.last()
|> cursor_fields.()
|> then(fn {timestamp, id} -> encode(timestamp, id) end)
end
%Page{entries: entries, next_cursor: next_cursor}
end
def encode(%DateTime{} = timestamp, id) do
%{"at" => DateTime.to_iso8601(timestamp), "id" => to_string(id)}
|> Jason.encode!()
|> Base.url_encode64(padding: false)
end
def decode(nil), do: {:ok, nil}
def decode(""), do: {:ok, nil}
def decode(encoded) when is_binary(encoded) do
with {:ok, json} <- Base.url_decode64(encoded, padding: false),
{:ok, %{"at" => timestamp, "id" => id}} <- Jason.decode(json),
{:ok, timestamp, 0} <- DateTime.from_iso8601(timestamp),
{:ok, id} <- Ecto.UUID.cast(id) do
{:ok, {timestamp, id}}
else
_ -> :error
end
end
def decode(_encoded), do: :error
defp normalize_limit(limit, _default)
when is_integer(limit) and limit > 0 and limit <= @maximum_limit,
do: limit
defp normalize_limit(_limit, default), do: min(default, @maximum_limit)
end