ferrobridge-term 0.1.129

The FerroBRIDGE FHIR terminology client for $lookup, $translate and $validate-code
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
// SPDX-FileCopyrightText: Cadasto B.V.
// SPDX-License-Identifier: BUSL-1.1

//! The client itself: one configured terminology server, one request at a
//! time.

use crate::config::{Config, Credentials, TokenFailure};
use crate::error::{Error, UpstreamError};
use crate::outcome::{
    BatchOutcome, Coded, LookupOutcome, Request, TranslateOutcome, Translation, ValidateOutcome,
    Validation,
};
use backon::{ExponentialBuilder, Retryable};
use http::header::{ACCEPT, CONTENT_TYPE, WWW_AUTHENTICATE};
use http::{HeaderMap, Method, StatusCode};
use secrecy::ExposeSecret;
use std::sync::Arc;
use url::Url;

/// The FHIR JSON media type (<https://hl7.org/fhir/R4/http.html#mime-type>).
pub const FHIR_JSON: &str = "application/fhir+json";

/// The path of the capability statement a FHIR server publishes
/// (<https://hl7.org/fhir/R4/http.html#capabilities>).
const METADATA: &str = "metadata";

/// One request, described so that every attempt builds the same thing.
struct Call {
    /// The operation, as `Resource/$code`, or the batch marker.
    operation: &'static str,
    /// The absolute URL.
    url: Url,
    /// The FHIR JSON request body.
    body: String,
}

/// What the server answered, read whole.
pub(crate) struct Answer {
    /// The URL that was called.
    pub(crate) url: Url,
    /// The status the server answered with.
    pub(crate) status: StatusCode,
    /// The response body, as received.
    pub(crate) body: String,
}

/// A client for one FHIR terminology server.
///
/// The client is cheap to clone: the HTTP stack and the configuration are
/// shared, so a clone carries the same connection pool.
#[derive(Debug, Clone)]
pub struct Client {
    /// The HTTP stack, with the configured timeout.
    http: reqwest::Client,
    /// The server this client talks to.
    config: Arc<Config>,
}

impl Client {
    /// Returns a client for the server `config` describes.
    ///
    /// This constructor is the only way to obtain a client, so a deployment
    /// that configures no terminology server holds no [`Client`] at all and
    /// the absence is a compile-time `Option<Client>` rather than a runtime
    /// fallback.
    ///
    /// # Errors
    /// Returns [`Error::BaseUrl`] when the base URL cannot carry a path, and
    /// [`Error::ClientBuild`] when the HTTP stack refuses the configuration.
    pub fn new(config: Config) -> Result<Self, Error> {
        if config.base_url.cannot_be_a_base() {
            return Err(Error::BaseUrl {
                base: config.base_url.clone(),
            });
        }
        let http = reqwest::Client::builder()
            .timeout(config.timeout)
            .build()
            .map_err(|source| Error::ClientBuild { source })?;
        Ok(Self {
            http,
            config: Arc::new(config),
        })
    }

    /// Returns the configuration this client was built from.
    #[must_use]
    pub fn config(&self) -> &Config {
        &self.config
    }

    /// Checks that the configured release can carry `request`, sending
    /// nothing.
    ///
    /// A caller that sends several requests uses this to set aside one the
    /// release cannot carry, since a batch holding it is refused whole.
    ///
    /// # Errors
    /// Returns [`Error::UnsupportedParameter`] when `request` names a
    /// parameter the configured release does not declare: a concept map
    /// `url` or a value set version on an STU3 client.
    pub fn check(&self, request: &Request) -> Result<(), Error> {
        let version = self.config.wire_version;
        match crate::wire::unsupported(version, request) {
            Some(parameter) => Err(Error::UnsupportedParameter {
                operation: request.operation(),
                parameter,
                wire_version: version,
            }),
            None => Ok(()),
        }
    }

