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