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                    "additionalProperties": true
317                }
318            }
319        },
320        "required": ["resourceType", "type"],
321        "additionalProperties": true
322    })
323}
324
325#[cfg(test)]
326mod test {
327    use std::sync::LazyLock;
328
329    use haste_fhir_model::r4::generated::{
330        resources::{Bundle, Patient},
331        terminology::StructureDefinitionKind,
332        types::{FHIRString, HumanName},
333    };
334
335    use super::*;
336
337    static RESOURCE_SDS: LazyLock<Vec<StructureDefinition>> = LazyLock::new(|| {
338        let sd_str = include_str!(
339            "../../../../artifacts/r4/hl7-core/definitions/hl7/profiles-resources.min.json"
340        );
341
342        let bundle: Bundle =
343            serde_json::from_str(sd_str).expect("Failed to parse StructureDefinitions");
344
345        bundle
346            .entry
347            .unwrap_or_default()
348            .into_iter()
349            .filter_map(|entry| entry.resource)
350            .filter_map(|resource| {
351                if let haste_fhir_model::r4::generated::resources::Resource::StructureDefinition(
352                    sd,
353                ) = *resource
354                {
355                    Some(sd)
356                } else {
357                    None
358                }
359            })
360            .collect()
361    });
362
363    /// Test-only stand-in for the real `{schema_base_url}` complex types are
364    /// referenced against in production.
365    const TEST_SCHEMA_BASE_URL: &str = "https://example.com/schemas/fhir";
366
367    /// Complex type schemas, keyed by the full external URL they're
368    /// referenced by (matching what `isolated_schema` generates), so tests
369    /// can resolve them the same way production does - via `$ref`, not `$defs`.
370    pub static FHIR_COMPLEX_TYPE_DEFINITIONS: LazyLock<HashMap<String, serde_json::Value>> =
371        LazyLock::new(|| {
372            let sd_str = include_str!(
373                "../../../../artifacts/r4/hl7-core/definitions/hl7/profiles-types.min.json"
374            );
375
376            let bundle: Bundle =
377                serde_json::from_str(sd_str).expect("Failed to parse StructureDefinitions");
378
379            bundle
380            .entry
381            .unwrap_or_default()
382            .into_iter()
383            .filter_map(|entry| entry.resource)
384            .filter_map(|resource| {
385                if let haste_fhir_model::r4::generated::resources::Resource::StructureDefinition(
386                    sd,
387                ) = *resource
388                {
389                    Some(sd)
390                } else {
391                    None
392                }
393            })
394            .filter(|sd|
395                sd.kind  == StructureDefinitionKind::complex_type()
396            )
397            .map(|sd| {
398                let type_name = sd.type_.value.clone().unwrap();
399                (
400                    format!("{TEST_SCHEMA_BASE_URL}/{type_name}"),
401                    isolated_schema(TEST_SCHEMA_BASE_URL, &sd).unwrap(),
402                )
403            })
404            .collect::<HashMap<String, _>>()
405        });
406
407    /// Resolves the external `$ref`s complex types are generated with by
408    /// looking them up in an in-memory map instead of making real HTTP calls.
409    struct TestSchemaRetriever;
410
411    impl jsonschema::Retrieve for TestSchemaRetriever {
412        fn retrieve(
413            &self,
414            uri: &jsonschema::Uri<String>,
415        ) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
416            FHIR_COMPLEX_TYPE_DEFINITIONS
417                .get(uri.as_str())
418                .cloned()
419                .ok_or_else(|| format!("Unknown schema: {uri}").into())
420        }
421    }
422
423    #[test]
424    fn test_sd_to_json_schema() {
425        let patient_sd = RESOURCE_SDS
426            .iter()
427            .find(|v| v.type_.value.as_deref() == Some("Patient"))
428            .unwrap();
429
430        let schema = isolated_schema(TEST_SCHEMA_BASE_URL, patient_sd).unwrap();
431
432        println!("{}", serde_json::to_string_pretty(&schema).unwrap());
433
434        assert!(!serde_json::to_string(&schema).unwrap().is_empty());
435    }
436
437    #[test]
438    fn patient_sd_test() {
439        let patient_sd = RESOURCE_SDS
440            .iter()
441            .find(|v| v.type_.value.as_deref() == Some("Patient"))
442            .unwrap();
443
444        let schema = isolated_schema(TEST_SCHEMA_BASE_URL, patient_sd).unwrap();
445
446        let validator = jsonschema::options()
447            .with_retriever(TestSchemaRetriever)
448            .build(&schema)
449            .unwrap();
450
451        let patient_data = serde_json::to_string(&Patient {
452            name: Some(vec![HumanName {
453                family: Some(Box::new(FHIRString {
454                    value: Some("Doe".to_string()),
455                    ..Default::default()
456                })),
457                given: Some(vec![FHIRString {
458                    value: Some("John".to_string()),
459                    ..Default::default()
460                }]),
461                ..Default::default()
462            }]),
463            ..Default::default()
464        })
465        .unwrap();
466
467        let mut patient_json = serde_json::from_str(&patient_data).unwrap();
468        let result = validator.validate(&patient_json);
469        assert!(result.is_ok());
470
471        patient_json["name"][0]["_given"] = json!("This is not a valid value");
472        let result = validator.validate(&patient_json);
473        assert!(result.is_err());
474
475        patient_json["name"][0]["_given"] = json!([{"id": "1"}]);
476        let result = validator.validate(&patient_json);
477        println!("{result:?}");
478        assert!(result.is_ok());
479
480        patient_json["name"] = json!("This is not a valid value");
481        let result = validator.validate(&patient_json);
482
483        assert!(result.is_err());
484    }
485}