use ::serde::Serialize;
use crate::r5::resources::{Bundle, CapabilityStatement, OperationOutcome, Resource};
const FHIR_JSON: &str = "application/fhir+json";
#[derive(Debug)]
pub enum ClientError {
Http(reqwest::Error),
Outcome {
status: u16,
outcome: Box<OperationOutcome>,
},
Status {
status: u16,
body: String,
},
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClientError::Http(e) => write!(f, "HTTP error: {e}"),
ClientError::Outcome { status, .. } => {
write!(f, "FHIR error status {status} (OperationOutcome)")
}
ClientError::Status { status, .. } => write!(f, "error status {status}"),
}
}
}
impl std::error::Error for ClientError {}
impl From<reqwest::Error> for ClientError {
fn from(e: reqwest::Error) -> Self {
ClientError::Http(e)
}
}
#[derive(Debug, Clone)]
pub struct Client {
base_url: String,
http: reqwest::Client,
}
impl Client {
#[must_use]
pub fn new(base_url: impl Into<String>) -> Self {
Self::with_http(base_url, reqwest::Client::new())
}
#[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 }
}
async fn send(&self, req: reqwest::RequestBuilder) -> Result<reqwest::Response, ClientError> {
let resp = req.header(reqwest::header::ACCEPT, FHIR_JSON).send().await?;
if resp.status().is_success() {
return Ok(resp);
}
let status = resp.status().as_u16();
let body = resp.text().await?;
match ::serde_json::from_str::<OperationOutcome>(&body) {
Ok(outcome) => Err(ClientError::Outcome { status, outcome: Box::new(outcome) }),
Err(_) => Err(ClientError::Status { status, body }),
}
}
pub async fn read(&self, resource_type: &str, id: &str) -> Result<Resource, ClientError> {
let url = format!("{}/{}/{}", self.base_url, resource_type, id);
Ok(self.send(self.http.get(url)).await?.json().await?)
}
pub async fn vread(
&self,
resource_type: &str,
id: &str,
version_id: &str,
) -> Result<Resource, ClientError> {
let url = format!("{}/{}/{}/_history/{}", self.base_url, resource_type, id, version_id);
Ok(self.send(self.http.get(url)).await?.json().await?)
}
pub async fn create<T: Serialize>(
&self,
resource_type: &str,
resource: &T,
) -> Result<Resource, ClientError> {
let url = format!("{}/{}", self.base_url, resource_type);
Ok(self.send(self.http.post(url).json(resource)).await?.json().await?)
}
pub async fn update<T: Serialize>(
&self,
resource_type: &str,
id: &str,
resource: &T,
) -> Result<Resource, ClientError> {
let url = format!("{}/{}/{}", self.base_url, resource_type, id);
Ok(self.send(self.http.put(url).json(resource)).await?.json().await?)
}
pub async fn delete(&self, resource_type: &str, id: &str) -> Result<(), ClientError> {
let url = format!("{}/{}/{}", self.base_url, resource_type, id);
self.send(self.http.delete(url)).await?;
Ok(())
}
pub async fn search(
&self,
resource_type: &str,
params: &[(&str, &str)],
) -> Result<Bundle, ClientError> {
let url = format!("{}/{}", self.base_url, resource_type);
Ok(self.send(self.http.get(url).query(params)).await?.json().await?)
}
pub async fn capabilities(&self) -> Result<CapabilityStatement, ClientError> {
let url = format!("{}/metadata", self.base_url);
Ok(self.send(self.http.get(url)).await?.json().await?)
}
}
#[cfg(test)]
mod tests {
use super::*;
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 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:?}"),
}
}
}