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}