Packages

Elixir client for Medplum's FHIR API with resource and workflow helpers.

Current section

Files

Jump to
medplum_elixir lib medplum client.ex
Raw

lib/medplum/client.ex

defmodule Medplum.Client do
@moduledoc """
Client configuration shared by Medplum API requests.
"""
@enforce_keys [:base_url]
@allowed_keys [
:access_token,
:auth_req_options,
:auth_mode,
:base_url,
:cache_tokens,
:client_id,
:client_secret,
:default_headers,
:fhir_version,
:max_retries,
:req_options,
:retry,
:token_refresh_skew
]
defstruct [
:base_url,
:client_id,
:client_secret,
:access_token,
fhir_version: "R4",
default_headers: [],
req_options: [],
auth_req_options: [],
auth_mode: :client_credentials,
retry: :transient,
max_retries: 2,
token_refresh_skew: 60,
cache_tokens: true
]
@typedoc "Client state used to authenticate requests and build FHIR URLs."
@type t :: %__MODULE__{
base_url: base_url(),
client_id: client_id(),
client_secret: client_secret(),
access_token: access_token(),
fhir_version: fhir_version(),
default_headers: headers(),
req_options: req_options(),
auth_req_options: req_options(),
auth_mode: auth_mode(),
retry: retry(),
max_retries: non_neg_integer(),
token_refresh_skew: non_neg_integer(),
cache_tokens: boolean()
}
@typedoc "Base Medplum URL without a trailing slash."
@type base_url :: String.t()
@typedoc "OAuth client id used for the client credentials grant."
@type client_id :: String.t()
@typedoc "OAuth client secret paired with the client id."
@type client_secret :: String.t()
@typedoc "Bearer token used directly for authenticated requests."
@type access_token :: String.t() | nil
@typedoc "FHIR version segment used in request paths. Defaults to `\"R4\"`."
@type fhir_version :: String.t()
@typedoc "Default headers merged into every FHIR request."
@type headers :: [{String.t(), String.t()}]
@typedoc "Extra `Req` options forwarded to outgoing requests."
@type req_options :: keyword()
@typedoc "Retry strategy passed through to `Req`."
@type retry :: false | :safe_transient | :transient
@typedoc "Authentication strategy used to authorize requests."
@type auth_mode :: :client_credentials | :access_token
@doc """
Builds a Medplum client.
The `base_url` value is normalized by trimming a trailing slash, and
`fhir_version` defaults to `"R4"` when it is not provided.
By default the client uses the OAuth client credentials grant. When
`access_token` is provided, the client switches to direct bearer-token mode
unless `auth_mode` is set explicitly.
"""
@spec new(keyword() | map()) :: t()
def new(opts), do: build(opts)
@doc """
Builds a Medplum client and raises on invalid configuration.
"""
@spec new!(keyword() | map()) :: t()
def new!(opts), do: build(opts)
@doc """
Builds a Medplum client that uses an existing bearer token directly.
"""
@spec new_with_access_token(keyword() | map(), String.t()) :: t()
def new_with_access_token(opts, access_token) when is_binary(access_token) do
opts
|> normalize_opts()
|> Keyword.put(:access_token, access_token)
|> Keyword.put(:auth_mode, :access_token)
|> build()
end
defp build(opts) when is_map(opts), do: opts |> normalize_opts() |> build()
defp build(opts) when is_list(opts) do
validate_keys!(opts)
base_url =
opts
|> fetch_string!(:base_url)
|> String.trim_trailing("/")
auth_mode = fetch_auth_mode(opts)
%__MODULE__{
base_url: base_url,
client_id: fetch_optional_string(opts, :client_id, ""),
client_secret: fetch_optional_string(opts, :client_secret, ""),
access_token: fetch_optional_string(opts, :access_token, nil),
fhir_version: fetch_string(opts, :fhir_version, "R4"),
default_headers: fetch_headers(opts),
req_options: fetch_keyword(opts, :req_options),
auth_req_options: fetch_keyword(opts, :auth_req_options),
auth_mode: auth_mode,
retry: fetch_retry(opts),
max_retries: fetch_non_neg_integer(opts, :max_retries, 2),
token_refresh_skew: fetch_non_neg_integer(opts, :token_refresh_skew, 60),
cache_tokens: fetch_boolean(opts, :cache_tokens, true)
}
|> validate_auth_config!()
end
defp build(_opts) do
raise ArgumentError, "Medplum client config must be keyword list or map"
end
defp normalize_opts(opts) when is_map(opts), do: Enum.into(opts, [])
defp normalize_opts(opts) when is_list(opts), do: opts
defp validate_keys!(opts) do
invalid_keys = Keyword.keys(opts) -- @allowed_keys
if invalid_keys != [] do
raise ArgumentError,
"unsupported Medplum client options: #{Enum.map_join(invalid_keys, ", ", &inspect/1)}"
end
end
defp fetch_string!(opts, key) do
opts
|> Keyword.fetch!(key)
|> ensure_string!(key)
end
defp fetch_string(opts, key, default) do
opts
|> Keyword.get(key, default)
|> ensure_string!(key)
end
defp fetch_optional_string(opts, key, default) do
case Keyword.get(opts, key, default) do
nil -> nil
value -> ensure_string!(value, key)
end
end
defp fetch_headers(opts) do
opts
|> Keyword.get(:default_headers, [])
|> ensure_headers!()
end
defp fetch_keyword(opts, key) do
value = Keyword.get(opts, key, [])
if Keyword.keyword?(value) do
value
else
raise ArgumentError, "#{inspect(key)} must be a keyword list"
end
end
defp fetch_retry(opts) do
retry = Keyword.get(opts, :retry, :transient)
if retry in [false, :safe_transient, :transient] do
retry
else
raise ArgumentError, ":retry must be false, :safe_transient, or :transient"
end
end
defp fetch_non_neg_integer(opts, key, default) do
value = Keyword.get(opts, key, default)
if is_integer(value) and value >= 0 do
value
else
raise ArgumentError, "#{inspect(key)} must be a non-negative integer"
end
end
defp fetch_boolean(opts, key, default) do
value = Keyword.get(opts, key, default)
if is_boolean(value) do
value
else
raise ArgumentError, "#{inspect(key)} must be a boolean"
end
end
defp ensure_string!(value, _key) when is_binary(value), do: value
defp ensure_string!(_value, key) do
raise ArgumentError, "#{inspect(key)} must be a string"
end
defp ensure_headers!(headers) when is_list(headers) do
Enum.map(headers, fn
{name, value} when is_binary(name) and is_binary(value) -> {name, value}
_other -> raise ArgumentError, ":default_headers must be a list of {binary, binary}"
end)
end
defp ensure_headers!(_headers) do
raise ArgumentError, ":default_headers must be a list of {binary, binary}"
end
defp fetch_auth_mode(opts) do
auth_mode =
case Keyword.fetch(opts, :auth_mode) do
{:ok, value} ->
value
:error ->
if Keyword.has_key?(opts, :access_token), do: :access_token, else: :client_credentials
end
if auth_mode in [:client_credentials, :access_token] do
auth_mode
else
raise ArgumentError, ":auth_mode must be :client_credentials or :access_token"
end
end
defp validate_auth_config!(%__MODULE__{auth_mode: :client_credentials} = client) do
if client.client_id == "" do
raise ArgumentError, ":client_id must be a string"
end
if client.client_secret == "" do
raise ArgumentError, ":client_secret must be a string"
end
client
end
defp validate_auth_config!(
%__MODULE__{auth_mode: :access_token, access_token: access_token} = client
)
when is_binary(access_token) and access_token != "" do
client
end
defp validate_auth_config!(%__MODULE__{auth_mode: :access_token}) do
raise ArgumentError,
":access_token must be a non-empty string when auth_mode is :access_token"
end
end