lib/frugality/generator.ex

defmodule Frugality.Generator do
  @moduledoc """
  A behaviour for producing HTTP document metadata.

  Defines two callbacks - `c:Frugality.Generator.etag/1` and
  `c:Frugality.Generator.last_modified/1`.

  Request preconditions are evaluated agains at least one of two
  pieces of metadata - an entity-tag and a last-modified timestamp.
  """

  alias Frugality.Core.Metadata
  alias Frugality.Encoder

  @type t() :: module()
  @type etag() ::
          {:weak | :strong, binary()}
          | {:source, iolist()}
          | {:source, iolist(), Access.t()}
  @type datetime() :: DateTime.t() | NaiveDateTime.t()

  @callback etag(map()) :: etag() | nil
  @callback last_modified(map()) :: datetime() | nil

  @doc false
  @spec generate(module(), map(), Access.container()) :: Metadata.t() | nil
  def generate(impl, data, opts \\ []) when is_atom(impl) and is_map(data) do
    encoder = Access.get(opts, :encoder)

    etag =
      data
      |> impl.etag()
      |> resolve_etag(encoder)

    last_modified = impl.last_modified(data)

    if etag || last_modified do
      Metadata.new(etag, last_modified)
    end
  end

  @spec resolve_etag(etag() | nil, module() | nil) :: EntityTag.t() | nil
  defp resolve_etag(nil, _), do: nil

  defp resolve_etag({:source, iodata}, encoder),
    do: resolve_etag({:source, iodata, []}, encoder)

  defp resolve_etag({_, _} = etag, _), do: etag

  defp resolve_etag({:source, iodata, opts}, encoder) do
    # The encoder specified in the `c:Frugality.Encoder.etag/1`
    # callback has a higher precedence than the one stored in the
    # connection.
    encoder =
      Access.get(opts, :encoder, encoder) ||
        raise "no encoder provided"

    binary = Encoder.encode(encoder, iodata)

    {:weak, binary}
  end
end