ferrobridge_term/client.rs
1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! The client itself: one configured terminology server, one request at a
5//! time.
6
7use crate::config::{Config, Credentials, TokenFailure};
8use crate::error::{Error, UpstreamError};
9use crate::outcome::{
10 BatchOutcome, Coded, ExpandOutcome, Expansion, LookupOutcome, Request, SubsumesOutcome,
11 Subsumption, TranslateOutcome, Translation, ValidateOutcome, Validation,
12};
13use backon::{ExponentialBuilder, Retryable};
14use http::header::{ACCEPT, CONTENT_TYPE, WWW_AUTHENTICATE};
15use http::{HeaderMap, Method, StatusCode};
16use secrecy::ExposeSecret;
17use std::sync::Arc;
18use url::Url;
19
20/// The FHIR JSON media type (<https://hl7.org/fhir/R4/http.html#mime-type>).
21pub const FHIR_JSON: &str = "application/fhir+json";
22
23/// The path of the capability statement a FHIR server publishes
24/// (<https://hl7.org/fhir/R4/http.html#capabilities>).
25const METADATA: &str = "metadata";
26
27/// One request, described so that every attempt builds the same thing.
28struct Call {
29 /// The operation, as `Resource/$code`, or the batch marker.
30 operation: &'static str,
31 /// The absolute URL.
32 url: Url,
33 /// The FHIR JSON request body of a `POST`; a `GET` carries none.
34 body: Option<String>,
35}
36
37/// What the server answered, read whole.
38pub(crate) struct Answer {
39 /// The URL that was called.
40 pub(crate) url: Url,
41 /// The status the server answered with.
42 pub(crate) status: StatusCode,
43 /// The response body, as received.
44 pub(crate) body: String,
45}
46
47/// A client for one FHIR terminology server.
48///
49/// The client is cheap to clone: the HTTP stack and the configuration are
50/// shared, so a clone carries the same connection pool.
51#[derive(Debug, Clone)]
52pub struct Client {
53 /// The HTTP stack, with the configured timeout.
54 http: reqwest::Client,
55 /// The server this client talks to.
56 config: Arc<Config>,
57}
58
59impl Client {
60 /// Returns a client for the server `config` describes.
61 ///
62 /// This constructor is the only way to obtain a client, so a deployment
63 /// that configures no terminology server holds no [`Client`] at all and
64 /// the absence is a compile-time `Option<Client>` rather than a runtime
65 /// fallback.
66 ///
67 /// # Errors
68 /// Returns [`Error::BaseUrl`] when the base URL cannot carry a path, and
69 /// [`Error::ClientBuild`] when the HTTP stack refuses the configuration.
70 pub fn new(config: Config) -> Result<Self, Error> {
71 if config.base_url.cannot_be_a_base() {
72 return Err(Error::BaseUrl {
73 base: config.base_url.clone(),
74 });
75 }
76 let http = reqwest::Client::builder()
77 .timeout(config.timeout)
78 .build()
79 .map_err(|source| Error::ClientBuild { source })?;
80 Ok(Self {
81 http,
82 config: Arc::new(config),
83 })
84 }
85
86 /// Returns the configuration this client was built from.
87 #[must_use]
88 pub fn config(&self) -> &Config {
89 &self.config
90 }
91
92 /// Checks that the configured release can carry `request`, sending
93 /// nothing.
94 ///
95 /// A caller that sends several requests uses this to set aside one the
96 /// release cannot carry, since a batch holding it is refused whole.
97 ///
98 /// # Errors
99 /// Returns [`Error::UnsupportedParameter`] when `request` names a
100 /// parameter the configured release does not declare: a concept map
101 /// `url` or a value set version on an STU3 client.
102 pub fn check(&self, request: &Request) -> Result<(), Error> {
103 let version = self.config.wire_version;
104 match crate::wire::unsupported(version, request) {
105 Some(parameter) => Err(Error::UnsupportedParameter {
106 operation: request.operation(),
107 parameter,
108 wire_version: version,
109 }),
110 None => Ok(()),
111 }
112 }
113
114 /// Returns the status the server answers `GET [base]/metadata` with.
115 ///
116 /// Every FHIR REST server states its capabilities at that path
117 /// (<https://hl7.org/fhir/R4/http.html#capabilities>), so a reachability
118 /// question needs no terminology operation. The call is not retried, and
119 /// every status is returned as it stands, `401` and `404` included; under
120 /// [`Credentials::OAuth`] a refused token is renewed once first.
121 ///
122 /// # Errors
123 /// Returns [`Error::BaseUrl`] when the base URL cannot carry a path,
124 /// [`Error::Token`] when no token could be obtained, [`Error::Timeout`]
125 /// when the server did not answer inside the configured budget, and
126 /// [`Error::Transport`] when the request never reached it.
127 pub async fn reachability(&self) -> Result<StatusCode, Error> {
128 let url = self.url(METADATA)?;
129 let (response, renewal) = self
130 .send(&url, || {
131 self.http
132 .request(Method::GET, url.clone())
133 .header(ACCEPT, FHIR_JSON)
134 })
135 .await?;
136 if let Some(source) = renewal {
137 return Err(Error::Token { url, source });
138 }
139 let status = response.status();
140 tracing::debug!(path = url.path(), status = %status, "the terminology server answered a probe");
141 Ok(status)
142 }
143
144 /// Returns the response to the request `build` makes under the configured
145 /// credentials, with the provider's failure when the server refused an
146 /// OAuth token and no new one could be obtained.
147 ///
148 /// A refused OAuth token is dropped and the request sent once more under a
149 /// new one; a second `401` is returned as it stands.
150 async fn send(
151 &self,
152 url: &Url,
153 build: impl Fn() -> reqwest::RequestBuilder,
154 ) -> Result<(reqwest::Response, Option<TokenFailure>), Error> {
155 let provider = match self.config.credentials.as_ref() {
156 None => return Ok((dispatch(url, build()).await?, None)),
157 Some(Credentials::Basic { user, password }) => {
158 let request = build().basic_auth(user, Some(password.expose_secret()));
159 return Ok((dispatch(url, request).await?, None));
160 }
161 Some(Credentials::OAuth(provider)) => provider,
162 };
163 let token = provider.token().await.map_err(|source| Error::Token {
164 url: url.clone(),
165 source,
166 })?;
167 let response = dispatch(url, build().bearer_auth(token.expose_secret())).await?;
168 // NOTE: RFC 9110 ยง15.5.2, a `401` means the request was not applied, so
169 // sending it once more under a renewed token cannot apply it twice.
170 if response.status() != StatusCode::UNAUTHORIZED {
171 return Ok((response, None));
172 }
173 provider.invalidate(&token);
174 match provider.token().await {
175 Ok(renewed) => {
176 tracing::debug!(
177 path = url.path(),
178 "the terminology server refused the token; re-sending once under a renewed one"
179 );
180 let request = build().bearer_auth(renewed.expose_secret());
181 Ok((dispatch(url, request).await?, None))
182 }
183 Err(source) => Ok((response, Some(source))),
184 }
185 }
186
187 /// Resolves the display of `code` in `system`.
188 ///
189 /// A server that does not know the code answers [`LookupOutcome::NotFound`]
190 /// carrying its `OperationOutcome`; the display is never an empty string.
191 ///
192 /// # Errors
193 /// Returns [`Error::Refused`] when the server refused the operation for
194 /// another reason, and [`Error`] otherwise when the call did not reach an
195 /// answer or the answer could not be decoded.
196 pub async fn lookup(
197 &self,
198 system: &str,
199 code: &str,
200 version: Option<&str>,
201 ) -> Result<LookupOutcome, Error> {
202 self.send_lookup(Request::Lookup {
203 system: system.to_owned(),
204 code: code.to_owned(),
205 version: version.map(str::to_owned),
206 date: None,
207 })
208 .await
209 }
210
211 /// Resolves the display of `code` in `system` as the code system stood
212 /// on `date`, a FHIR `dateTime` sent as the `date` input
213 /// (<https://hl7.org/fhir/R4/codesystem-operation-lookup.html>).
214 ///
215 /// # Errors
216 /// As [`Client::lookup`].
217 pub async fn lookup_at(
218 &self,
219 system: &str,
220 code: &str,
221 version: Option<&str>,
222 date: &str,
223 ) -> Result<LookupOutcome, Error> {
224 self.send_lookup(Request::Lookup {
225 system: system.to_owned(),
226 code: code.to_owned(),
227 version: version.map(str::to_owned),
228 date: Some(date.to_owned()),
229 })
230 .await
231 }
232
233 /// Returns the outcome of the `$lookup` `request`.
234 async fn send_lookup(&self, request: Request) -> Result<LookupOutcome, Error> {
235 let answer = self.operation(&request).await?;
236 crate::decode::lookup(self.config.wire_version, &answer)
237 }
238
239 /// Expands the value set `expansion` names, with the page, filter, date
240 /// and designations it asks for
241 /// (<https://hl7.org/fhir/R4/valueset-operation-expand.html>).
242 ///
243 /// The answer keeps the nesting of `expansion.contains`. A value set the
244 /// server does not know is [`ExpandOutcome::NotFound`] carrying its
245 /// `OperationOutcome`, never an empty expansion.
246 ///
247 /// # Errors
248 /// Returns [`Error::Refused`] when the server refused the expansion for
249 /// another reason, `too-costly` among them, and [`Error`] otherwise when
250 /// the call did not reach an answer or the answer could not be decoded.
251 /// Returns [`Error::UnsupportedParameter`] when the expansion names a
252 /// value set version on an STU3 client, and [`Error::ParameterRange`]
253 /// when `offset` or `count` exceeds a FHIR `integer`; nothing is sent.
254 pub async fn expand(&self, expansion: &Expansion) -> Result<ExpandOutcome, Error> {
255 let request = Request::Expand(expansion.clone());
256 let answer = self.operation(&request).await?;
257 crate::decode::expand(self.config.wire_version, &answer)
258 }
259
260 /// Tests how the two codes `subsumption` names relate in their code
261 /// system (<https://hl7.org/fhir/R4/codesystem-operation-subsumes.html>).
262 ///
263 /// # Errors
264 /// Returns [`Error::Refused`] when the server could not determine the
265 /// relationship, for example because it does not know a code, carrying
266 /// the `OperationOutcome` and its `tx-issue-type` coding, and [`Error`]
267 /// otherwise when the call did not reach an answer or the answer could
268 /// not be decoded. An `outcome` outside the four codes is
269 /// [`Error::Body`].
270 pub async fn subsumes(&self, subsumption: &Subsumption) -> Result<SubsumesOutcome, Error> {
271 let request = Request::Subsumes(subsumption.clone());
272 let answer = self.operation(&request).await?;
273 crate::decode::subsumes(self.config.wire_version, &answer)
274 }
275
276 /// Returns how many `CodeSystem` resources the server holds under the
277 /// canonical URL `system`, asked as
278 /// `GET [base]/CodeSystem?url=<system>&_summary=count`
279 /// (<https://hl7.org/fhir/R4/search.html#summary>).
280 ///
281 /// A count of `0` says the server does not serve the code system, which
282 /// tells an unknown code from an unserved system after a bare `404` from
283 /// [`Client::lookup`].
284 ///
285 /// # Errors
286 /// Returns [`Error::Refused`] when the server refused the search, and
287 /// [`Error::Body`] when the answer is not a `searchset` `Bundle` stating
288 /// its `total`; [`Error`] otherwise when the call did not reach an
289 /// answer.
290 pub async fn code_system_count(&self, system: &str) -> Result<u32, Error> {
291 let mut url = self.url("CodeSystem")?;
292 url.query_pairs_mut()
293 .append_pair("url", system)
294 .append_pair("_summary", "count");
295 let answer = self
296 .execute(Call {
297 operation: crate::CODE_SYSTEM_SEARCH,
298 url,
299 body: None,
300 })
301 .await?;
302 crate::decode::search_total(self.config.wire_version, &answer)
303 }
304
305 /// Translates the code `translation` names, through the concept map,
306 /// into the target system and in the direction it names.
307 ///
308 /// `url`, `targetsystem` and `reverse` are each sent only when the
309 /// translation sets them, as R4 declares each `0..1`
310 /// (<https://hl7.org/fhir/R4/conceptmap-operation-translate.html>).
311 /// Every match the server returned is carried with its equivalence, so the
312 /// caller decides which ones count;
313 /// [`TranslateOutcome::accepted`] yields the `equivalent` and `equal` ones.
314 ///
315 /// # Examples
316 ///
317 /// A deployment with no terminology server holds no client, and a
318 /// translation it cannot make is a refusal:
319 ///
320 /// ```rust,no_run
321 /// use ferrobridge_term::client::Client;
322 /// use ferrobridge_term::outcome::Translation;
323 ///
324 /// # fn main() {
325 /// # let terminology: Option<Client> = None;
326 /// # let _call = async move {
327 /// let Some(client) = terminology.as_ref() else {
328 /// return Err("no terminology server is configured".into());
329 /// };
330 /// let translation = Translation::new("http://example.org/source", "alpha")
331 /// .with_concept_map("http://example.org/ConceptMap/source-to-target");
332 /// let outcome = client.translate(&translation).await?;
333 /// let Some(translated) = outcome.accepted().next() else {
334 /// return Err("no equivalent translation".into());
335 /// };
336 /// let code = translated.concept.as_ref().and_then(|concept| concept.code.as_deref());
337 /// assert!(code.is_some(), "an accepted match names its target code");
338 /// # Ok::<(), Box<dyn std::error::Error>>(())
339 /// # };
340 /// # }
341 /// ```
342 ///
343 /// # Errors
344 /// Returns [`Error::Refused`] when the server refused the operation, for
345 /// example because it serves no such concept map, and [`Error`] otherwise
346 /// when the call did not reach an answer or the answer could not be
347 /// decoded. A refusal is never a missing translation. Returns
348 /// [`Error::UnsupportedParameter`] when the translation names a concept
349 /// map on an STU3 client, whose `$translate` has no `url`; nothing is
350 /// sent.
351 pub async fn translate(&self, translation: &Translation) -> Result<TranslateOutcome, Error> {
352 let request = Request::Translate(translation.clone());
353 let answer = self.operation(&request).await?;
354 crate::decode::translate(self.config.wire_version, &answer)
355 }
356
357 /// Validates that `code` from `system` is a member of `value_set_url`.
358 ///
359 /// # Errors
360 /// Returns [`Error::Refused`] when the server refused the operation, for
361 /// example because it serves no such value set, and [`Error`] otherwise
362 /// when the call did not reach an answer or the answer could not be
363 /// decoded. A refusal is never an invalid code.
364 pub async fn validate_code(
365 &self,
366 value_set_url: &str,
367 system: &str,
368 code: &str,
369 ) -> Result<ValidateOutcome, Error> {
370 self.validate(&Validation {
371 value_set_url: value_set_url.to_owned(),
372 value_set_version: None,
373 date: None,
374 coded: Coded::Code {
375 system: system.to_owned(),
376 code: code.to_owned(),
377 version: None,
378 display: None,
379 },
380 })
381 .await
382 }
383
384 /// Validates that the coded value of `validation` is a member of its
385 /// value set, in any of the three forms the operation accepts: a code
386 /// with its system, version and display, a `Coding`, or a
387 /// `CodeableConcept`
388 /// (<https://hl7.org/fhir/R4/valueset-operation-validate-code.html>).
389 ///
390 /// # Errors
391 /// Returns [`Error::Refused`] when the server refused the operation, and
392 /// [`Error`] otherwise when the call did not reach an answer or the answer
393 /// could not be decoded. A refusal is never an invalid code. Returns
394 /// [`Error::UnsupportedParameter`] when the validation names a value set
395 /// version on an STU3 client, whose `$validate-code` has no
396 /// `valueSetVersion`; nothing is sent.
397 pub async fn validate(&self, validation: &Validation) -> Result<ValidateOutcome, Error> {
398 let request = Request::ValidateCode(validation.clone());
399 let answer = self.operation(&request).await?;
400 crate::decode::validate_code(self.config.wire_version, &answer)
401 }
402
403 /// Validates every one of `validations`, answering them by position: one
404 /// call for one validation, one `batch` `Bundle` for several.
405 ///
406 /// An entry the server refused is that entry's own typed error and leaves
407 /// the others alone, as in [`Client::batch`].
408 ///
409 /// # Errors
410 /// Returns [`Error::BatchArity`] when a batch answer holds a different
411 /// number of entries than were sent, and [`Error`] otherwise when the
412 /// batch itself did not reach an answer or its `Bundle` could not be
413 /// decoded. Returns [`Error::UnsupportedParameter`] when one of several
414 /// validations names a parameter the configured release does not
415 /// declare, and then no entry is sent.
416 pub async fn validate_all(
417 &self,
418 validations: &[Validation],
419 ) -> Result<Vec<Result<ValidateOutcome, Error>>, Error> {
420 match validations {
421 [] => Ok(Vec::new()),
422 [one] => Ok(vec![self.validate(one).await]),
423 several => {
424 let requests: Vec<Request> =
425 several.iter().cloned().map(Request::ValidateCode).collect();
426 let version = self.config.wire_version;
427 Ok(self
428 .batch_answers(&requests)
429 .await?
430 .into_iter()
431 .map(|answer| crate::decode::validate_code(version, &answer))
432 .collect())
433 }
434 }
435 }
436
437 /// Sends `requests` as one `batch` `Bundle` and answers them by position.
438 ///
439 /// "The server processes each entry independently" and answers a
440 /// `batch-response` whose entries are "in the same order as the request"
441 /// (<https://hl7.org/fhir/R4/http.html#transaction>), so entry `n` is the
442 /// answer to request `n`. An entry the server refused is that entry's own
443 /// typed error and leaves the others alone.
444 ///
445 /// # Errors
446 /// Returns [`Error::BatchArity`] when the answer holds a different number
447 /// of entries than were sent, and [`Error`] otherwise when the batch
448 /// itself did not reach an answer or its `Bundle` could not be decoded.
449 /// Returns [`Error::UnsupportedParameter`] when a request names a
450 /// parameter the configured release does not declare, and then no entry
451 /// is sent.
452 pub async fn batch(
453 &self,
454 requests: &[Request],
455 ) -> Result<Vec<Result<BatchOutcome, Error>>, Error> {
456 let version = self.config.wire_version;
457 Ok(requests
458 .iter()
459 .zip(self.batch_answers(requests).await?)
460 .map(|(request, answer)| crate::decode::batch_entry(version, request, &answer))
461 .collect())
462 }
463
464 /// Sends `requests` as one `batch` `Bundle` and returns each entry's
465 /// answer, in request order.
466 async fn batch_answers(&self, requests: &[Request]) -> Result<Vec<Answer>, Error> {
467 let version = self.config.wire_version;
468 let body = crate::wire::batch_body(version, requests)
469 .map_err(|source| source.into_error(crate::BATCH, version))?;
470 let answer = self
471 .execute(Call {
472 operation: crate::BATCH,
473 url: self.config.base_url.clone(),
474 body: Some(body),
475 })
476 .await?;
477 if answer.status != StatusCode::OK {
478 return Err(self.refusal(crate::BATCH, &answer));
479 }
480 let entries =
481 crate::wire::batch_entries(version, &answer.body).map_err(|source| Error::Body {
482 operation: crate::BATCH,
483 url: answer.url.clone(),
484 source: Box::new(source),
485 })?;
486 if entries.len() != requests.len() {
487 return Err(Error::BatchArity {
488 sent: requests.len(),
489 answered: entries.len(),
490 });
491 }
492 Ok(entries
493 .into_iter()
494 .map(|entry| Answer {
495 url: answer.url.clone(),
496 status: entry.status,
497 body: entry.body,
498 })
499 .collect())
500 }
501
502 /// Returns the answer to the `POST` form of `request`.
503 ///
504 /// The operations are invoked with a `Parameters` body, the form that
505 /// carries every input whatever its type
506 /// (<https://hl7.org/fhir/R4/operations.html>).
507 async fn operation(&self, request: &Request) -> Result<Answer, Error> {
508 let operation = request.operation();
509 let version = self.config.wire_version;
510 let body = crate::wire::request_body(version, request)
511 .map_err(|source| source.into_error(operation, version))?;
512 let url = self.url(operation)?;
513 self.execute(Call {
514 operation,
515 url,
516 body: Some(body),
517 })
518 .await
519 }
520
521 /// Returns the absolute URL of `operation` under the configured base.
522 fn url(&self, operation: &str) -> Result<Url, Error> {
523 let mut url = self.config.base_url.clone();
524 {
525 let mut path = url.path_segments_mut().map_err(|()| Error::BaseUrl {
526 base: self.config.base_url.clone(),
527 })?;
528 path.pop_if_empty();
529 path.extend(operation.split('/'));
530 }
531 Ok(url)
532 }
533
534 /// Returns the answer to `call`, retrying it when the budget allows.
535 async fn execute(&self, call: Call) -> Result<Answer, Error> {
536 let client = self;
537 let request = &call;
538 let attempt = move || async move { client.attempt(request).await };
539 attempt
540 .retry(self.backoff())
541 .when(Error::is_retryable)
542 .notify(|error: &Error, delay| {
543 tracing::warn!(
544 error = error.kind(),
545 delay_ms = delay.as_millis(),
546 "retrying a terminology request"
547 );
548 })
549 .await
550 }
551
552 /// Returns the retry schedule the configuration asks for.
553 fn backoff(&self) -> ExponentialBuilder {
554 let retries =
555 usize::try_from(self.config.retry.max_attempts.saturating_sub(1)).unwrap_or(usize::MAX);
556 ExponentialBuilder::new()
557 .with_min_delay(self.config.retry.initial_backoff)
558 .with_max_delay(self.config.retry.max_backoff)
559 .with_max_times(retries)
560 }
561
562 /// Sends `call` once and reads the whole answer.
563 async fn attempt(&self, call: &Call) -> Result<Answer, Error> {
564 let (response, renewal) = self
565 .send(&call.url, || match &call.body {
566 Some(body) => self
567 .http
568 .request(Method::POST, call.url.clone())
569 .header(ACCEPT, FHIR_JSON)
570 .header(CONTENT_TYPE, FHIR_JSON)
571 .body(body.clone()),
572 None => self
573 .http
574 .request(Method::GET, call.url.clone())
575 .header(ACCEPT, FHIR_JSON),
576 })
577 .await?;
578
579 let status = response.status();
580 let headers = response.headers().clone();
581 let bytes = response.bytes().await.map_err(|source| Error::Transport {
582 url: call.url.clone(),
583 source,
584 })?;
585 let answer = Answer {
586 url: call.url.clone(),
587 status,
588 body: String::from_utf8_lossy(&bytes).into_owned(),
589 };
590 // A code is a clinical fact, so the log carries the path and never the
591 // body or the query.
592 tracing::debug!(
593 operation = call.operation,
594 path = answer.url.path(),
595 status = %status,
596 "the terminology server answered"
597 );
598
599 if status == StatusCode::UNAUTHORIZED
600 && let Some(source) = renewal
601 {
602 return Err(Error::Renewal {
603 url: answer.url.clone(),
604 upstream: Box::new(self.upstream(&answer)),
605 source,
606 });
607 }
608 if status == StatusCode::UNAUTHORIZED {
609 return Err(Error::Unauthorized {
610 url: answer.url.clone(),
611 challenge: challenge(&headers),
612 upstream: Box::new(self.upstream(&answer)),
613 });
614 }
615 if status.is_server_error() {
616 return Err(Error::ServerFailure {
617 url: answer.url.clone(),
618 upstream: Box::new(self.upstream(&answer)),
619 });
620 }
621 Ok(answer)
622 }
623
624 /// Returns `answer` as an upstream refusal, read in the wire version.
625 fn upstream(&self, answer: &Answer) -> UpstreamError {
626 UpstreamError::new(self.config.wire_version, answer.status, answer.body.clone())
627 }
628
629 /// Returns the error for an answer the operation cannot read.
630 fn refusal(&self, operation: &'static str, answer: &Answer) -> Error {
631 Error::Refused {
632 operation,
633 url: answer.url.clone(),
634 upstream: Box::new(self.upstream(answer)),
635 }
636 }
637}
638
639/// Returns the response to `request`, sent once, for `url`.
640async fn dispatch(url: &Url, request: reqwest::RequestBuilder) -> Result<reqwest::Response, Error> {
641 request.send().await.map_err(|source| {
642 if source.is_timeout() {
643 Error::Timeout {
644 url: url.clone(),
645 source,
646 }
647 } else {
648 Error::Transport {
649 url: url.clone(),
650 source,
651 }
652 }
653 })
654}
655
656/// Returns the `WWW-Authenticate` challenge, when the answer carries usable
657/// text.
658fn challenge(headers: &HeaderMap) -> Option<String> {
659 headers
660 .get(WWW_AUTHENTICATE)
661 .and_then(|value| value.to_str().ok())
662 .map(str::to_owned)
663}
664
665#[cfg(test)]
666mod tests {
667 use super::Client;
668 use crate::config::{Config, WireVersion};
669
670 fn client(base: &str) -> Client {
671 Client::new(Config::new(
672 base.parse().expect("a valid URL"),
673 WireVersion::R4,
674 ))
675 .expect("a buildable client")
676 }
677
678 #[test]
679 fn an_operation_url_sits_under_the_versioned_base() {
680 let url = client("http://tx.invalid/fhir/r4")
681 .url("CodeSystem/$lookup")
682 .expect("a URL under the base");
683 assert_eq!("http://tx.invalid/fhir/r4/CodeSystem/$lookup", url.as_str());
684 }
685
686 #[test]
687 fn a_trailing_slash_on_the_base_does_not_double() {
688 let url = client("http://tx.invalid/r4b/")
689 .url("ValueSet/$validate-code")
690 .expect("a URL under the base");
691 assert_eq!(
692 "http://tx.invalid/r4b/ValueSet/$validate-code",
693 url.as_str()
694 );
695 }
696}