Current section
Files
Jump to
Current section
Files
lib/medplum.ex
defmodule Medplum do
@moduledoc """
Concise client for Medplum's FHIR API and OAuth flows.
Build a `Medplum.Client`, then pass it to the resource helpers in this module for
authenticated FHIR requests. Public operations return `{:ok, map()}` for decoded
response bodies or `{:error, %Medplum.Error{}}` for transport, auth, or API failures.
Clients can either reuse cached client-credentials tokens or use an existing bearer
token directly. The module exposes resource helpers, OAuth helpers, and lower-level
request functions for unsupported endpoints.
## Example
client =
Medplum.new(
base_url: "https://api.medplum.com",
client_id: System.fetch_env!("MEDPLUM_CLIENT_ID"),
client_secret: System.fetch_env!("MEDPLUM_CLIENT_SECRET")
)
{:ok, patient} = Medplum.read(client, "Patient", "123")
{:ok, patients} = Medplum.search(client, "Patient", %{"family" => "Smith"})
"""
alias Medplum.Client
alias Medplum.Error
alias Medplum.OAuth
alias Medplum.Request
@type client :: Client.t()
@type result :: {:ok, map()} | {:error, Error.t()}
@type async_result :: %{
required(String.t()) => term(),
optional(String.t()) => term()
}
@type binary_result :: %{
body: binary(),
content_type: String.t() | nil,
headers: [{String.t(), String.t()}]
}
@doc """
Builds a reusable Medplum client from base URL and client credentials.
"""
@spec new(keyword() | map()) :: Client.t()
def new(opts), do: Client.new(opts)
@doc """
Builds a reusable Medplum client and raises on invalid configuration.
"""
@spec new!(keyword() | map()) :: Client.t()
def new!(opts), do: Client.new!(opts)
@doc """
Builds a reusable Medplum client that authenticates with an existing bearer token.
"""
@spec new_with_access_token(keyword() | map(), String.t()) :: Client.t()
def new_with_access_token(opts, access_token),
do: Client.new_with_access_token(opts, access_token)
@doc """
Builds a Medplum OAuth authorize URL for authorization-code flows.
"""
@spec authorize_url(Client.t() | keyword() | map(), keyword() | map()) :: String.t()
def authorize_url(client_or_opts, params), do: OAuth.authorize_url(client_or_opts, params)
@doc """
Exchanges an authorization code for a Medplum OAuth token response.
"""
@spec exchange_authorization_code(Client.t() | keyword() | map(), String.t(), keyword() | map()) ::
result()
def exchange_authorization_code(client_or_opts, code, opts \\ []),
do: OAuth.exchange_authorization_code(client_or_opts, code, opts)
@doc """
Fetches OpenID Connect userinfo from `/oauth2/userinfo`.
"""
@spec userinfo(Client.t(), keyword()) :: result()
def userinfo(%Client{} = client, opts \\ []), do: OAuth.userinfo(client, opts)
@doc """
Fetches one FHIR resource by resource type and id.
"""
@spec read(Client.t(), String.t(), String.t()) :: result()
def read(%Client{} = client, resource_type, id) do
Request.request(client, :get, "/#{resource_type}/#{id}")
end
@doc """
Searches a FHIR resource type with query parameters encoded from the given map.
"""
@spec search(Client.t(), String.t(), map()) :: result()
def search(%Client{} = client, resource_type, params \\ %{}) do
Request.request(client, :get, "/#{resource_type}", params: params)
end
@doc """
Creates a FHIR resource and fills in `"resourceType"` when it is omitted.
"""
@spec create(Client.t(), String.t(), map()) :: result()
def create(%Client{} = client, resource_type, attrs) when is_map(attrs) do
body = Map.put_new(attrs, "resourceType", resource_type)
Request.request(client, :post, "/#{resource_type}", json: body)
end
@doc """
Replaces a FHIR resource by type and id, forcing the outgoing body to match both.
"""
@spec update(Client.t(), String.t(), String.t(), map()) :: result()
def update(%Client{} = client, resource_type, id, attrs) when is_map(attrs) do
body =
attrs
|> Map.put("id", id)
|> Map.put_new("resourceType", resource_type)
Request.request(client, :put, "/#{resource_type}/#{id}", json: body)
end
@doc """
Deletes a FHIR resource by resource type and id.
"""
@spec delete(Client.t(), String.t(), String.t()) :: result()
def delete(%Client{} = client, resource_type, id) do
Request.request(client, :delete, "/#{resource_type}/#{id}")
end
@doc """
Sends an authenticated request to any FHIR-relative path or absolute Medplum URL.
"""
@spec request(Client.t(), atom(), String.t(), keyword()) :: result()
def request(%Client{} = client, method, path, opts \\ []) do
Request.request(client, method, path, opts)
end
@doc """
Sends an authenticated request to a non-FHIR Medplum path relative to `base_url`.
"""
@spec api_request(Client.t(), atom(), String.t(), keyword()) :: result()
def api_request(%Client{} = client, method, path, opts \\ []) do
Request.api_request(client, method, path, opts)
end
@doc """
Applies a JSON Patch document to a resource by type and id.
"""
@spec patch(Client.t(), String.t(), String.t(), list(map())) :: result()
def patch(%Client{} = client, resource_type, id, operations) when is_list(operations) do
Request.request(
client,
:patch,
"/#{resource_type}/#{id}",
body: Jason.encode!(operations),
headers: [{"content-type", "application/json-patch+json"}]
)
end
@doc """
Invokes a FHIR operation on the system, type, or instance level.
"""
@spec operation(
Client.t(),
:system | String.t() | {String.t(), String.t()},
String.t(),
map(),
keyword()
) ::
result() | {:ok, async_result()}
def operation(%Client{} = client, target, operation_name, params \\ %{}, opts \\ [])
when is_map(params) do
{method, request_opts} = operation_request_opts(params, opts)
path = operation_path(target, operation_name)
Request.request(client, method, path, request_opts)
end
@doc """
Polls an async status URL until a final response is available.
"""
@spec poll_async(Client.t(), String.t(), keyword()) :: result() | {:ok, async_result()}
def poll_async(%Client{} = client, status_url_or_path, opts \\ []) do
interval = Keyword.get(opts, :interval, 1_000)
max_attempts = Keyword.get(opts, :max_attempts, 30)
do_poll_async(client, status_url_or_path, interval, max_attempts)
end
@doc """
Executes a FHIR batch bundle against the base endpoint.
"""
@spec batch(Client.t(), list(map()) | map()) :: result() | {:ok, async_result()}
def batch(%Client{} = client, entries_or_bundle) do
bundle = build_bundle(entries_or_bundle, "batch")
Request.request(client, :post, "/", json: bundle)
end
@doc """
Executes a FHIR transaction bundle against the base endpoint.
"""
@spec transaction(Client.t(), list(map()) | map()) :: result() | {:ok, async_result()}
def transaction(%Client{} = client, entries_or_bundle) do
bundle = build_bundle(entries_or_bundle, "transaction")
Request.request(client, :post, "/", json: bundle)
end
@doc """
Uploads raw bytes to the Binary endpoint.
"""
@spec create_binary(Client.t(), binary(), keyword()) :: result()
def create_binary(%Client{} = client, data, opts) when is_binary(data) and is_list(opts) do
content_type = Keyword.fetch!(opts, :content_type)
extra_headers = Keyword.get(opts, :headers, [])
headers =
[{"content-type", content_type}]
|> maybe_put_security_context(Keyword.get(opts, :security_context))
|> maybe_put_filename(Keyword.get(opts, :filename))
|> Kernel.++(extra_headers)
Request.request(client, :post, "/Binary", body: data, headers: headers)
end
@doc """
Downloads raw bytes from a Binary resource.
"""
@spec get_binary(Client.t(), String.t()) :: {:ok, binary_result()} | {:error, Error.t()}
def get_binary(%Client{} = client, id_or_url) when is_binary(id_or_url) do
path = if absolute_url?(id_or_url), do: id_or_url, else: "/Binary/#{id_or_url}"
Request.request(client, :get, path,
headers: [{"accept", "*/*"}],
response_mode: :raw,
decode_body: false
)
end
@doc """
Performs an official Medplum conditional upsert using a query embedded in attrs.
"""
@spec upsert(Client.t(), String.t(), map()) :: result()
def upsert(%Client{} = client, resource_type, attrs) when is_map(attrs) do
body = attrs |> Map.put_new("resourceType", resource_type)
ensure_resource_type!(body, resource_type)
{query, body} = extract_upsert_query!(body)
Request.request(client, :put, "/#{resource_type}?#{query}", json: body)
end
@doc """
Sends a GraphQL request to the FHIR GraphQL endpoint.
"""
@spec graphql(Client.t(), String.t(), keyword()) :: result()
def graphql(%Client{} = client, query, opts \\ []) when is_binary(query) do
payload =
%{"query" => query}
|> maybe_put_graphql_field("variables", Keyword.get(opts, :variables))
|> maybe_put_graphql_field("operationName", Keyword.get(opts, :operation_name))
Request.request(
client,
:post,
"/$graphql",
json: payload,
headers: [{"accept", "application/json"}, {"content-type", "application/json"}]
)
end
@doc """
Streams Bundle entries across paginated search results.
The stream yields each Bundle `entry` map and raises `Medplum.Error` if a later page fails.
"""
@spec stream_search(Client.t(), String.t(), map()) :: Enumerable.t()
def stream_search(%Client{} = client, resource_type, params \\ %{}) do
Stream.resource(
fn -> {:path, "/#{resource_type}", params} end,
fn
nil ->
{:halt, nil}
{:path, path, params} ->
case Request.request(client, :get, path, params: params) do
{:ok, bundle} ->
{bundle_entries(bundle), next_state(bundle)}
{:error, error} ->
raise error
end
{:url, url} ->
case Request.request(client, :get, url) do
{:ok, bundle} ->
{bundle_entries(bundle), next_state(bundle)}
{:error, error} ->
raise error
end
end,
fn _state -> :ok end
)
end
defp bundle_entries(%{"entry" => entries}) when is_list(entries), do: entries
defp bundle_entries(_bundle), do: []
defp next_state(%{"link" => links}) when is_list(links) do
case Enum.find(links, &match?(%{"relation" => "next"}, &1)) do
%{"url" => url} when is_binary(url) -> {:url, url}
_other -> nil
end
end
defp next_state(_bundle), do: nil
defp operation_request_opts(params, opts) do
method =
Keyword.get_lazy(opts, :method, fn ->
if map_size(params) == 0, do: :get, else: :post
end)
request_opts =
opts
|> Keyword.delete(:method)
|> Keyword.update(:headers, [], fn headers -> headers end)
|> maybe_add_async_preference()
|> maybe_put_operation_body(method, params)
{method, request_opts}
end
defp operation_path(:system, operation_name), do: "/#{normalize_operation_name(operation_name)}"
defp operation_path(resource_type, operation_name) when is_binary(resource_type),
do: "/#{resource_type}/#{normalize_operation_name(operation_name)}"
defp operation_path({resource_type, id}, operation_name)
when is_binary(resource_type) and is_binary(id) do
"/#{resource_type}/#{id}/#{normalize_operation_name(operation_name)}"
end
defp normalize_operation_name("$" <> _rest = operation_name), do: operation_name
defp normalize_operation_name(operation_name), do: "$" <> operation_name
defp do_poll_async(_client, _status_url_or_path, _interval, 0) do
{:error, Error.new(:request_failed, reason: :max_attempts_exceeded)}
end
defp do_poll_async(client, status_url_or_path, interval, attempts_left) do
case Request.request(client, :get, status_url_or_path) do
{:ok, %{"statusUrl" => next_url}} ->
Process.sleep(interval)
do_poll_async(client, next_url, interval, attempts_left - 1)
{:ok, %{"resourceType" => "AsyncJob", "status" => status} = job}
when status in ["accepted", "queued", "running", "in-progress"] ->
Process.sleep(interval)
job_url = Map.get(job, "url", status_url_or_path)
do_poll_async(client, job_url, interval, attempts_left - 1)
other ->
other
end
end
defp build_bundle(entries, type) when is_list(entries) do
%{"resourceType" => "Bundle", "type" => type, "entry" => entries}
end
defp build_bundle(%{"resourceType" => "Bundle"} = bundle, _type), do: bundle
defp build_bundle(bundle, _type) when is_map(bundle) do
bundle
end
defp maybe_put_security_context(headers, nil), do: headers
defp maybe_put_security_context(headers, security_context),
do: headers ++ [{"x-security-context", security_context}]
defp maybe_put_filename(headers, nil), do: headers
defp maybe_put_filename(headers, filename) do
headers ++ [{"content-disposition", ~s(attachment; filename="#{filename}")}]
end
defp absolute_url?(url) do
String.starts_with?(url, "http://") or String.starts_with?(url, "https://")
end
defp ensure_resource_type!(%{"resourceType" => actual}, resource_type)
when actual == resource_type,
do: :ok
defp ensure_resource_type!(%{"resourceType" => actual}, resource_type) do
raise ArgumentError,
~s(attrs["resourceType"] must match #{inspect(resource_type)}, got #{inspect(actual)})
end
defp extract_upsert_query!(attrs) do
cond do
is_binary(attrs["_search"]) ->
{attrs["_search"], Map.delete(attrs, "_search")}
is_map(attrs["_search"]) ->
{URI.encode_query(attrs["_search"]), Map.delete(attrs, "_search")}
true ->
raise ArgumentError,
~s(upsert/3 requires attrs["_search"] as a query string or map for Medplum conditional PUT)
end
end
defp maybe_put_graphql_field(payload, _key, nil), do: payload
defp maybe_put_graphql_field(payload, key, value), do: Map.put(payload, key, value)
defp maybe_add_async_preference(opts) do
if Keyword.get(opts, :async, false) do
headers = Keyword.get(opts, :headers, [])
Keyword.put(opts, :headers, headers ++ [{"prefer", "respond-async"}])
else
opts
end
end
defp maybe_put_operation_body(opts, :get, _params), do: opts
defp maybe_put_operation_body(opts, _method, params) do
if Keyword.has_key?(opts, :json) or Keyword.has_key?(opts, :body) do
opts
else
Keyword.put(opts, :json, params)
end
end
end