use ::serde::Serialize;
use crate::release::Release;
const FHIR_JSON: &str = "application/fhir+json";
fn is_retryable<R: Release>(e: &ReleaseClientError<R>) -> bool {
match e {
ReleaseClientError::Http(e) => e.is_timeout() || e.is_connect() || e.is_request(),
ReleaseClientError::Outcome { status, .. } | ReleaseClientError::Status { status, .. } => {
*status >= 500 || *status == 429
}
ReleaseClientError::Url(_) | ReleaseClientError::BodyTooLarge { .. } => false,
}
}
pub enum ReleaseClientError<R: Release> {
Http(reqwest::Error),
Outcome {
status: u16,
outcome: Box<R::OperationOutcome>,
},
Status {
status: u16,
body: String,
},
Url(String),
BodyTooLarge {
limit: usize,
},
}
#[allow(clippy::missing_fields_in_debug)]
impl<R: Release> std::fmt::Debug for ReleaseClientError<R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ReleaseClientError::Http(e) => f.debug_tuple("Http").field(e).finish(),
ReleaseClientError::Outcome { status, outcome } => f
.debug_struct("Outcome")
.field("status", status)
.field("outcome", outcome)
.finish(),
ReleaseClientError::Status { status, body } => f
.debug_struct("Status")
.field("status", status)
.field("body_len", &body.len())
.finish_non_exhaustive(),
ReleaseClientError::Url(msg) => f.debug_tuple("Url").field(msg).finish(),
ReleaseClientError::BodyTooLarge { limit } => f
.debug_struct("BodyTooLarge")
.field("limit", limit)
.finish(),
}
}
}
impl<R: Release> std::fmt::Display for ReleaseClientError<R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ReleaseClientError::Http(e) => write!(f, "HTTP error: {e}"),
ReleaseClientError::Outcome { status, .. } => {
write!(f, "FHIR error status {status} (OperationOutcome)")
}
ReleaseClientError::Status { status, .. } => write!(f, "error status {status}"),
ReleaseClientError::Url(msg) => write!(f, "cannot build request URL: {msg}"),
ReleaseClientError::BodyTooLarge { limit } => {
write!(f, "response body exceeds {limit} bytes")
}
}
}
}
impl<R: Release> std::error::Error for ReleaseClientError<R> {}
impl<R: Release> From<reqwest::Error> for ReleaseClientError<R> {
fn from(e: reqwest::Error) -> Self {
ReleaseClientError::Http(e)
}
}
const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const DEFAULT_MAX_BODY: usize = 64 * 1024 * 1024;
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
pub attempts: u32,
pub backoff: std::time::Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
attempts: 0,
backoff: std::time::Duration::from_millis(200),
}
}
}
type TokenSource = std::sync::Arc<dyn Fn() -> Option<String> + Send + Sync>;
#[derive(Clone)]
pub struct ReleaseClient<R: Release> {
base_url: String,
http: reqwest::Client,
auth: Option<TokenSource>,
retry: RetryPolicy,
max_body: usize,
release: std::marker::PhantomData<R>,
}
impl<R: Release> std::fmt::Debug for ReleaseClient<R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ReleaseClient")
.field("base_url", &self.base_url)
.field("http", &self.http)
.field("release", &R::LABEL)
.field("auth", &self.auth.as_ref().map(|_| "<token supplier>"))
.field("retry", &self.retry)
.field("max_body", &self.max_body)
.finish()
}
}
impl<R: Release> ReleaseClient<R> {
#[must_use]
pub fn new(base_url: impl Into<String>) -> Self {
let http = reqwest::Client::builder()
.timeout(DEFAULT_TIMEOUT)
.connect_timeout(DEFAULT_CONNECT_TIMEOUT)
.build()
.unwrap_or_default();
Self::with_http(base_url, http)
}
#[must_use]
pub fn with_http(base_url: impl Into<String>, http: reqwest::Client) -> Self {
let base_url = base_url.into().trim_end_matches('/').to_string();
Self {
base_url,
http,
auth: None,
retry: RetryPolicy::default(),
max_body: DEFAULT_MAX_BODY,
release: std::marker::PhantomData,
}
}
#[must_use]
pub fn with_bearer_token<F>(mut self, source: F) -> Self
where
F: Fn() -> Option<String> + Send + Sync + 'static,
{
self.auth = Some(std::sync::Arc::new(source));
self
}
#[must_use]
pub fn with_retry(mut self, retry: RetryPolicy) -> Self {
self.retry = retry;
self
}
#[must_use]
pub fn with_max_body(mut self, bytes: usize) -> Self {
self.max_body = bytes;
self
}
fn url(&self, segments: &[&str]) -> Result<reqwest::Url, ReleaseClientError<R>> {
let mut url = reqwest::Url::parse(&self.base_url)
.map_err(|e| ReleaseClientError::Url(format!("{}: {e}", self.base_url)))?;
{
let mut path = url.path_segments_mut().map_err(|()| {
ReleaseClientError::Url("base URL cannot have path segments".into())
})?;
for s in segments {
path.push(s);
}
}
Ok(url)
}
async fn send(
&self,
req: reqwest::RequestBuilder,
) -> Result<reqwest::Response, ReleaseClientError<R>> {
let idempotent = req
.try_clone()
.and_then(|r| r.build().ok())
.is_some_and(|r| {
matches!(
*r.method(),
reqwest::Method::GET | reqwest::Method::PUT | reqwest::Method::DELETE
)
});
let mut delay = self.retry.backoff;
let tries = if idempotent { self.retry.attempts } else { 0 };
for attempt in 0..=tries {
let Some(this) = req.try_clone() else {
return self.send_once(req).await;
};
match self.send_once(this).await {
Ok(resp) => return Ok(resp),
Err(e) if attempt < tries && is_retryable(&e) => {
tokio::time::sleep(delay).await;
delay *= 2;
}
Err(e) => return Err(e),
}
}
self.send_once(req).await
}
async fn send_once(
&self,
req: reqwest::RequestBuilder,
) -> Result<reqwest::Response, ReleaseClientError<R>> {
let mut req = req.header(reqwest::header::ACCEPT, FHIR_JSON);
if let Some(source) = &self.auth
&& let Some(token) = source()
{
req = req.bearer_auth(token);
}
let resp = req.send().await?;
if resp.status().is_success() {
return Ok(resp);
}
let status = resp.status().as_u16();
let body = self.body_capped(resp).await?;
match ::serde_json::from_str::<R::OperationOutcome>(&body) {
Ok(outcome) => Err(ReleaseClientError::Outcome {
status,
outcome: Box::new(outcome),
}),
Err(_) => Err(ReleaseClientError::Status {
status,
body: body.chars().take(2048).collect(),
}),
}
}
async fn body_capped(
&self,
mut resp: reqwest::Response,
) -> Result<String, ReleaseClientError<R>> {
if resp
.content_length()
.is_some_and(|n| n > self.max_body as u64)
{
return Err(ReleaseClientError::BodyTooLarge {
limit: self.max_body,
});
}
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = resp.chunk().await? {
if buf.len() + chunk.len() > self.max_body {
return Err(ReleaseClientError::BodyTooLarge {
limit: self.max_body,
});
}
buf.extend_from_slice(&chunk);
}
String::from_utf8(buf).map_err(|e| ReleaseClientError::Url(e.to_string()))
}
async fn json<T: ::serde::de::DeserializeOwned>(
&self,
resp: reqwest::Response,
) -> Result<T, ReleaseClientError<R>> {
let body = self.body_capped(resp).await?;
::serde_json::from_str(&body).map_err(|e| ReleaseClientError::Status {
status: 200,
body: format!("malformed FHIR JSON: {e}"),
})
}
pub async fn read(
&self,
resource_type: &str,
id: &str,
) -> Result<R::Resource, ReleaseClientError<R>> {
Ok(self.read_with_etag(resource_type, id).await?.0)
}
pub async fn read_with_etag(
&self,
resource_type: &str,
id: &str,
) -> Result<(R::Resource, Option<String>), ReleaseClientError<R>> {
let url = self.url(&[resource_type, id])?;
let resp = self.send(self.http.get(url)).await?;
let etag = resp
.headers()
.get(reqwest::header::ETAG)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
Ok((self.json(resp).await?, etag))
}
pub async fn vread(
&self,
resource_type: &str,
id: &str,
version_id: &str,
) -> Result<R::Resource, ReleaseClientError<R>> {
let url = self.url(&[resource_type, id, "_history", version_id])?;
let resp = self.send(self.http.get(url)).await?;
self.json(resp).await
}
pub async fn create<T: Serialize>(
&self,
resource_type: &str,
resource: &T,
) -> Result<R::Resource, ReleaseClientError<R>> {
let url = self.url(&[resource_type])?;
let resp = self.send(self.http.post(url).json(resource)).await?;
self.json(resp).await
}
pub async fn create_conditional<T: Serialize>(
&self,
resource_type: &str,
resource: &T,
if_none_exist: &str,
) -> Result<R::Resource, ReleaseClientError<R>> {
let url = self.url(&[resource_type])?;
let resp = self
.send(
self.http
.post(url)
.header("If-None-Exist", if_none_exist)
.json(resource),
)
.await?;
self.json(resp).await
}
pub async fn update<T: Serialize>(
&self,
resource_type: &str,
id: &str,
resource: &T,
) -> Result<R::Resource, ReleaseClientError<R>> {
self.put(resource_type, id, resource, None).await
}
pub async fn update_if_match<T: Serialize>(
&self,
resource_type: &str,
id: &str,
resource: &T,
etag: &str,
) -> Result<R::Resource, ReleaseClientError<R>> {
self.put(resource_type, id, resource, Some(etag)).await
}
async fn put<T: Serialize>(
&self,
resource_type: &str,
id: &str,
resource: &T,
etag: Option<&str>,
) -> Result<R::Resource, ReleaseClientError<R>> {
let url = self.url(&[resource_type, id])?;
let mut req = self.http.put(url).json(resource);
if let Some(etag) = etag {
req = req.header(reqwest::header::IF_MATCH, etag);
}
let resp = self.send(req).await?;
self.json(resp).await
}
pub async fn delete(&self, resource_type: &str, id: &str) -> Result<(), ReleaseClientError<R>> {
let url = self.url(&[resource_type, id])?;
self.send(self.http.delete(url)).await?;
Ok(())
}
pub async fn delete_if_match(
&self,
resource_type: &str,
id: &str,
etag: &str,
) -> Result<(), ReleaseClientError<R>> {
let url = self.url(&[resource_type, id])?;
self.send(
self.http
.delete(url)
.header(reqwest::header::IF_MATCH, etag),
)
.await?;
Ok(())
}
pub async fn search(
&self,
resource_type: &str,
params: &[(&str, &str)],
) -> Result<R::Bundle, ReleaseClientError<R>> {
let url = self.url(&[resource_type])?;
let resp = self.send(self.http.get(url).query(params)).await?;
self.json(resp).await
}
pub async fn next_page(
&self,
bundle: &R::Bundle,
) -> Result<Option<R::Bundle>, ReleaseClientError<R>> {
let Some(next) = R::next_link(bundle) else {
return Ok(None);
};
let resp = self.send(self.http.get(next)).await?;
Ok(Some(self.json(resp).await?))
}
pub async fn search_all(
&self,
resource_type: &str,
params: &[(&str, &str)],
max_pages: usize,
) -> Result<Vec<R::Bundle>, ReleaseClientError<R>> {
let mut pages = Vec::new();
let mut current = self.search(resource_type, params).await?;
loop {
let next = self.next_page(¤t).await?;
pages.push(current);
match next {
Some(b) if pages.len() < max_pages => current = b,
_ => return Ok(pages),
}
}
}
pub async fn capabilities(&self) -> Result<R::CapabilityStatement, ReleaseClientError<R>> {
let url = self.url(&["metadata"])?;
let resp = self.send(self.http.get(url)).await?;
self.json(resp).await
}
}