    /// Returns the status the server answers `GET [base]/metadata` with.
    ///
    /// Every FHIR REST server states its capabilities at that path
    /// (<https://hl7.org/fhir/R4/http.html#capabilities>), so a reachability
    /// question needs no terminology operation. The call is not retried, and
    /// every status is returned as it stands, `401` and `404` included; under
    /// [`Credentials::OAuth`] a refused token is renewed once first.
    ///
    /// # Errors
    /// Returns [`Error::BaseUrl`] when the base URL cannot carry a path,
    /// [`Error::Token`] when no token could be obtained, [`Error::Timeout`]
    /// when the server did not answer inside the configured budget, and
    /// [`Error::Transport`] when the request never reached it.
    pub async fn reachability(&self) -> Result<StatusCode, Error> {
        let url = self.url(METADATA)?;
        let (response, renewal) = self
            .send(&url, || {
                self.http
                    .request(Method::GET, url.clone())
                    .header(ACCEPT, FHIR_JSON)
            })
            .await?;
        if let Some(source) = renewal {
            return Err(Error::Token { url, source });
        }
        let status = response.status();
        tracing::debug!(path = url.path(), status = %status, "the terminology server answered a probe");
        Ok(status)
    }

    /// Returns the response to the request `build` makes under the configured
    /// credentials, with the provider's failure when the server refused an
    /// OAuth token and no new one could be obtained.
    ///
    /// A refused OAuth token is dropped and the request sent once more under a
    /// new one; a second `401` is returned as it stands.
    async fn send(
        &self,
        url: &Url,
        build: impl Fn() -> reqwest::RequestBuilder,
    ) -> Result<(reqwest::Response, Option<TokenFailure>), Error> {
        let provider = match self.config.credentials.as_ref() {
            None => return Ok((dispatch(url, build()).await?, None)),
            Some(Credentials::Basic { user, password }) => {
                let request = build().basic_auth(user, Some(password.expose_secret()));
                return Ok((dispatch(url, request).await?, None));
            }
            Some(Credentials::OAuth(provider)) => provider,
        };
        let token = provider.token().await.map_err(|source| Error::Token {
            url: url.clone(),
            source,
        })?;
        let response = dispatch(url, build().bearer_auth(token.expose_secret())).await?;
        // NOTE: RFC 9110 §15.5.2, a `401` means the request was not applied, so
        // sending it once more under a renewed token cannot apply it twice.
        if response.status() != StatusCode::UNAUTHORIZED {
            return Ok((response, None));
        }
        provider.invalidate(&token);
        match provider.token().await {
            Ok(renewed) => {
                tracing::debug!(
                    path = url.path(),
                    "the terminology server refused the token; re-sending once under a renewed one"
                );
                let request = build().bearer_auth(renewed.expose_secret());
                Ok((dispatch(url, request).await?, None))
            }
            Err(source) => Ok((response, Some(source))),
        }
    }

    /// Resolves the display of `code` in `system`.
    ///
    /// A server that does not know the code answers [`LookupOutcome::NotFound`]
    /// carrying its `OperationOutcome`; the display is never an empty string.
    ///
    /// # Errors
    /// Returns [`Error::Refused`] when the server refused the operation for
    /// another reason, and [`Error`] otherwise when the call did not reach an
    /// answer or the answer could not be decoded.
    pub async fn lookup(
        &self,
        system: &str,
        code: &str,
        version: Option<&str>,
    ) -> Result<LookupOutcome, Error> {
        let request = Request::Lookup {
            system: system.to_owned(),
            code: code.to_owned(),
            version: version.map(str::to_owned),
        };
        let answer = self.operation(&request).await?;
        crate::decode::lookup(self.config.wire_version, &answer)
    }

