Skip to main content

ferrobridge_term/
config.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! How a client is pointed at one terminology server: its versioned base URL,
5//! its wire version, its budget, and its credentials.
6//!
7//! No specification governs the timeout, the retry budget or the credential
8//! form: our own design. FHIR leaves authentication to the deployment
9//! (<https://hl7.org/fhir/R4/security.html>), so the credentials are optional.
10
11use secrecy::SecretString;
12use std::fmt;
13use std::sync::Arc;
14use std::time::Duration;
15use url::Url;
16
17/// The FHIR release a server answers in.
18///
19/// The release decides which generated module writes the request and reads
20/// the `Parameters` and the `OperationOutcome` of a call. R4 and R4B declare
21/// the same `in` and `out` parameters for the three operations this client
22/// sends. STU3 declares fewer, so a request naming a parameter STU3 has no
23/// equivalent for is refused with
24/// [`Error::UnsupportedParameter`](crate::error::Error::UnsupportedParameter)
25/// before anything is sent.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27#[non_exhaustive]
28pub enum WireVersion {
29    /// FHIR STU3, release 3.0.2 (<https://hl7.org/fhir/STU3/>).
30    Stu3,
31    /// FHIR R4 (<https://hl7.org/fhir/R4/>).
32    R4,
33    /// FHIR R4B (<https://hl7.org/fhir/R4B/>).
34    R4B,
35}
36
37impl WireVersion {
38    /// Returns the release name as the FHIR specification spells it.
39    #[must_use]
40    pub const fn as_str(self) -> &'static str {
41        match self {
42            Self::Stu3 => "STU3",
43            Self::R4 => "R4",
44            Self::R4B => "R4B",
45        }
46    }
47}
48
49/// The credentials the client presents on every request.
50///
51/// Every secret is a [`SecretString`], so neither a `Debug` rendering nor a
52/// log line can carry it.
53#[derive(Debug, Clone)]
54#[non_exhaustive]
55pub enum Credentials {
56    /// RFC 7617 basic authentication.
57    Basic {
58        /// The user name, which is not a secret.
59        user: String,
60        /// The password.
61        password: SecretString,
62    },
63    /// An RFC 6750 bearer token the provider obtains with the OAuth 2.0
64    /// client credentials grant (RFC 6749 §4.4), renewed once when the server
65    /// answers `401`.
66    OAuth(Arc<dyn TokenProvider>),
67}
68
69/// Why a [`TokenProvider`] obtained no token.
70///
71/// The provider's own error is its [`source`](std::error::Error::source), so
72/// a caller walking the chain reaches that error's type.
73#[derive(Debug, Clone)]
74pub struct TokenFailure(Arc<dyn std::error::Error + Send + Sync>);
75
76impl TokenFailure {
77    /// Returns the failure the provider's error `cause` describes.
78    #[must_use]
79    pub fn new(cause: Arc<dyn std::error::Error + Send + Sync>) -> Self {
80        Self(cause)
81    }
82}
83
84impl fmt::Display for TokenFailure {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.write_str("the token provider obtained no token")
87    }
88}
89
90impl std::error::Error for TokenFailure {
91    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
92        Some(&*self.0)
93    }
94}
95
96/// The source of the tokens a [`Credentials::OAuth`] client sends.
97///
98/// The client asks for a token before every attempt. When the server answers
99/// `401`, it hands the refused token back through
100/// [`TokenProvider::invalidate`], asks once more, and re-sends the request
101/// once, since a `401` means the server applied nothing (RFC 9110 §15.5.2).
102#[async_trait::async_trait]
103pub trait TokenProvider: fmt::Debug + Send + Sync {
104    /// Returns the token to send.
105    ///
106    /// # Errors
107    /// Returns the provider's own failure when no token can be obtained.
108    async fn token(&self) -> Result<SecretString, TokenFailure>;
109
110    /// Drops `stale`, a token the server refused, so the next
111    /// [`TokenProvider::token`] obtains a new one. A token the provider has
112    /// already replaced stays.
113    fn invalidate(&self, stale: &SecretString);
114}
115
116/// How often, and how far apart, a call is retried.
117///
118/// The delay grows exponentially from `initial_backoff` and is capped at
119/// `max_backoff`; `max_attempts` counts the first try, so `1` disables retry.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct RetryPolicy {
122    /// How many times the request is sent at most, the first try included.
123    pub max_attempts: u32,
124    /// The delay before the second attempt.
125    pub initial_backoff: Duration,
126    /// The ceiling every later delay is clamped to.
127    pub max_backoff: Duration,
128}
129
130impl Default for RetryPolicy {
131    fn default() -> Self {
132        Self {
133            max_attempts: 3,
134            initial_backoff: Duration::from_millis(200),
135            max_backoff: Duration::from_secs(5),
136        }
137    }
138}
139
140/// Everything one [`Client`](crate::client::Client) needs about one
141/// terminology server.
142///
143/// `base_url` is the FHIR service base URL, the path `CodeSystem`,
144/// `ConceptMap` and `ValueSet` live under
145/// (<https://hl7.org/fhir/R4/http.html#root>). A server that serves several
146/// releases side by side puts the release in that path, so the base URL and
147/// `wire_version` are set together and must agree.
148#[derive(Debug, Clone)]
149pub struct Config {
150    /// The FHIR service base URL.
151    pub base_url: Url,
152    /// The release the server answers in.
153    pub wire_version: WireVersion,
154    /// How long one request may take, connection included.
155    pub timeout: Duration,
156    /// The retry budget.
157    pub retry: RetryPolicy,
158    /// The credentials, when the deployment needs them.
159    pub credentials: Option<Credentials>,
160}
161
162impl Config {
163    /// Returns a configuration for the server whose base URL is `base_url`.
164    ///
165    /// The timeout and the retry budget take their defaults, and no
166    /// credentials are set.
167    #[must_use]
168    pub fn new(base_url: Url, wire_version: WireVersion) -> Self {
169        Self {
170            base_url,
171            wire_version,
172            timeout: Duration::from_secs(30),
173            retry: RetryPolicy::default(),
174            credentials: None,
175        }
176    }
177
178    /// Returns this configuration with `timeout` as its per-request budget.
179    #[must_use]
180    pub fn with_timeout(mut self, timeout: Duration) -> Self {
181        self.timeout = timeout;
182        self
183    }
184
185    /// Returns this configuration with `retry` as its retry budget.
186    #[must_use]
187    pub fn with_retry(mut self, retry: RetryPolicy) -> Self {
188        self.retry = retry;
189        self
190    }
191
192    /// Returns this configuration with `credentials` presented on every call.
193    #[must_use]
194    pub fn with_credentials(mut self, credentials: Credentials) -> Self {
195        self.credentials = Some(credentials);
196        self
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::{Config, Credentials, RetryPolicy, WireVersion};
203    use secrecy::SecretString;
204    use std::time::Duration;
205
206    fn config() -> Config {
207        Config::new(
208            "http://tx.invalid/r4".parse().expect("a valid URL"),
209            WireVersion::R4,
210        )
211    }
212
213    #[test]
214    fn a_basic_password_is_not_in_the_debug_rendering() {
215        let config = config().with_credentials(Credentials::Basic {
216            user: "bridge".to_owned(),
217            password: SecretString::from("s3cr3t-password"),
218        });
219        let rendered = format!("{config:?}");
220        assert!(!rendered.contains("s3cr3t-password"));
221        assert!(rendered.contains("bridge"));
222    }
223
224    #[test]
225    fn the_defaults_are_three_attempts_and_thirty_seconds() {
226        let config = config();
227        assert_eq!(Duration::from_secs(30), config.timeout);
228        assert_eq!(RetryPolicy::default(), config.retry);
229        assert_eq!(3, config.retry.max_attempts);
230    }
231
232    #[test]
233    fn a_wire_version_names_its_release() {
234        assert_eq!("STU3", WireVersion::Stu3.as_str());
235        assert_eq!("R4", WireVersion::R4.as_str());
236        assert_eq!("R4B", WireVersion::R4B.as_str());
237    }
238}