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}