Skip to main content

haste_repository/
utilities.rs

1use std::sync::LazyLock;
2
3use haste_fhir_model::r4::{
4    datetime::Instant,
5    generated::{
6        resources::Resource,
7        terminology::IssueType,
8        types::{Extension, ExtensionValueTypeChoice, FHIRId, Meta},
9    },
10};
11use haste_fhir_operation_error::{OperationOutcomeError, derive::OperationOutcomeError};
12use haste_jwt::{AuthorId, AuthorKind};
13use haste_reflect::MetaValue;
14
15static ID_CHARACTERS: &[char] = &[
16    '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i',
17    'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '-',
18];
19
20// [A-Za-z0-9\-\.]{1,64} See https://hl7.org/fhir/r4/datatypes.html#id
21// Can't use _ for compliance.
22pub fn generate_id(len: Option<usize>) -> String {
23    let len = len.unwrap_or(26);
24    nanoid::nanoid!(len, ID_CHARACTERS).to_string()
25}
26
27static ID_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
28    let characters_allowed = ID_CHARACTERS.iter().collect::<String>();
29    regex::Regex::new(&format!("^[{characters_allowed}]*$"))
30        .expect("ID_CHARACTERS should produce a valid regex")
31});
32
33/// Validates a FHIR resource ID.
34///
35/// # Errors
36///
37/// Returns an [`OperationOutcomeError`] if `id` contains characters that are
38/// not permitted in a FHIR resource ID.
39pub fn validate_id(id: &str) -> Result<(), OperationOutcomeError> {
40    if ID_REGEX.is_match(id) {
41        Ok(())
42    } else {
43        Err(OperationOutcomeError::fatal(
44            IssueType::invalid(),
45            format!("ID contains invalid characters: {id}"),
46        ))
47    }
48}
49
50/// Joins a tenant id and a project id into one subdomain label:
51/// `{tenant}--{project}`.
52pub const HOSTNAME_ID_SEPARATOR: &str = "--";
53
54/// A DNS label holds 63 characters. 8 stay unused, as room for a later
55/// prefix or suffix such as `-staging`.
56const HOSTNAME_LABEL_MAX_LEN: usize = 63 - 8;
57
58/// The shortest tenant or project id.
59pub const HOSTNAME_ID_MIN_LEN: usize = 3;
60
61/// A valid tenant or project id: lowercase letters and digits, joined by
62/// single hyphens. It is subdomain safe, and never holds
63/// [`HOSTNAME_ID_SEPARATOR`], so `{tenant}--{project}` splits back apart.
64///
65/// The sign-up form uses it as an HTML `pattern`, so it must stay valid under
66/// the browser's `v` regex flag (no unescaped `-` inside `[]`).
67pub const HOSTNAME_ID_PATTERN: &str = "[a-z0-9]+(-[a-z0-9]+)*";
68
69static HOSTNAME_ID_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
70    regex::Regex::new(&format!("^{HOSTNAME_ID_PATTERN}$"))
71        .expect("HOSTNAME_ID_PATTERN should be a valid regex")
72});
73
74/// Validates a tenant or project id: at least [`HOSTNAME_ID_MIN_LEN`]
75/// characters, matching [`HOSTNAME_ID_PATTERN`].
76///
77/// # Errors
78///
79/// Returns an [`OperationOutcomeError`] naming the rule `id` breaks.
80pub fn validate_hostname_id(id: &str) -> Result<(), OperationOutcomeError> {
81    let invalid =
82        |message: String| Err(OperationOutcomeError::error(IssueType::invalid(), message));
83
84    if id.len() < HOSTNAME_ID_MIN_LEN {
85        return invalid(format!(
86            "An id must be at least {HOSTNAME_ID_MIN_LEN} characters long."
87        ));
88    }
89
90    if !HOSTNAME_ID_REGEX.is_match(id) {
91        return invalid(
92            "An id may only contain lowercase letters, digits and single hyphens between them."
93                .to_string(),
94        );
95    }
96
97    Ok(())
98}
99
100/// Lowercase letters and digits. Generated ids leave out the hyphen, so they
101/// always match [`HOSTNAME_ID_PATTERN`].
102static HOSTNAME_ID_CHARACTERS: LazyLock<Vec<char>> = LazyLock::new(|| {
103    ID_CHARACTERS
104        .iter()
105        .copied()
106        .filter(|character| *character != '-')
107        .collect()
108});
109
110/// A random tenant or project id of `len` (default 26) letters and digits.
111#[must_use]
112pub fn generate_hostname_id(len: Option<usize>) -> String {
113    let len = len.unwrap_or(26);
114    nanoid::nanoid!(len, &HOSTNAME_ID_CHARACTERS)
115}
116
117/// Validates a project id, and that `{tenant}--{id}` is at most 55 characters:
118/// a DNS label, less the spare room.
119///
120/// # Errors
121///
122/// Returns an [`OperationOutcomeError`] if `id` breaks
123/// [`validate_hostname_id`] or is too long for `tenant`.
124pub fn validate_project_id(tenant: &str, id: &str) -> Result<(), OperationOutcomeError> {
125    validate_hostname_id(id)?;
126
127    let max_len = HOSTNAME_LABEL_MAX_LEN.saturating_sub(tenant.len() + HOSTNAME_ID_SEPARATOR.len());
128    if id.len() > max_len {
129        return Err(OperationOutcomeError::error(
130            IssueType::invalid(),
131            format!("A project id in tenant '{tenant}' can be at most {max_len} characters long."),
132        ));
133    }
134
135    Ok(())
136}
137
138#[derive(OperationOutcomeError)]
139pub enum DataTransformError {
140    #[error(code = "invalid", diagnostic = "Invalid data: '{arg0}'")]
141    InvalidData(String),
142    #[error(code = "not-found", diagnostic = "Data not found")]
143    NotFound(String),
144}
145
146/// Sets the ID of a FHIR resource.
147///
148/// If `id_` is `Some`, that value is assigned as the resource ID. Otherwise, a
149/// new ID is generated and assigned.
150///
151/// # Errors
152///
153/// Returns an [`OperationOutcomeError`] if the resource does not contain an `id`
154/// field or if the `id` field is not of type [`Option<String>`].
155pub fn set_resource_id(
156    resource: &mut Resource,
157    id_: Option<String>,
158) -> Result<(), OperationOutcomeError> {
159    let id: &mut dyn std::any::Any =
160        resource
161            .get_field_mut("id")
162            .ok_or(DataTransformError::InvalidData(
163                "Missing 'id' field".to_string(),
164            ))?;
165    let id: &mut Option<String> =
166        id.downcast_mut::<Option<String>>()
167            .ok_or(DataTransformError::InvalidData(
168                "Invalid 'id' field".to_string(),
169            ))?;
170    *id = Some(id_.unwrap_or_else(|| generate_id(None)));
171    Ok(())
172}
173
174fn get_or_create_meta(resource: &mut Resource) -> Result<&mut Box<Meta>, OperationOutcomeError> {
175    let meta: &mut dyn std::any::Any =
176        resource
177            .get_field_mut("meta")
178            .ok_or(DataTransformError::InvalidData(
179                "Missing 'meta' field".to_string(),
180            ))?;
181    let meta: &mut Option<Box<Meta>> =
182        meta.downcast_mut::<Option<Box<Meta>>>()
183            .ok_or(DataTransformError::InvalidData(
184                "Invalid 'meta' field".to_string(),
185            ))?;
186
187    if meta.is_none() {
188        *meta = Some(Box::new(Meta::default()));
189    }
190
191    let meta = meta.as_mut().ok_or(DataTransformError::NotFound(
192        "Failed to create 'meta' field".to_string(),
193    ))?;
194
195    Ok(meta)
196}
197
198static HASTE_AUTHOR_ING_EXTENSION_URL: &str = "https://haste.health/author";
199
200static HASTE_EXTENSIONS: &[&str] = &[HASTE_AUTHOR_ING_EXTENSION_URL];
201
202/// Base of Haste Health's canonical URLs. A canonical under it names a
203/// resource by type and id, as in `https://haste.health/fhir/Membership/<id>`.
204static HASTE_CANONICAL_BASE: &str = "https://haste.health/fhir/";
205
206/// The Haste Health canonical URL naming `resource_type/id`.
207#[must_use]
208pub fn haste_canonical(resource_type: &str, id: &str) -> String {
209    format!("{HASTE_CANONICAL_BASE}{resource_type}/{id}")
210}
211
212/// The `(resource type, id)` a Haste Health canonical URL names, ignoring any
213/// `|version`. `None` for a canonical outside Haste Health's namespace or not
214/// shaped `type/id`.
215#[must_use]
216pub fn parse_haste_canonical(canonical: &str) -> Option<(&str, &str)> {
217    let path = canonical.strip_prefix(HASTE_CANONICAL_BASE)?;
218    let path = path.split_once('|').map_or(path, |(path, _version)| path);
219    let (resource_type, id) = path.split_once('/')?;
220    (!resource_type.is_empty() && !id.is_empty() && !id.contains('/'))
221        .then_some((resource_type, id))
222}
223
224// The author is a canonical rather than a Reference: authors can be Haste Health
225// types (Membership, ClientApplication) that other FHIR systems reject as
226// reference targets.
227fn create_haste_extensions(author_type: &AuthorKind, author_id: &AuthorId) -> Vec<Extension> {
228    vec![Extension {
229        url: HASTE_AUTHOR_ING_EXTENSION_URL.to_string(),
230        value: Some(ExtensionValueTypeChoice::Canonical(Box::new(
231            haste_canonical(author_type.as_ref(), author_id.as_ref()).into(),
232        ))),
233        ..Default::default()
234    }]
235}
236
237fn filter_and_set_haste_extensions(
238    meta: &mut Meta,
239    author_type: &AuthorKind,
240    author_id: &AuthorId,
241) {
242    let haste_extensions = create_haste_extensions(author_type, author_id);
243
244    meta.extension = Some(
245        meta.extension
246            .take()
247            .unwrap_or_default()
248            .into_iter()
249            .filter(|ext| !HASTE_EXTENSIONS.contains(&ext.url.as_str()))
250            .chain(haste_extensions)
251            .collect::<Vec<Extension>>(),
252    );
253}
254
255/// Sets the metadata for a FHIR resource, including the `versionId`, `lastUpdated`, and Haste authoring extensions.
256///
257/// If the resource does not have a `meta` element, one is created. The
258/// `versionId` field is then populated with a newly generated ID and the extensions are set to include the authoring information.
259///
260/// # Errors
261///
262/// Returns an [`OperationOutcomeError`] if the resource does not contain a
263/// `meta` field or if the `meta` field is not of type [`Option<Box<Meta>>`].
264pub fn set_resource_meta(
265    resource: &mut Resource,
266    author_type: &AuthorKind,
267    author_id: &AuthorId,
268) -> Result<(), OperationOutcomeError> {
269    let meta = get_or_create_meta(resource)?;
270    meta.versionId = Some(Box::new(FHIRId {
271        id: None,
272        extension: None,
273        value: Some(generate_id(None)),
274    }));
275
276    meta.lastUpdated = Some(Box::new(Instant::Iso8601(chrono::Utc::now()).into()));
277
278    filter_and_set_haste_extensions(meta, author_type, author_id);
279
280    Ok(())
281}