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