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
}
}
#[cfg(feature = "r5")]
pub type Client = ReleaseClient<crate::r5::R5>;
#[cfg(feature = "r5")]
pub type ClientError = ReleaseClientError<crate::r5::R5>;
#[cfg(test)]
#[cfg(feature = "r5")]
mod tests {
use super::*;
use crate::r5::resources::Resource;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn read_returns_resource() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/Patient/pat-1"))
.respond_with(
ResponseTemplate::new(200).set_body_json(::serde_json::json!({
"resourceType": "Patient", "id": "pat-1", "active": true
})),
)
.mount(&server)
.await;
let client = Client::new(server.uri());
let resource = client.read("Patient", "pat-1").await.unwrap();
match resource {
Resource::Patient(p) => assert_eq!(p.id.unwrap().0, "pat-1"),
other => panic!("expected Patient, got {other:?}"),
}
}
#[tokio::test]
async fn search_returns_bundle() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/Patient"))
.respond_with(
ResponseTemplate::new(200).set_body_json(::serde_json::json!({
"resourceType": "Bundle", "type": "searchset",
"entry": [{ "resource": { "resourceType": "Patient", "id": "a" } }]
})),
)
.mount(&server)
.await;
let client = Client::new(server.uri());
let bundle = client
.search("Patient", &[("name", "chalmers")])
.await
.unwrap();
assert_eq!(bundle.iter_resources().count(), 1);
}
#[tokio::test]
async fn a_hostile_id_cannot_retarget_the_request() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/Patient/..%2FPatient%2Fother"))
.respond_with(
ResponseTemplate::new(200).set_body_json(::serde_json::json!({
"resourceType": "Patient", "id": "safe"
})),
)
.mount(&server)
.await;
let client = Client::new(server.uri());
let resource = client
.read("Patient", "../Patient/other")
.await
.expect("the encoded path is the one requested");
match resource {
Resource::Patient(p) => assert_eq!(p.id.unwrap().0, "safe"),
other => panic!("expected Patient, got {other:?}"),
}
}
#[tokio::test]
async fn a_stalled_server_times_out() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/Patient/slow"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(std::time::Duration::from_secs(30))
.set_body_json(::serde_json::json!({"resourceType": "Patient"})),
)
.mount(&server)
.await;
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(150))
.build()
.expect("client");
let client = Client::with_http(server.uri(), http);
let err = client
.read("Patient", "slow")
.await
.expect_err("should time out");
match err {
ClientError::Http(e) => assert!(e.is_timeout(), "expected a timeout, got {e}"),
other => panic!("expected a transport timeout, got {other:?}"),
}
}
#[tokio::test]
async fn an_oversized_body_is_refused() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/Patient/big"))
.respond_with(ResponseTemplate::new(200).set_body_string("x".repeat(4096)))
.mount(&server)
.await;
let client = Client::new(server.uri()).with_max_body(1024);
match client.read("Patient", "big").await {
Err(ClientError::BodyTooLarge { limit }) => assert_eq!(limit, 1024),
other => panic!("expected BodyTooLarge, got {other:?}"),
}
}
#[test]
fn debug_output_does_not_leak_the_body() {
let err: ClientError = ReleaseClientError::Status {
status: 500,
body: "{\"resourceType\":\"Patient\",\"name\":[{\"family\":\"Sensitive\"}]}"
.to_string(),
};
let rendered = format!("{err:?}");
assert!(!rendered.contains("Sensitive"), "leaked: {rendered}");
assert!(rendered.contains("body_len"));
}
#[tokio::test]
async fn error_status_parses_operation_outcome() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/Patient/missing"))
.respond_with(
ResponseTemplate::new(404).set_body_json(::serde_json::json!({
"resourceType": "OperationOutcome",
"issue": [{ "severity": "error", "code": "not-found",
"diagnostics": "no such Patient" }]
})),
)
.mount(&server)
.await;
let client = Client::new(server.uri());
let err = client.read("Patient", "missing").await.unwrap_err();
match err {
ClientError::Outcome { status, outcome } => {
assert_eq!(status, 404);
assert_eq!(outcome.issue.len(), 1);
}
other => panic!("expected Outcome, got {other:?}"),
}
}
}