lib/github/auth/cache.ex

defmodule GitHub.Auth.Cache do
  @moduledoc """
  Optional caching process for GitHub App and Installation tokens

  This cache uses a local ETS table to store tokens, so they are not shared across a cluster.
  However, this is generally okay, as multiple tokens can be used for the same GitHub App or
  Installation at the same time.

  Assuming the expiration time given to this cache is correct, it will automatically handle
  invalidating tokens both periodically and on-demand.

  ## Usage

  To enable caching, include this module in your application's top-level supervisor:

      # application.ex
      defmodule MyApp.Application do
        use Application

        def start(_type, _args) do
          children = [
            # ...
            GitHub.Auth.Cache
          ]

          opts = [strategy: :one_for_one, name: MyApp.Supervisor]
          Supervisor.start_link(children, opts)
        end
      end
  """
  use GenServer
  require Logger

  @ets_table __MODULE__
  @remove_expired_cycle_msec 60_000

  #
  # Public API
  #

  @doc false
  def start_link(_) do
    GenServer.start_link(__MODULE__, nil, name: __MODULE__)
  end

  @doc """
  Get a previously stored value if unexpired

  Here the key is presumed to be `{:app, app_id}` or `{:installation, installation_id}`, though
  any key will work. Returns `:error` if the key is not found or if the stored value has expired.
  """
  @spec get(term) :: {:ok, term} | :error
  def get(key) do
    case :ets.whereis(@ets_table) do
      :undefined -> :error
      _else -> get_token(key)
    end
  end

  @doc """
  Set a value in the cache

  Here the key is presumed to be `{:app, app_id}` or `{:installation, installation_id}`, though
  any key will work. The expiration must be a Unix timestamp (seconds since Jan 1, 1970).
  """
  @spec put(term, integer, term) :: :ok
  def put(key, expiration, value) do
    GenServer.call(__MODULE__, {:put, key, expiration, value})
  end

  #
  # Private API
  #

  @doc false
  @impl GenServer
  def init(nil) do
    @ets_table = :ets.new(@ets_table, [{:read_concurrency, true}, :protected, :named_table])
    Process.send_after(self(), :remove_expired, @remove_expired_cycle_msec)
    {:ok, nil}
  end

  @impl GenServer
  def handle_call({:put, key, expiration, value}, _from, _state) do
    put_token(key, expiration, value)
    {:reply, :ok, nil}
  end

  @impl GenServer
  def handle_info(:remove_expired, _state) do
    remove_expired()
    Process.send_after(self(), :remove_expired, @remove_expired_cycle_msec)
    {:noreply, nil}
  end

  #
  # Helpers
  #

  defp get_token(key) do
    case :ets.lookup(@ets_table, key) do
      [{^key, expiration, value}] ->
        if expiration > now() do
          {:ok, value}
        else
          :error
        end

      [] ->
        :error
    end
  end

  defp put_token(key, expiration, value) do
    :ets.insert(@ets_table, {key, expiration, value})
  end

  defp remove_expired do
    :ets.match_delete(@ets_table, {{:_, :"$1", :_}, [{:<, :"$1", now()}], []})
  end

  defp now, do: DateTime.utc_now() |> DateTime.to_unix(:second)
end