defmodule Goth do
@external_resource "README.md"
@moduledoc """
A Goth token server.
"""
use GenServer
require Logger
alias Goth.Backoff
alias Goth.Token
@registry Goth.Registry
@max_retries 20
@default_refresh_after 3_300_000
@doc """
Starts the server.
When the server is started, we attempt to fetch the token and store it in
internal cache. If we fail, we'll retry with backoff.
## Options
* `:name` - a unique name to register the server under. It can be any term.
* `:source` - the source to retrieve the token from.
See documentation for the `:source` option in `Goth.Token.fetch/1` for
more information.
* `:refresh_after` - Time in milliseconds after which the token will be automatically
refreshed. Defaults to `3_300_000` (55 minutes; 5 minutes before the token, which
is valid for 1h, expires)
* `:http_client` - a function that makes the HTTP request. Defaults to using built-in
integration with [Hackney](https://github.com/benoitc/hackney)
See documentation for the `:http_client` option in `Goth.Token.fetch/1` for
more information.
* `:http_client` - a funtion that makes the HTTP request. Can be one of the following:
* `fun` - same as `{fun, []}`
* `{fun, opts}` - `fun` must be a 1-arity funtion that receives a keyword list with fields
`:method`, `:url`, `:headers`, and `:body` along with any passed `opts`. The funtion must return
`{:ok, %{status: status, headers: headers, body: body}}` or `{:error, exception}`.
Example: `{&HTTPClient.request/1, connect_timeout: 5000}`.
`fun` can also be an atom `:hackney` to use the built-in [Hackney](http://github.com/benoitc/hackney)-based
client.
Defaults to `{:hackney, []}`
* `:prefetch` - how to prefetch the token when the server starts. The possible options
are `:async` to do it asynchronously or `:sync` to do it synchronously
(that is, the server doesn't start until an attempt to fetch the token was made). Defaults
to `:async`.
* `:max_retries` - the maximum number of retries (default: `20`)
* `:backoff_min` - the minimum backoff interval (default: `1_000`)
* `:backoff_max` - the maximum backoff interval (default: `30_000`)
* `:backoff_type` - the backoff strategy, `:exp` for exponential, `:rand` for random and
`:rand_exp` for random exponential (default: `:rand_exp`)
## Examples
Generate a token using a service account credentials file:
iex> credentials = "credentials.json" |> File.read!() |> Jason.decode!()
iex> {:ok, _} = Goth.start_link(name: MyApp.Goth, source: {:service_account, credentials, []})
iex> Goth.fetch!(MyApp.Goth)
%Goth.Token{...}
Retrieve the token using a refresh token:
iex> credentials = "credentials.json" |> File.read!() |> Jason.decode!()
iex> {:ok, _} = Goth.start_link(name: MyApp.Goth, source: {:refresh_token, credentials, []})
iex> Goth.fetch!(MyApp.Goth)
%Goth.Token{...}
Retrieve the token using the Google metadata server:
iex> {:ok, _} = Goth.start_link(name: MyApp.Goth, source: {:metadata, []})
iex> Goth.fetch!(MyApp.Goth)
%Goth.Token{...}
"""
@doc since: "1.3.0"
def start_link(opts) do
opts =
opts
|> Keyword.put_new(:refresh_after, @default_refresh_after)
|> Keyword.put_new(:http_client, {:hackney, []})
name = Keyword.fetch!(opts, :name)
GenServer.start_link(__MODULE__, opts, name: registry_name(name))
end
defmacrop ensure_hackney do
if Code.ensure_loaded?(:hackney) do
:ok
else
quote do
unless Code.ensure_loaded?(:hackney) do
Logger.error("""
Could not find hackney dependency.
Please add :hackney to your dependencies:
{:hackney, "~> 1.17"}
Or use a different HTTP client. See Goth.Token.fetch/1 documentation for more information.
""")
raise "missing hackney dependency"
end
{:ok, _} = Application.ensure_all_started(:hackney)
:ok
end
end
end
def __hackney__(options) do
ensure_hackney()
{method, options} = Keyword.pop!(options, :method)
{url, options} = Keyword.pop!(options, :url)
{headers, options} = Keyword.pop!(options, :headers)
{body, options} = Keyword.pop!(options, :body)
options = [:with_body] ++ options
case :hackney.request(method, url, headers, body, options) do
{:ok, status, headers, response_body} ->
{:ok, %{status: status, headers: headers, body: response_body}}
{:error, reason} ->
{:error, RuntimeError.exception(inspect(reason))}
end
end
@doc """
Fetches the token from the cache.
If the token is not in the cache, this function blocks for `timeout`
milliseconds (defaults to `5000`) while it is attempted to be
fetched in the background.
To fetch the token bypassing the cache, see `Goth.Token.fetch/1`.
"""
@doc since: "1.3.0"
def fetch(name, timeout \\ 5000) do
read_from_ets(name) || GenServer.call(registry_name(name), :fetch, timeout)
end
@doc """
Fetches the token, erroring if it is missing.
See `fetch/2` for more information.
"""
@doc since: "1.3.0"
def fetch!(name, timeout \\ 5000) do
case fetch(name, timeout) do
{:ok, token} -> token
{:error, exception} -> raise exception
end
end
defstruct [
:name,
:source,
:backoff,
:http_client,
:retry_after,
:refresh_after,
max_retries: @max_retries,
retries: @max_retries
]
defp read_from_ets(name) do
case Registry.lookup(@registry, name) do
[{_pid, %Token{} = token}] -> {:ok, token}
_ -> nil
end
end
@impl true
def init(opts) when is_list(opts) do
{backoff_opts, opts} = Keyword.split(opts, [:backoff_type, :backoff_min, :backoff_max])
{prefetch, opts} = Keyword.pop(opts, :prefetch, :async)
state = struct!(__MODULE__, opts)
state =
state
|> Map.update!(:http_client, &start_http_client/1)
|> Map.replace!(:backoff, Backoff.new(backoff_opts))
|> Map.replace!(:retries, state.max_retries)
case prefetch do
:async ->
{:ok, state, {:continue, :async_prefetch}}
:sync ->
prefetch(state)
{:ok, state}
end
end
@impl true
def handle_continue(:async_prefetch, state) do
prefetch(state)
{:noreply, state}
end
defp prefetch(state) do
# given calculating JWT for each request is expensive, we do it once
# on system boot to hopefully fill in the cache.
case Token.fetch(state) do
{:ok, token} ->
store_and_schedule_refresh(state, token)
{:error, _} ->
send(self(), :refresh)
end
end
@impl true
def handle_call(:fetch, _from, state) do
reply = read_from_ets(state.name) || fetch_and_schedule_refresh(state)
{:reply, reply, state}
end
defp fetch_and_schedule_refresh(state) do
with {:ok, token} <- Token.fetch(state) do
store_and_schedule_refresh(state, token)
{:ok, token}
end
end
defp start_http_client(:hackney) do
{&__hackney__/1, []}
end
defp start_http_client({:hackney, opts}) do
{&__hackney__/1, opts}
end
defp start_http_client(fun) when is_function(fun, 1) do
{fun, []}
end
defp start_http_client({fun, opts}) when is_function(fun, 1) do
{fun, opts}
end
defp start_http_client({module, _} = config) when is_atom(module) do
Logger.warn("Setting http_client: mod | {mod, opts} is deprecated in favour of http_client: fun | {fun, opts}")
Goth.HTTPClient.init(config)
end
@impl true
def handle_info(:refresh, state) do
case Token.fetch(state) do
{:ok, token} -> do_refresh(token, state)
{:error, exception} -> handle_retry(exception, state)
end
end
defp handle_retry(exception, %{retries: 1}) do
raise "too many failed attempts to refresh, last error: #{inspect(exception)}"
end
defp handle_retry(_, state) do
{time_in_seconds, backoff} = Backoff.backoff(state.backoff)
Process.send_after(self(), :refresh, time_in_seconds)
{:noreply, %{state | retries: state.retries - 1, backoff: backoff}}
end
defp do_refresh(token, state) do
state = %{state | retries: state.max_retries, backoff: Backoff.reset(state.backoff)}
store_and_schedule_refresh(state, token)
{:noreply, state}
end
defp store_and_schedule_refresh(state, token) do
put(state.name, token)
Process.send_after(self(), :refresh, state.refresh_after)
end
defp put(name, token) do
Registry.update_value(@registry, name, fn _ -> token end)
end
defp registry_name(name) do
{:via, Registry, {@registry, name}}
end
end