    /// Translates the code `translation` names, through the concept map,
    /// into the target system and in the direction it names.
    ///
    /// `url`, `targetsystem` and `reverse` are each sent only when the
    /// translation sets them, as R4 declares each `0..1`
    /// (<https://hl7.org/fhir/R4/conceptmap-operation-translate.html>).
    /// Every match the server returned is carried with its equivalence, so the
    /// caller decides which ones count;
    /// [`TranslateOutcome::accepted`] yields the `equivalent` and `equal` ones.
    ///
    /// # Examples
    ///
    /// A deployment with no terminology server holds no client, and a
    /// translation it cannot make is a refusal:
    ///
    /// ```rust,no_run
    /// use ferrobridge_term::client::Client;
    /// use ferrobridge_term::outcome::Translation;
    ///
    /// # fn main() {
    /// # let terminology: Option<Client> = None;
    /// # let _call = async move {
    /// let Some(client) = terminology.as_ref() else {
    ///     return Err("no terminology server is configured".into());
    /// };
    /// let translation = Translation::new("http://example.org/source", "alpha")
    ///     .with_concept_map("http://example.org/ConceptMap/source-to-target");
    /// let outcome = client.translate(&translation).await?;
    /// let Some(translated) = outcome.accepted().next() else {
    ///     return Err("no equivalent translation".into());
    /// };
    /// let code = translated.concept.as_ref().and_then(|concept| concept.code.as_deref());
    /// assert!(code.is_some(), "an accepted match names its target code");
    /// # Ok::<(), Box<dyn std::error::Error>>(())
    /// # };
    /// # }
    /// ```
    ///
    /// # Errors
    /// Returns [`Error::Refused`] when the server refused the operation, for
    /// example because it serves no such concept map, and [`Error`] otherwise
    /// when the call did not reach an answer or the answer could not be
    /// decoded. A refusal is never a missing translation. Returns
    /// [`Error::UnsupportedParameter`] when the translation names a concept
    /// map on an STU3 client, whose `$translate` has no `url`; nothing is
    /// sent.
    pub async fn translate(&self, translation: &Translation) -> Result<TranslateOutcome, Error> {
        let request = Request::Translate(translation.clone());
        let answer = self.operation(&request).await?;
        crate::decode::translate(self.config.wire_version, &answer)
    }

    /// Validates that `code` from `system` is a member of `value_set_url`.
    ///
    /// # Errors
    /// Returns [`Error::Refused`] when the server refused the operation, for
    /// example because it serves no such value set, and [`Error`] otherwise
    /// when the call did not reach an answer or the answer could not be
    /// decoded. A refusal is never an invalid code.
    pub async fn validate_code(
        &self,
        value_set_url: &str,
        system: &str,
        code: &str,
    ) -> Result<ValidateOutcome, Error> {
        self.validate(&Validation {
            value_set_url: value_set_url.to_owned(),
            value_set_version: None,
            coded: Coded::Code {
                system: system.to_owned(),
                code: code.to_owned(),
                version: None,
                display: None,
            },
        })
        .await
    }

    /// Validates that the coded value of `validation` is a member of its
    /// value set, in any of the three forms the operation accepts: a code
    /// with its system, version and display, a `Coding`, or a
    /// `CodeableConcept`
    /// (<https://hl7.org/fhir/R4/valueset-operation-validate-code.html>).
    ///
    /// # Errors
    /// Returns [`Error::Refused`] when the server refused the operation, and
    /// [`Error`] otherwise when the call did not reach an answer or the answer
    /// could not be decoded. A refusal is never an invalid code. Returns
    /// [`Error::UnsupportedParameter`] when the validation names a value set
    /// version on an STU3 client, whose `$validate-code` has no
    /// `valueSetVersion`; nothing is sent.
    pub async fn validate(&self, validation: &Validation) -> Result<ValidateOutcome, Error> {
        let request = Request::ValidateCode(validation.clone());
        let answer = self.operation(&request).await?;
        crate::decode::validate_code(self.config.wire_version, &answer)
    }

    /// Validates every one of `validations`, answering them by position: one
    /// call for one validation, one `batch` `Bundle` for several.
    ///
    /// An entry the server refused is that entry's own typed error and leaves
    /// the others alone, as in [`Client::batch`].
    ///
    /// # Errors
    /// Returns [`Error::BatchArity`] when a batch answer holds a different
    /// number of entries than were sent, and [`Error`] otherwise when the
    /// batch itself did not reach an answer or its `Bundle` could not be
    /// decoded. Returns [`Error::UnsupportedParameter`] when one of several
    /// validations names a parameter the configured release does not
    /// declare, and then no entry is sent.
    pub async fn validate_all(
        &self,
        validations: &[Validation],
    ) -> Result<Vec<Result<ValidateOutcome, Error>>, Error> {
        match validations {
            [] => Ok(Vec::new()),
            [one] => Ok(vec![self.validate(one).await]),
            several => {
                let requests: Vec<Request> =
                    several.iter().cloned().map(Request::ValidateCode).collect();
                let version = self.config.wire_version;
                Ok(self
                    .batch_answers(&requests)
                    .await?
                    .into_iter()
                    .map(|answer| crate::decode::validate_code(version, &answer))
                    .collect())
            }
        }
    }

