lib/gluttony/handler.ex

defmodule Gluttony.Handler do
  @moduledoc """
  This module defines the behaviour for handlers.

  Both `handle_element/2`, `handle_content/2` and `handle_cached/2` should return on of the following result tuples:

  - `{:entry, chars_or_attrs}` - To indicate that a new entry should be created.
  - `{:entry, :key, value}` - To indicate that a entry should be updated with the given key and value.
  - `{:feed, :key, value}` - To indicate that the feed should be updated with the given key and value.
  - `{:cache, :key}` - To indicate that the given key should be cached.
    Because successive calls with the same key will clean the previous value, this should only be called on `handle_element`.
    This will guarantee that the cache will live through the tag lifecycle, instead of being cleaned on whenever new content is found.
  - `{:cache, :key, value}` - To indicate that the given key should be cached with the given value.
  - `{:cont, chars_or_attrs}` - To indicate that the current element should be ignored.

  If the value is a list, it will be appended to the existing value. Otherwise, it will replace the current value.
  Its also possible to pass a list as the path to create a nested structure (all intermidiate values will be created).
  """

  import Gluttony.Helpers

  @type attrs :: list({binary(), term()})
  @type stack :: list(binary())
  @type chars :: binary() | list(binary())

  @type path :: atom() | list(atom())

  @type result ::
          {:entry, term()}
          | {:entry, path(), term()}
          | {:feed, path(), term()}
          | {:cache, atom()}
          | {:cache, path(), term()}
          | {:cont, term()}

  @doc """
  This callback is called when a start element is encountered.
  """
  @callback handle_element(attrs :: attrs(), stack :: stack()) :: result()

  @doc """
  This callback is called when a content of a element is encountered.
  """
  @callback handle_content(chars :: chars(), stack :: stack()) :: result()

  @doc """
  This callback is called when and element is about to finish.
  """
  @callback handle_cached(cached :: term, stack :: stack()) :: result()

  @doc false
  def handle_element(impl, attrs, %{stack: stack} = state) do
    attrs
    |> impl.handle_element(stack)
    |> handle_result(state)
  end

  @doc false
  def handle_content(impl, chars, %{stack: stack} = state) do
    chars
    |> impl.handle_content(stack)
    |> handle_result(state)
  end

  @doc false
  def handle_cached(_impl, nil, state), do: state

  def handle_cached(impl, cached, %{stack: stack} = state) do
    cached
    |> impl.handle_cached(stack)
    |> handle_result(state)
  end

  defp handle_result(result, %{entries: entries} = state) do
    case result do
      {:feed, keys, value} when is_list(keys) ->
        update_feed(state, keys, value)

      {:feed, key, value} ->
        update_feed(state, [key], value)

      {:entry, _chars_or_attrs} ->
        %{state | entries: [%{} | entries]}

      {:entry, keys, value} when is_list(keys) ->
        update_entry(state, keys, value)

      {:entry, key, value} ->
        update_entry(state, [key], value)

      {:cache, key} ->
        update_cache(state, [key])

      {:cache, keys, value} when is_list(keys) ->
        update_cache(state, keys, value)

      {:cache, key, value} ->
        update_cache(state, [key], value)

      {:cont, _chars_or_attrs} ->
        state
    end
  end

  defp update_feed(%{feed: feed} = state, keys, value) do
    feed = place_in(feed, keys, value)
    %{state | feed: feed}
  end

  defp update_entry(%{entries: entries} = state, keys, value) do
    {entry, entries} = pop_current_entry(entries)
    entry = place_in(entry, keys, value)
    %{state | entries: [entry | entries]}
  end

  defp update_cache(%{cache: cache} = state, [key | keys], value \\ %{}) do
    # Converts the first key (actual cache key) to a string.
    # This allows us to use the cache key as a string to match the Saxy event.
    # Because of this, we don't have to convert the tag name to atom to compare.
    keys = [to_string(key) | keys]
    cache = place_in(cache, keys, value)
    %{state | cache: cache}
  end

  # Get current entry (latest created) or create a new one.
  defp pop_current_entry([]), do: {nil, []}
  defp pop_current_entry([entry | entries]), do: {entry, entries}
end