Skip to main content

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