fhir_sdk/client/
mod.rs

1//! FHIR REST Client Implementation.
2
3mod aliases;
4mod auth;
5mod builder;
6mod error;
7mod fhir;
8mod misc;
9mod request;
10mod search;
11
12use std::{marker::PhantomData, sync::Arc};
13
14use ::std::any::type_name;
15use misc::parse_major_fhir_version;
16use reqwest::{StatusCode, Url, header};
17
18pub use self::{
19	aliases::*, auth::LoginManager, builder::ClientBuilder, error::Error, fhir::*,
20	request::RequestSettings, search::SearchParameters,
21};
22use self::{auth::AuthCallback, misc::make_uuid_header_value};
23use crate::version::{DefaultVersion, FhirR4B, FhirR5, FhirStu3, FhirVersion};
24
25/// FHIR REST Client.
26pub struct Client<Version = DefaultVersion>(Arc<ClientData>, PhantomData<Version>);
27
28/// FHIR Rest Client data.
29struct ClientData {
30	/// The FHIR server's base URL.
31	base_url: Url,
32	/// HTTP request client.
33	client: reqwest::Client,
34	/// Request settings.
35	request_settings: std::sync::Mutex<RequestSettings>,
36	/// Authorization callback method, returning the authorization header value.
37	auth_callback: tokio::sync::Mutex<Option<AuthCallback>>,
38
39	/// Whether to error if the server responds with a different major FHIR
40	/// version.
41	error_on_version_mismatch: bool,
42	/// Whether to error before we try to send a request to a different server
43	/// than is configured in the base URL. Also not applies to custom requests!
44	/// Reasoning is to avoid search results and references to resources on other
45	/// servers when this is not wanted.
46	error_on_origin_mismatch: bool,
47}
48
49impl<V: FhirVersion> From<ClientData> for Client<V> {
50	fn from(data: ClientData) -> Self {
51		Self(Arc::new(data), PhantomData)
52	}
53}
54
55impl<V: FhirVersion> Client<V> {
56	/// Start building a new client with custom settings.
57	#[must_use]
58	pub fn builder() -> ClientBuilder<V> {
59		ClientBuilder::default()
60	}
61
62	/// Create a new client with default settings.
63	pub fn new(base_url: Url) -> Result<Self, Error> {
64		Self::builder().base_url(base_url).build()
65	}
66
67	/// Get the configured base URL.
68	#[must_use]
69	pub fn base_url(&self) -> &Url {
70		&self.0.base_url
71	}
72
73	/// Get the URL with the configured base URL and the given path segments.
74	fn url(&self, segments: &[&str]) -> Url {
75		let mut url = self.0.base_url.clone();
76		#[allow(clippy::expect_used, reason = "We made sure of it in the constructor")]
77		url.path_segments_mut().expect("Base URL cannot be base").pop_if_empty().extend(segments);
78		url
79	}
80
81	/// Get the request settings configured in this client.
82	#[must_use]
83	pub fn request_settings(&self) -> RequestSettings {
84		#[allow(clippy::expect_used, reason = "only happens on panics, so we can panic again")]
85		self.0.request_settings.lock().expect("mutex poisened").clone()
86	}
87
88	/// Set the request settings for this client. Be warned that this can be
89	/// subject to race conditions. Prefer to use
90	/// [Self::patch_request_settings]. Basically same as
91	/// `client.patch_request_settings(|_| new_settings)`.
92	pub fn set_request_settings(&self, settings: RequestSettings) {
93		tracing::debug!("Setting new request settings");
94		#[allow(clippy::expect_used, reason = "only happens on panics, so we can panic again")]
95		let mut request_settings = self.0.request_settings.lock().expect("mutex poisened");
96		*request_settings = settings;
97	}
98
99	/// Patch the request settings atomically. Blocks all requests until the
100	/// change to request settings is finished.
101	pub fn patch_request_settings<F>(&self, mutator: F)
102	where
103		F: FnOnce(RequestSettings) -> RequestSettings,
104	{
105		tracing::debug!("Patching request settings");
106		#[allow(clippy::expect_used, reason = "only happens on panics, so we can panic again")]
107		let mut request_settings = self.0.request_settings.lock().expect("mutex poisened");
108		let patched = mutator(request_settings.clone());
109		*request_settings = patched;
110	}
111
112	/// Convert to a different version.
113	fn convert_version<Version>(self) -> Client<Version> {
114		Client(self.0, PhantomData)
115	}
116
117	/// Switch the client to STU3 mode.
118	#[must_use]
119	pub fn stu3(self) -> Client<FhirStu3> {
120		self.convert_version()
121	}
122
123	/// Switch the client to R4B mode.
124	#[must_use]
125	pub fn r4b(self) -> Client<FhirR4B> {
126		self.convert_version()
127	}
128
129	/// Switch the client to R5 mode.
130	#[must_use]
131	pub fn r5(self) -> Client<FhirR5> {
132		self.convert_version()
133	}
134
135	/// Run a request using the internal request settings, calling the auth
136	/// callback to retrieve a new Authorization header on `unauthtorized`
137	/// responses. Also adds the `X-Correlation-Id` header if not already present.
138	#[tracing::instrument(level = "info", skip_all, fields(x_correlation_id))]
139	async fn run_request(
140		&self,
141		mut request: reqwest::RequestBuilder,
142	) -> Result<reqwest::Response, Error> {
143		let (client, info_request_result) = request.build_split();
144		let mut info_request = info_request_result?;
145		let req_method = info_request.method().clone();
146		let req_url = info_request.url().clone();
147
148		// Check the URL origin if configured to ensure equality.
149		if self.0.error_on_origin_mismatch {
150			// Make sure we are not forwarded to any malicious server.
151			if info_request.url().origin() != self.0.base_url.origin() {
152				return Err(Error::DifferentOrigin(info_request.url().to_string()));
153			}
154		}
155
156		// Generate a new correlation ID for this request/transaction across login, if there was
157		// none.
158		let correlation_id = info_request
159			.headers_mut()
160			.entry("X-Correlation-Id")
161			.or_insert_with(make_uuid_header_value);
162		let x_correlation_id = correlation_id.to_str().ok().map(ToOwned::to_owned);
163		request = reqwest::RequestBuilder::from_parts(client, info_request);
164		tracing::Span::current().record("x_correlation_id", x_correlation_id);
165
166		// Try running the request
167		let mut request_settings = self.request_settings();
168		tracing::info!("Sending {req_method} request to {req_url} (potentially with retries)");
169		let mut response = request_settings
170			.make_request(request.try_clone().ok_or(Error::RequestNotClone)?)
171			.await?;
172
173		// On authorization failure, retry after refreshing the authorization header.
174		if response.status() == StatusCode::UNAUTHORIZED {
175			if let Ok(mut auth_callback) = self.0.auth_callback.try_lock() {
176				if let Some(auth_callback) = auth_callback.as_mut() {
177					tracing::info!("Hit unauthorized response, calling auth_callback");
178					let auth_value = auth_callback
179						.authenticate(self.0.client.clone())
180						.await
181						.map_err(|err| Error::AuthCallback(format!("{err:#}")))?;
182					self.patch_request_settings(move |settings| {
183						settings.header(header::AUTHORIZATION, auth_value)
184					});
185				} else {
186					// There is no auth callback, return without retrying.
187					return Ok(response);
188				}
189			} else {
190				// Auth callback was blocked, we assume there was a login in flight and update
191				// our request settings after it is done.
192				_ = self.0.auth_callback.lock().await;
193			}
194			// Retry request with new request settings.
195			request_settings = self.request_settings();
196			tracing::info!("Retrying request after authorization refresh");
197			response = request_settings.make_request(request).await?;
198		}
199
200		tracing::info!("Got response: {}", response.status());
201
202		// Test server FHIR version in response, if configured to do so.
203		if self.0.error_on_version_mismatch {
204			if let Some(version) = parse_major_fhir_version(response.headers())? {
205				let expected = V::VERSION.split_once('.').map_or(V::VERSION, |(major, _)| major);
206				if version != expected {
207					return Err(Error::DifferentFhirVersion(version.to_owned()));
208				}
209			}
210		}
211
212		Ok(response)
213	}
214
215	/// Send a custom HTTP request anywhere you want, but using this client's
216	/// internal HTTP machinery. The machinery includes automatic authentication
217	/// if configured (`auth_callback`) and automatic retrying of requests on
218	/// connection problems (as per `request_settings`).
219	///
220	/// Keep in mind that mismatching origins to the base URL are rejected if not explicitly allowed
221	/// via the flag in the builder ([ClientBuilder::allow_origin_mismatch]). Similarly, if the
222	/// server responds with a different major FHIR version than the client is configured for, the
223	/// response is rejected if not explicitly allowed via the flag in the builder
224	/// ([ClientBuilder::allow_version_mismatch]).
225	pub async fn send_custom_request<F>(&self, make_request: F) -> Result<reqwest::Response, Error>
226	where
227		F: FnOnce(&reqwest::Client) -> reqwest::RequestBuilder + Send,
228	{
229		let request = (make_request)(&self.0.client);
230		self.run_request(request).await
231	}
232}
233
234impl<V> Clone for Client<V> {
235	fn clone(&self) -> Self {
236		Self(self.0.clone(), self.1)
237	}
238}
239
240impl std::fmt::Debug for ClientData {
241	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242		let auth_callback = match self.auth_callback.try_lock() {
243			Ok(inside) => {
244				if inside.is_some() {
245					"Some(<login_manager>)"
246				} else {
247					"None"
248				}
249			}
250			Err(_) => "<blocked>",
251		};
252
253		f.debug_struct("ClientData")
254			.field("base_url", &self.base_url)
255			.field("client", &self.client)
256			.field("request_settings", &self.request_settings)
257			.field("auth_callback", &auth_callback)
258			.field("error_on_version_mismatch", &self.error_on_version_mismatch)
259			.field("error_on_origin_mismatch", &self.error_on_origin_mismatch)
260			.finish()
261	}
262}
263
264impl<V> std::fmt::Debug for Client<V> {
265	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266		f.debug_struct("Client").field("data", &self.0).field("version", &type_name::<V>()).finish()
267	}
268}
269
270#[cfg(test)]
271mod tests;