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
20pub 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
33pub 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
50pub const HOSTNAME_ID_SEPARATOR: &str = "--";
53
54const HOSTNAME_LABEL_MAX_LEN: usize = 63 - 8;
57
58pub const HOSTNAME_ID_MIN_LEN: usize = 3;
60
61pub 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
74pub 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
100static HOSTNAME_ID_CHARACTERS: LazyLock<Vec<char>> = LazyLock::new(|| {
103 ID_CHARACTERS
104 .iter()
105 .copied()
106 .filter(|character| *character != '-')
107 .collect()
108});
109
110#[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
117pub 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
146pub 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
202static HASTE_CANONICAL_BASE: &str = "https://haste.health/fhir/";
205
206#[must_use]
208pub fn haste_canonical(resource_type: &str, id: &str) -> String {
209 format!("{HASTE_CANONICAL_BASE}{resource_type}/{id}")
210}
211
212#[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
224fn 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
255pub 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}