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, Reference},
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#[derive(OperationOutcomeError)]
51pub enum DataTransformError {
52    #[error(code = "invalid", diagnostic = "Invalid data: '{arg0}'")]
53    InvalidData(String),
54    #[error(code = "not-found", diagnostic = "Data not found")]
55    NotFound(String),
56}
57
58/// Sets the ID of a FHIR resource.
59///
60/// If `id_` is `Some`, that value is assigned as the resource ID. Otherwise, a
61/// new ID is generated and assigned.
62///
63/// # Errors
64///
65/// Returns an [`OperationOutcomeError`] if the resource does not contain an `id`
66/// field or if the `id` field is not of type [`Option<String>`].
67pub fn set_resource_id(
68    resource: &mut Resource,
69    id_: Option<String>,
70) -> Result<(), OperationOutcomeError> {
71    let id: &mut dyn std::any::Any =
72        resource
73            .get_field_mut("id")
74            .ok_or(DataTransformError::InvalidData(
75                "Missing 'id' field".to_string(),
76            ))?;
77    let id: &mut Option<String> =
78        id.downcast_mut::<Option<String>>()
79            .ok_or(DataTransformError::InvalidData(
80                "Invalid 'id' field".to_string(),
81            ))?;
82    *id = Some(id_.unwrap_or_else(|| generate_id(None)));
83    Ok(())
84}
85
86fn get_or_create_meta(resource: &mut Resource) -> Result<&mut Box<Meta>, OperationOutcomeError> {
87    let meta: &mut dyn std::any::Any =
88        resource
89            .get_field_mut("meta")
90            .ok_or(DataTransformError::InvalidData(
91                "Missing 'meta' field".to_string(),
92            ))?;
93    let meta: &mut Option<Box<Meta>> =
94        meta.downcast_mut::<Option<Box<Meta>>>()
95            .ok_or(DataTransformError::InvalidData(
96                "Invalid 'meta' field".to_string(),
97            ))?;
98
99    if meta.is_none() {
100        *meta = Some(Box::new(Meta::default()));
101    }
102
103    let meta = meta.as_mut().ok_or(DataTransformError::NotFound(
104        "Failed to create 'meta' field".to_string(),
105    ))?;
106
107    Ok(meta)
108}
109
110static HASTE_AUTHOR_ING_EXTENSION_URL: &str = "https://haste.health/author";
111
112static HASTE_EXTENSIONS: &[&str] = &[HASTE_AUTHOR_ING_EXTENSION_URL];
113
114fn create_haste_extensions(author_type: &AuthorKind, author_id: &AuthorId) -> Vec<Extension> {
115    vec![Extension {
116        url: HASTE_AUTHOR_ING_EXTENSION_URL.to_string(),
117        value: Some(ExtensionValueTypeChoice::Reference(Box::new(Reference {
118            reference: Some(Box::new(format!("{author_type}/{author_id}").into())),
119            ..Default::default()
120        }))),
121        ..Default::default()
122    }]
123}
124
125fn filter_and_set_haste_extensions(
126    meta: &mut Meta,
127    author_type: &AuthorKind,
128    author_id: &AuthorId,
129) {
130    let haste_extensions = create_haste_extensions(author_type, author_id);
131
132    meta.extension = Some(
133        meta.extension
134            .take()
135            .unwrap_or_default()
136            .into_iter()
137            .filter(|ext| !HASTE_EXTENSIONS.contains(&ext.url.as_str()))
138            .chain(haste_extensions)
139            .collect::<Vec<Extension>>(),
140    );
141}
142
143/// Sets the metadata for a FHIR resource, including the `versionId`, `lastUpdated`, and Haste authoring extensions.
144///
145/// If the resource does not have a `meta` element, one is created. The
146/// `versionId` field is then populated with a newly generated ID and the extensions are set to include the authoring information.
147///
148/// # Errors
149///
150/// Returns an [`OperationOutcomeError`] if the resource does not contain a
151/// `meta` field or if the `meta` field is not of type [`Option<Box<Meta>>`].
152pub fn set_resource_meta(
153    resource: &mut Resource,
154    author_type: &AuthorKind,
155    author_id: &AuthorId,
156) -> Result<(), OperationOutcomeError> {
157    let meta = get_or_create_meta(resource)?;
158    meta.versionId = Some(Box::new(FHIRId {
159        id: None,
160        extension: None,
161        value: Some(generate_id(None)),
162    }));
163
164    meta.lastUpdated = Some(Box::new(Instant::Iso8601(chrono::Utc::now()).into()));
165
166    filter_and_set_haste_extensions(meta, author_type, author_id);
167
168    Ok(())
169}