Skip to main content

haste_sd_to_json_schema/
lib.rs

1use std::collections::HashMap;
2
3use haste_codegen::{
4    traversal,
5    utilities::{self, conditionals::is_typechoice, extract::Max},
6};
7use haste_fhir_model::r4::generated::{
8    resources::StructureDefinition, terminology::IssueType, types::ElementDefinition,
9};
10use haste_fhir_operation_error::OperationOutcomeError;
11use serde_json::json;
12
13#[derive(serde::Serialize, serde::Deserialize, Debug)]
14#[serde(rename_all = "lowercase")]
15enum JSONSchemaType {
16    Object,
17    Boolean,
18    String,
19    Number,
20    Array,
21}
22
23#[allow(dead_code)]
24struct JSONSchema {}
25
26struct Processed {
27    cardinality: (u64, Max),
28    field: String,
29    schema: serde_json::Value,
30}
31
32static PRIMITIVE_TYPES: &[&str] = &[
33    "http://hl7.org/fhirpath/System.String",
34    "http://hl7.org/fhirpath/System.Time",
35    "http://hl7.org/fhirpath/System.Date",
36    "http://hl7.org/fhirpath/System.DateTime",
37    "http://hl7.org/fhirpath/System.Instant",
38    "xhtml",
39    "markdown",
40    "url",
41    "canonical",
42    "uuid",
43    "string",
44    "uri",
45    "code",
46    "id",
47    "oid",
48    "base64Binary",
49    "time",
50    "date",
51    "dateTime",
52    "instant",
53    "http://hl7.org/fhirpath/System.Boolean",
54    "boolean",
55    "http://hl7.org/fhirpath/System.Integer",
56    "http://hl7.org/fhirpath/System.Decimal",
57    "decimal",
58    "integer",
59    "unsignedInt",
60    "positiveInt",
61];
62
63fn fhir_primitive_type_to_json_schema_type(fhir_type: &str) -> JSONSchemaType {
64    match fhir_type {
65        "http://hl7.org/fhirpath/System.Boolean" | "boolean" => JSONSchemaType::Boolean,
66        "http://hl7.org/fhirpath/System.Integer"
67        | "http://hl7.org/fhirpath/System.Decimal"
68        | "decimal"
69        | "integer"
70        | "unsignedInt"
71        | "positiveInt" => JSONSchemaType::Number,
72        _ => JSONSchemaType::String,
73    }
74}
75
76fn is_fhir_primitive_type(fhir_type: &str) -> bool {
77    PRIMITIVE_TYPES.contains(&fhir_type)
78}
79
80fn wrap_if_array(
81    sd: &StructureDefinition,
82    element: &ElementDefinition,
83    base: Processed,
84) -> Processed {
85    match base.cardinality.1 {
86        Max::Unlimited if !utilities::conditionals::is_root(sd, element) => Processed {
87            cardinality: base.cardinality,
88            field: base.field,
89            schema: json!({
90                "type": "array",
91                "items": base.schema,
92            }),
93        },
94        Max::Fixed(n) if n > 1 && !utilities::conditionals::is_root(sd, element) => Processed {
95            cardinality: base.cardinality,
96            field: base.field,
97            schema: json!({
98                "type": "array",
99                "items": base.schema,
100            }),
101        },
102        _ => base,
103    }
104}
105
106// Generate a JSON Schema reference for a FHIR type
107// If it's a Resource or DomainResource, we return a generic object schema.
108fn datatype_reference_schema(schema_loc: &str, fhir_type: &str) -> serde_json::Value {
109    match fhir_type {
110        "DomainResource" | "Resource" => json!({
111            "type": "object",
112             "additionalProperties": true,
113        }),
114        _ => json!({
115            "$ref": format!("{}/{}", schema_loc, fhir_type)
116        }),
117    }
118}
119
120fn process_leaf(
121    schema_loc: &str,
122    sd: &StructureDefinition,
123    element: &ElementDefinition,
124) -> Vec<Processed> {
125    let cardinality = utilities::extract::cardinality(element);
126    let base_schema = if is_typechoice(element) {
127        element
128            .type_
129            .as_ref()
130            .unwrap_or(&vec![])
131            .iter()
132            .flat_map(|fhir_type| {
133                let type_code = fhir_type.code.value.as_deref().unwrap_or_default();
134
135                let field_name = utilities::generate::type_choice_variant_name(element, type_code);
136
137                if is_fhir_primitive_type(type_code) {
138                    vec![
139                        Processed {
140                            cardinality: (0, cardinality.1),
141                            field: format!("_{field_name}"),
142                            schema: datatype_reference_schema(schema_loc, "Element"),
143                        },
144                        Processed {
145                            cardinality: (0, cardinality.1),
146                            field: field_name,
147                            schema: json!({
148                                "type": fhir_primitive_type_to_json_schema_type(type_code)
149                            }),
150                        },
151                    ]
152                } else {
153                    vec![Processed {
154                        cardinality: (0, cardinality.1),
155                        field: field_name,
156                        schema: datatype_reference_schema(schema_loc, type_code),
157                    }]
158                }
159            })
160            .collect()
161    } else {
162        let type_code = element
163            .type_
164            .as_ref()
165            .and_then(|t| t.first())
166            .map(|t| t.code.as_ref())
167            .and_then(|c| c.value.as_ref())
168            .map(std::string::String::as_str)
169            .unwrap_or_default();
170        let field_name =
171            utilities::extract::field_name(element.path.value.as_deref().map_or("", |s| s));
172
173        if is_fhir_primitive_type(type_code) {
174            vec![
175                Processed {
176                    cardinality: (0, cardinality.1),
177                    field: format!("_{field_name}"),
178                    schema: datatype_reference_schema(schema_loc, "Element"),
179                },
180                Processed {
181                    cardinality,
182                    field: field_name,
183                    schema: json!({
184                        "type": fhir_primitive_type_to_json_schema_type(type_code)
185                    }),
186                },
187            ]
188        } else {
189            vec![Processed {
190                cardinality,
191                field: field_name,
192                schema: datatype_reference_schema(schema_loc, type_code),
193            }]
194        }
195    };
196
197    base_schema
198        .into_iter()
199        .map(|schema| wrap_if_array(sd, element, schema))
200        .collect()
201}
202
203fn process_complex(
204    sd: &StructureDefinition,
205    element: &ElementDefinition,
206    children: Vec<Processed>,
207) -> Processed {
208    let mut required_properties = vec![];
209    let mut properties: HashMap<String, serde_json::Value> = HashMap::new();
210    if utilities::conditionals::is_root(sd, element) && utilities::conditionals::is_resource_sd(sd)
211    {
212        properties.insert(
213            "resourceType".to_string(),
214            json!({
215                "type": "string",
216                "const": sd.type_.value.as_ref().unwrap_or(&"Unknown".to_string()),
217            }),
218        );
219        required_properties.push("resourceType".to_string());
220    }
221
222    for child in children {
223        if child.cardinality.0 > 0 {
224            required_properties.push(child.field.clone());
225        }
226        properties.insert(child.field, child.schema);
227    }
228
229    wrap_if_array(
230        sd,
231        element,
232        Processed {
233            cardinality: utilities::extract::cardinality(element),
234            field: utilities::extract::field_name(element.path.value.as_deref().map_or("", |s| s)),
235            schema: json!({
236                "type": "object",
237                "properties": properties,
238                "required": required_properties,
239                "additionalProperties": false,
240            }),
241        },
242    )
243}
244
245/// Generates an isolated JSON Schema from a FHIR [`StructureDefinition`].
246///
247/// Traverses the structure definition and converts leaf elements into
248/// primitive schemas and complex elements into nested schemas. Returns an
249/// [`OperationOutcomeError`] if traversal fails or no schema can be generated.
250///
251/// # Arguments
252///
253/// * `schema_loc` - The location used when generating the schema.
254/// * `sd` - The FHIR [`StructureDefinition`] to convert.
255///
256/// # Errors
257///
258/// Returns an [`OperationOutcomeError`] if the structure definition cannot
259/// be traversed or does not produce a schema.
260pub fn isolated_schema(
261    schema_loc: &str,
262    sd: &StructureDefinition,
263) -> Result<serde_json::Value, OperationOutcomeError> {
264    let mut visitor = |element: &ElementDefinition,
265                       children: Vec<Vec<Processed>>,
266                       _index: usize|
267     -> Vec<Processed> {
268        if children.is_empty() {
269            process_leaf(schema_loc, sd, element)
270        } else {
271            vec![process_complex(
272                sd,
273                element,
274                children.into_iter().flatten().collect(),
275            )]
276        }
277    };
278
279    let mut result = traversal::traversal(sd, &mut visitor).map_err(|e| {
280        OperationOutcomeError::error(
281            IssueType::exception(),
282            format!("Error traversing StructureDefinition: {e}"),
283        )
284    })?;
285
286    if let Some(result) = result.pop() {
287        Ok(result.schema)
288    } else {
289        Err(OperationOutcomeError::error(
290            IssueType::exception(),
291            "No schema generated from StructureDefinition".to_string(),
292        ))
293    }
294}
295
296// Creates a type schema for a bundle of resources
297#[must_use]
298pub fn bundle_of_resource(resource_schema: &serde_json::Value) -> serde_json::Value {
299    json!({
300        "type": "object",
301        "properties": {
302            "resourceType": {
303                "type": "string",
304                "const": "Bundle"
305            },
306            "type": {
307                "enum": ["collection", "searchset", "history"]
308            },
309            "entry": {
310                "type": "array",
311                "items": {
312                    "type": "object",
313                    "properties": {
314                        "resource": resource_schema
315                    },
316                    "required": ["resource"],
317                    "additionalProperties": true
318                }
319            }
320        },
321        "required": ["resourceType", "type", "entry"],
322        "additionalProperties": false
323    })
324}
325
326#[cfg(test)]
327mod test {
328    use std::sync::LazyLock;
329
330    use haste_fhir_model::r4::generated::{
331        resources::{Bundle, Patient},
332        terminology::StructureDefinitionKind,
333        types::{FHIRString, HumanName},
334    };
335
336    use super::*;
337
338    static RESOURCE_SDS: LazyLock<Vec<StructureDefinition>> = LazyLock::new(|| {
339        let sd_str = include_str!(
340            "../../../../artifacts/r4/hl7-core/definitions/hl7/profiles-resources.min.json"
341        );
342
343        let bundle: Bundle =
344            serde_json::from_str(sd_str).expect("Failed to parse StructureDefinitions");
345
346        bundle
347            .entry
348            .unwrap_or_default()
349            .into_iter()
350            .filter_map(|entry| entry.resource)
351            .filter_map(|resource| {
352                if let haste_fhir_model::r4::generated::resources::Resource::StructureDefinition(
353                    sd,
354                ) = *resource
355                {
356                    Some(sd)
357                } else {
358                    None
359                }
360            })
361            .collect()
362    });
363
364    /// Test-only stand-in for the real `{schema_base_url}` complex types are
365    /// referenced against in production.
366    const TEST_SCHEMA_BASE_URL: &str = "https://example.com/schemas/fhir";
367
368    /// Complex type schemas, keyed by the full external URL they're
369    /// referenced by (matching what `isolated_schema` generates), so tests
370    /// can resolve them the same way production does - via `$ref`, not `$defs`.
371    pub static FHIR_COMPLEX_TYPE_DEFINITIONS: LazyLock<HashMap<String, serde_json::Value>> =
372        LazyLock::new(|| {
373            let sd_str = include_str!(
374                "../../../../artifacts/r4/hl7-core/definitions/hl7/profiles-types.min.json"
375            );
376
377            let bundle: Bundle =
378                serde_json::from_str(sd_str).expect("Failed to parse StructureDefinitions");
379
380            bundle
381            .entry
382            .unwrap_or_default()
383            .into_iter()
384            .filter_map(|entry| entry.resource)
385            .filter_map(|resource| {
386                if let haste_fhir_model::r4::generated::resources::Resource::StructureDefinition(
387                    sd,
388                ) = *resource
389                {
390                    Some(sd)
391                } else {
392                    None
393                }
394            })
395            .filter(|sd|
396                sd.kind  == StructureDefinitionKind::complex_type()
397            )
398            .map(|sd| {
399                let type_name = sd.type_.value.clone().unwrap();
400                (
401                    format!("{TEST_SCHEMA_BASE_URL}/{type_name}"),
402                    isolated_schema(TEST_SCHEMA_BASE_URL, &sd).unwrap(),
403                )
404            })
405            .collect::<HashMap<String, _>>()
406        });
407
408    /// Resolves the external `$ref`s complex types are generated with by
409    /// looking them up in an in-memory map instead of making real HTTP calls.
410    struct TestSchemaRetriever;
411
412    impl jsonschema::Retrieve for TestSchemaRetriever {
413        fn retrieve(
414            &self,
415            uri: &jsonschema::Uri<String>,
416        ) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
417            FHIR_COMPLEX_TYPE_DEFINITIONS
418                .get(uri.as_str())
419                .cloned()
420                .ok_or_else(|| format!("Unknown schema: {uri}").into())
421        }
422    }
423
424    #[test]
425    fn test_sd_to_json_schema() {
426        let patient_sd = RESOURCE_SDS
427            .iter()
428            .find(|v| v.type_.value.as_deref() == Some("Patient"))
429            .unwrap();
430
431        let schema = isolated_schema(TEST_SCHEMA_BASE_URL, patient_sd).unwrap();
432
433        println!("{}", serde_json::to_string_pretty(&schema).unwrap());
434
435        assert!(!serde_json::to_string(&schema).unwrap().is_empty());
436    }
437
438    #[test]
439    fn patient_sd_test() {
440        let patient_sd = RESOURCE_SDS
441            .iter()
442            .find(|v| v.type_.value.as_deref() == Some("Patient"))
443            .unwrap();
444
445        let schema = isolated_schema(TEST_SCHEMA_BASE_URL, patient_sd).unwrap();
446
447        let validator = jsonschema::options()
448            .with_retriever(TestSchemaRetriever)
449            .build(&schema)
450            .unwrap();
451
452        let patient_data = serde_json::to_string(&Patient {
453            name: Some(vec![HumanName {
454                family: Some(Box::new(FHIRString {
455                    value: Some("Doe".to_string()),
456                    ..Default::default()
457                })),
458                given: Some(vec![FHIRString {
459                    value: Some("John".to_string()),
460                    ..Default::default()
461                }]),
462                ..Default::default()
463            }]),
464            ..Default::default()
465        })
466        .unwrap();
467
468        let mut patient_json = serde_json::from_str(&patient_data).unwrap();
469        let result = validator.validate(&patient_json);
470        assert!(result.is_ok());
471
472        patient_json["name"][0]["_given"] = json!("This is not a valid value");
473        let result = validator.validate(&patient_json);
474        assert!(result.is_err());
475
476        patient_json["name"][0]["_given"] = json!([{"id": "1"}]);
477        let result = validator.validate(&patient_json);
478        println!("{result:?}");
479        assert!(result.is_ok());
480
481        patient_json["name"] = json!("This is not a valid value");
482        let result = validator.validate(&patient_json);
483
484        assert!(result.is_err());
485    }
486}