    /// Sends `requests` as one `batch` `Bundle` and answers them by position.
    ///
    /// "The server processes each entry independently" and answers a
    /// `batch-response` whose entries are "in the same order as the request"
    /// (<https://hl7.org/fhir/R4/http.html#transaction>), so entry `n` is the
    /// answer to request `n`. An entry the server refused is that entry's own
    /// typed error and leaves the others alone.
    ///
    /// # Errors
    /// Returns [`Error::BatchArity`] when the answer holds a different number
    /// of entries than were sent, and [`Error`] otherwise when the batch
    /// itself did not reach an answer or its `Bundle` could not be decoded.
    /// Returns [`Error::UnsupportedParameter`] when a request names a
    /// parameter the configured release does not declare, and then no entry
    /// is sent.
    pub async fn batch(
        &self,
        requests: &[Request],
    ) -> Result<Vec<Result<BatchOutcome, Error>>, Error> {
        let version = self.config.wire_version;
        Ok(requests
            .iter()
            .zip(self.batch_answers(requests).await?)
            .map(|(request, answer)| crate::decode::batch_entry(version, request, &answer))
            .collect())
    }

    /// Sends `requests` as one `batch` `Bundle` and returns each entry's
    /// answer, in request order.
    async fn batch_answers(&self, requests: &[Request]) -> Result<Vec<Answer>, Error> {
        let version = self.config.wire_version;
        let body = crate::wire::batch_body(version, requests)
            .map_err(|source| source.into_error(crate::BATCH, version))?;
        let answer = self
            .execute(Call {
                operation: crate::BATCH,
                url: self.config.base_url.clone(),
                body,
            })
            .await?;
        if answer.status != StatusCode::OK {
            return Err(self.refusal(crate::BATCH, &answer));
        }
        let entries =
            crate::wire::batch_entries(version, &answer.body).map_err(|source| Error::Body {
                operation: crate::BATCH,
                url: answer.url.clone(),
                source: Box::new(source),
            })?;
        if entries.len() != requests.len() {
            return Err(Error::BatchArity {
                sent: requests.len(),
                answered: entries.len(),
            });
        }
        Ok(entries
            .into_iter()
            .map(|entry| Answer {
                url: answer.url.clone(),
                status: entry.status,
                body: entry.body,
            })
            .collect())
    }

    /// Returns the answer to the `POST` form of `request`.
    ///
    /// The operations are invoked with a `Parameters` body, the form that
    /// carries every input whatever its type
    /// (<https://hl7.org/fhir/R4/operations.html>).
    async fn operation(&self, request: &Request) -> Result<Answer, Error> {
        let operation = request.operation();
        let version = self.config.wire_version;
        let body = crate::wire::request_body(version, request)
            .map_err(|source| source.into_error(operation, version))?;
        let url = self.url(operation)?;
        self.execute(Call {
            operation,
            url,
            body,
        })
        .await
    }

    /// Returns the absolute URL of `operation` under the configured base.
    fn url(&self, operation: &str) -> Result<Url, Error> {
        let mut url = self.config.base_url.clone();
        {
            let mut path = url.path_segments_mut().map_err(|()| Error::BaseUrl {
                base: self.config.base_url.clone(),
            })?;
            path.pop_if_empty();
            path.extend(operation.split('/'));
        }
        Ok(url)
    }

    /// Returns the answer to `call`, retrying it when the budget allows.
    async fn execute(&self, call: Call) -> Result<Answer, Error> {
        let client = self;
        let request = &call;
        let attempt = move || async move { client.attempt(request).await };
        attempt
            .retry(self.backoff())
            .when(Error::is_retryable)
            .notify(|error: &Error, delay| {
                tracing::warn!(
                    error = error.kind(),
                    delay_ms = delay.as_millis(),
                    "retrying a terminology request"
                );
            })
            .await
    }

