Skip to main content

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}