logo

cmdarek

17-08-2021

Cache API token in Elixir

Probably most of the APIs that you call are protected somehow. The most common way to do that is to provide token with expiry date. We are not going to explore how to manage tokens on API side but we are going to deal with them on client side. If you want to read more about how to generate tokens please read: Phoenix.Token.

In our example we are going to have two endpoint:

  • /endpoint/token
  • /endpoint/protected_data

First one is going to provide us token in following format:

                    
{
    access_token: "eyJhbG", 
    expires_in: 100
}
                    
                    

We are going to store this information in AccessToken structure. This module is also responsible for fetching token from API and validating it. We assume that token is valid if it expires before hour from now.

                    
defmodule ApiClient.AccessToken do
  @enforce_keys [:token, :expires_at]
  defstruct token: nil, expires_at: nil

  alias ApiClient.HttpClient

  @token_url "token"

  def get do
    case HttpClient.get(@token_url) do
      :error ->
        :error

      result ->
        new(result)
    end
  end

  def valid?(%__MODULE__{} = access_token) do
    DateTime.compare(DateTime.add(access_token.expires_at, -60), DateTime.utc_now()) == :gt
  end

  defp new(response) do
    token = response["access_token"]
    expires_at = DateTime.add(DateTime.utc_now(), response["expires_in"])

    %__MODULE__{token: token, expires_at: expires_at}
  end
end
                    
                    

To store AccessToken we use GenServer. Implementation is quite simple. We initiate AccessTokenCache with nil as value for cached token. On first call we just call AccessToken to get token and in succeeding calls we check if token is still valid. If yes we return it and otherwise we try to get new one and update cache.

                    
defmodule ApiClient.AccessTokenCache do
  use GenServer

  alias ApiClient.AccessToken

  def start_link(opts) do
    GenServer.start_link(__MODULE__, :ok, opts)
  end

  def get() do
    GenServer.call(__MODULE__, :get)
  end

  @impl true
  def init(:ok) do
    {:ok, nil}
  end

  @impl true
  def handle_call(:get, _from, nil) do
    get_access_token()
  end

  def handle_call(:get, _from, %AccessToken{} = access_token) do
    if AccessToken.valid?(access_token) do
      {:reply, access_token, access_token}
    else
      get_access_token()
    end
  end

  defp get_access_token() do
    case AccessToken.get() do
      :error ->
        {:reply, :error, nil}

      access_token ->
        {:reply, access_token, access_token}
    end
  end
end
                    
                    

To get data from second endpoint we first have to call for token using AccessTokenCache. Then we have to use token in header to authorize call.

                    
defmodule ApiClient do
  alias ApiClient.AccessTokenCache
  alias ApiClient.HttpClient

  def get_data do
    access_token = AccessTokenCache.get()

    HttpClient.get("protected_data", access_token.token)
  end
end
                    
                    

To test this functionality we are going to use Bypass, which is excellent tool to create a custom plug that can be put in place instead of an actual HTTP server to return prebaked responses to client requests.

                    
defmodule ApiClientTest do
  use ExUnit.Case

  alias ApiClient.AccessTokenCache

  @access_token "{\"access_token\":\"eyJhbG\", \"expires_in\":100}"
  @protected_data "{\"data\": \"secret\"}"

  setup do
    bypass = Bypass.open(port: 50_123)

    {:ok, _pid} = start_supervised({AccessTokenCache, [name: AccessTokenCache]})

    {:ok, bypass: bypass}
  end

  test "get_data", %{bypass: bypass} do
    Bypass.expect(bypass, "GET", "/token", fn conn ->
      Plug.Conn.resp(conn, 200, @access_token)
    end)

    Bypass.expect(bypass, "GET", "/protected_data", fn conn ->
      assert Enum.member?(conn.req_headers, {"authorization", "Bearer eyJhbG"}) == true

      Plug.Conn.resp(conn, 200, @protected_data)
    end)

    assert %{"data" => "secret"} == ApiClient.get_data()
  end
end
                    
                    

You can view full code at: https://github.com/elpikel/api_client