    /// Returns the retry schedule the configuration asks for.
    fn backoff(&self) -> ExponentialBuilder {
        let retries =
            usize::try_from(self.config.retry.max_attempts.saturating_sub(1)).unwrap_or(usize::MAX);
        ExponentialBuilder::new()
            .with_min_delay(self.config.retry.initial_backoff)
            .with_max_delay(self.config.retry.max_backoff)
            .with_max_times(retries)
    }

    /// Sends `call` once and reads the whole answer.
    async fn attempt(&self, call: &Call) -> Result<Answer, Error> {
        let (response, renewal) = self
            .send(&call.url, || {
                self.http
                    .request(Method::POST, call.url.clone())
                    .header(ACCEPT, FHIR_JSON)
                    .header(CONTENT_TYPE, FHIR_JSON)
                    .body(call.body.clone())
            })
            .await?;

        let status = response.status();
        let headers = response.headers().clone();
        let bytes = response.bytes().await.map_err(|source| Error::Transport {
            url: call.url.clone(),
            source,
        })?;
        let answer = Answer {
            url: call.url.clone(),
            status,
            body: String::from_utf8_lossy(&bytes).into_owned(),
        };
        // A code is a clinical fact, so the log carries the path and never the
        // body or the query.
        tracing::debug!(
            operation = call.operation,
            path = answer.url.path(),
            status = %status,
            "the terminology server answered"
        );

        if status == StatusCode::UNAUTHORIZED
            && let Some(source) = renewal
        {
            return Err(Error::Renewal {
                url: answer.url.clone(),
                upstream: Box::new(self.upstream(&answer)),
                source,
            });
        }
        if status == StatusCode::UNAUTHORIZED {
            return Err(Error::Unauthorized {
                url: answer.url.clone(),
                challenge: challenge(&headers),
                upstream: Box::new(self.upstream(&answer)),
            });
        }
        if status.is_server_error() {
            return Err(Error::ServerFailure {
                url: answer.url.clone(),
                upstream: Box::new(self.upstream(&answer)),
            });
        }
        Ok(answer)
    }

    /// Returns `answer` as an upstream refusal, read in the wire version.
    fn upstream(&self, answer: &Answer) -> UpstreamError {
        UpstreamError::new(self.config.wire_version, answer.status, answer.body.clone())
    }

    /// Returns the error for an answer the operation cannot read.
    fn refusal(&self, operation: &'static str, answer: &Answer) -> Error {
        Error::Refused {
            operation,
            url: answer.url.clone(),
            upstream: Box::new(self.upstream(answer)),
        }
    }
}

/// Returns the response to `request`, sent once, for `url`.
async fn dispatch(url: &Url, request: reqwest::RequestBuilder) -> Result<reqwest::Response, Error> {
    request.send().await.map_err(|source| {
        if source.is_timeout() {
            Error::Timeout {
                url: url.clone(),
                source,
            }
        } else {
            Error::Transport {
                url: url.clone(),
                source,
            }
        }
    })
}

/// Returns the `WWW-Authenticate` challenge, when the answer carries usable
/// text.
fn challenge(headers: &HeaderMap) -> Option<String> {
    headers
        .get(WWW_AUTHENTICATE)
        .and_then(|value| value.to_str().ok())
        .map(str::to_owned)
}

#[cfg(test)]
mod tests {
    use super::Client;
    use crate::config::{Config, WireVersion};

    fn client(base: &str) -> Client {
        Client::new(Config::new(
            base.parse().expect("a valid URL"),
            WireVersion::R4,
        ))
        .expect("a buildable client")
    }

    #[test]
    fn an_operation_url_sits_under_the_versioned_base() {
        let url = client("http://tx.invalid/fhir/r4")
            .url("CodeSystem/$lookup")
            .expect("a URL under the base");
        assert_eq!("http://tx.invalid/fhir/r4/CodeSystem/$lookup", url.as_str());
    }

    #[test]
    fn a_trailing_slash_on_the_base_does_not_double() {
        let url = client("http://tx.invalid/r4b/")
            .url("ValueSet/$validate-code")
            .expect("a URL under the base");
        assert_eq!(
            "http://tx.invalid/r4b/ValueSet/$validate-code",
            url.as_str()
        );
    }
}