Skip to main content

haste_openapi_schema_generator/
lib.rs

1use std::collections::{HashMap, HashSet};
2
3use haste_fhir_model::r4::generated::{
4    resources::{SearchParameter, StructureDefinition},
5    terminology::{IssueType, SearchParamType, StructureDefinitionKind},
6};
7use haste_fhir_operation_error::OperationOutcomeError;
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10
11#[derive(Deserialize, Serialize)]
12pub struct OpenAPIComponents {
13    schemas: std::collections::HashMap<String, serde_json::Value>,
14}
15
16#[derive(Deserialize, Serialize)]
17pub struct OpenAPIOperationContent {
18    description: String,
19    // Content Type to Schema mapping
20    #[serde(skip_serializing_if = "Option::is_none")]
21    content: Option<HashMap<String, serde_json::Value>>,
22}
23
24#[derive(Deserialize, Serialize)]
25pub struct OpenAPIOperation {
26    #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
27    request_body: Option<OpenAPIOperationContent>,
28    responses: HashMap<String, OpenAPIOperationContent>,
29    parameters: Vec<serde_json::Value>,
30}
31
32#[derive(Deserialize, Serialize)]
33pub struct OpenAPIPathItem {
34    #[serde(skip_serializing_if = "Option::is_none")]
35    get: Option<OpenAPIOperation>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    post: Option<OpenAPIOperation>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    put: Option<OpenAPIOperation>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    delete: Option<OpenAPIOperation>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    patch: Option<OpenAPIOperation>,
44}
45
46pub type OpenAPIPaths = HashMap<String, OpenAPIPathItem>;
47
48#[derive(Deserialize, Serialize)]
49pub struct OpenAPIInfo {
50    title: String,
51    version: String,
52}
53
54#[derive(Deserialize, Serialize)]
55pub struct OpenAPIServerVariable {
56    default: String,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    description: Option<String>,
59}
60
61#[derive(Deserialize, Serialize)]
62pub struct OpenAPIServer {
63    url: String,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    description: Option<String>,
66    variables: HashMap<String, OpenAPIServerVariable>,
67}
68
69#[derive(Deserialize, Serialize)]
70pub struct OpenAPI {
71    servers: Vec<OpenAPIServer>,
72    openapi: String,
73    info: OpenAPIInfo,
74    components: OpenAPIComponents,
75    paths: OpenAPIPaths,
76}
77
78fn resource_schema(resource_name: &str) -> serde_json::Value {
79    json!({
80        "$ref": format!("#/components/schemas/{resource_name}")
81    })
82}
83
84fn operation_outcome_schema() -> serde_json::Value {
85    json!({
86        "$ref": "#/components/schemas/OperationOutcome"
87    })
88}
89
90fn json_content(schema: serde_json::Value) -> HashMap<String, serde_json::Value> {
91    HashMap::from([("application/json".to_string(), schema)])
92}
93
94fn response(
95    description: impl Into<String>,
96    content: Option<HashMap<String, serde_json::Value>>,
97) -> OpenAPIOperationContent {
98    OpenAPIOperationContent {
99        description: description.into(),
100        content,
101    }
102}
103
104fn resource_response(
105    resource_name: &str,
106    description: impl Into<String>,
107) -> OpenAPIOperationContent {
108    response(
109        description,
110        Some(json_content(json!({
111            "schema": resource_schema(resource_name)
112        }))),
113    )
114}
115
116fn operation_outcome_response(description: impl Into<String>) -> OpenAPIOperationContent {
117    response(
118        description,
119        Some(json_content(json!({
120            "schema": operation_outcome_schema()
121        }))),
122    )
123}
124
125fn id_parameter(resource_name: &str) -> serde_json::Value {
126    json!({
127        "name": "id",
128        "in": "path",
129        "required": true,
130        "schema": {
131            "type": "string"
132        },
133        "description": format!("The ID of the {resource_name} resource")
134    })
135}
136
137fn read_resource_operation(resource_name: &str) -> OpenAPIOperation {
138    OpenAPIOperation {
139        request_body: None,
140        responses: HashMap::from([
141            (
142                "200".to_string(),
143                resource_response(
144                    resource_name,
145                    format!("Successful read of {resource_name} resource"),
146                ),
147            ),
148            (
149                "400".to_string(),
150                operation_outcome_response("Client error"),
151            ),
152            (
153                "500".to_string(),
154                operation_outcome_response("Server error"),
155            ),
156        ]),
157        parameters: vec![id_parameter(resource_name)],
158    }
159}
160
161fn put_resource_operation(resource_name: &str) -> OpenAPIOperation {
162    OpenAPIOperation {
163        request_body: Some(resource_response(
164            resource_name,
165            format!("The {resource_name} resource to create or update"),
166        )),
167        responses: HashMap::from([
168            (
169                "200".to_string(),
170                resource_response(
171                    resource_name,
172                    format!("Successful put/creation of {resource_name} resource"),
173                ),
174            ),
175            (
176                "400".to_string(),
177                operation_outcome_response("Client error"),
178            ),
179            (
180                "500".to_string(),
181                operation_outcome_response("Server error"),
182            ),
183        ]),
184        parameters: vec![id_parameter(resource_name)],
185    }
186}
187
188fn delete_instance_operation(resource_name: &str) -> OpenAPIOperation {
189    OpenAPIOperation {
190        request_body: None,
191        responses: HashMap::from([
192            (
193                "200".to_string(),
194                response(
195                    format!("Successful deletion of {resource_name} resource"),
196                    None,
197                ),
198            ),
199            (
200                "400".to_string(),
201                operation_outcome_response("Client error"),
202            ),
203        ]),
204        parameters: vec![id_parameter(resource_name)],
205    }
206}
207
208fn patch_resource_operation(resource_name: &str) -> OpenAPIOperation {
209    OpenAPIOperation {
210        request_body: Some(OpenAPIOperationContent {
211            description: format!("JSON Patch operation for {resource_name} resource."),
212            content: Some(json_content(json!({
213                "schema": {
214                    "type": "array"
215                }
216            }))),
217        }),
218        responses: HashMap::from([
219            (
220                "200".to_string(),
221                resource_response(
222                    resource_name,
223                    format!("Successful patch of {resource_name} resource"),
224                ),
225            ),
226            (
227                "400".to_string(),
228                operation_outcome_response("Client error"),
229            ),
230        ]),
231        parameters: vec![id_parameter(resource_name)],
232    }
233}
234
235fn create_resource_operation(resource_name: &str) -> OpenAPIOperation {
236    OpenAPIOperation {
237        request_body: Some(resource_response(
238            resource_name,
239            format!("The {resource_name} resource to create"),
240        )),
241        responses: HashMap::from([
242            (
243                "200".to_string(),
244                resource_response(
245                    resource_name,
246                    format!("Successful creation of {resource_name} resource"),
247                ),
248            ),
249            (
250                "400".to_string(),
251                operation_outcome_response("Client error"),
252            ),
253        ]),
254        parameters: vec![],
255    }
256}
257
258fn search_resource_operation(
259    resource_name: &str,
260    parameters: Vec<serde_json::Value>,
261) -> OpenAPIOperation {
262    OpenAPIOperation {
263        request_body: None,
264        responses: HashMap::from([
265            (
266                "200".to_string(),
267                response(
268                    "Successful search operation",
269                    Some(json_content(json!({
270                        "schema": haste_sd_to_json_schema::bundle_of_resource(&json!({
271                            "$ref": format!("#/components/schemas/{resource_name}")
272                        }))
273                    }))),
274                ),
275            ),
276            (
277                "400".to_string(),
278                operation_outcome_response("Client error"),
279            ),
280        ]),
281        parameters,
282    }
283}
284
285fn delete_resource_operation(parameters: Vec<serde_json::Value>) -> OpenAPIOperation {
286    OpenAPIOperation {
287        request_body: None,
288        responses: HashMap::from([
289            (
290                "200".to_string(),
291                response("Successful delete operation", None),
292            ),
293            (
294                "400".to_string(),
295                operation_outcome_response("Client error"),
296            ),
297        ]),
298        parameters,
299    }
300}
301
302fn resource_search_parameters_schema(
303    resource_name: &str,
304    search_parameters: &[SearchParameter],
305) -> Vec<serde_json::Value> {
306    search_parameters
307        .iter()
308        .filter(|sp| {
309            sp.base.iter().any(|b| {
310                let base = b.as_str();
311
312                base == Some(resource_name)
313                    || base == Some("Resource")
314                    || base == Some("DomainResource")
315            }) && sp.type_ != SearchParamType::composite()
316        })
317        .map(|sp| {
318            let search_type = if sp.type_ == SearchParamType::number() {
319                "number"
320            } else {
321                "string"
322            };
323
324            json!({
325                "name": sp.code.value,
326                "in": "query",
327                "required": false,
328                "schema": {
329                    "type": search_type
330                },
331                "description": sp.description.value.as_deref().unwrap_or_default()
332            })
333        })
334        .collect()
335}
336
337/// Generates an [`OpenAPI`] document containing only the FHIR resources supported by the server.
338///
339/// Resource and complex-type schemas are represented as external `$ref`s pointing to
340/// `{schema_base_url}/{TypeName}`. This keeps the generated document small while allowing
341/// individual schemas to be served on demand from a separate endpoint.
342///
343/// For each supported resource, the generated document includes endpoints for reading,
344/// creating, searching, updating, and deleting resources, as applicable.
345///
346/// # Arguments
347///
348/// * `server_root` - The root URL of the FHIR server.
349/// * `api_version` - The version of the API represented by the generated document.
350/// * `schema_base_url` - The base URL from which individual FHIR schemas are served
351///   (e.g. `/schemas/fhir`).
352/// * `sds` - All available [`StructureDefinition`]s, including resource and complex-type
353///   definitions.
354/// * `search_parameters` - All available [`SearchParameter`]s used to generate resource
355///   search operations.
356/// * `supported_resource_names` - The set of FHIR resource type names supported by the
357///   server. Resource definitions not present in this set are excluded from the generated
358///   document.
359///
360/// # Errors
361///
362/// Returns an [`OperationOutcomeError`] if a supported resource
363/// [`StructureDefinition`] does not contain a type name.
364///
365/// # Returns
366///
367/// An [`OpenAPI`] document containing the server configuration, schemas, and paths for
368/// all supported resources and applicable complex types.
369pub fn open_api_schema_generator<S: std::hash::BuildHasher>(
370    server_root: &str,
371    api_version: &str,
372    schema_base_url: &str,
373    sds: &[StructureDefinition],
374    search_parameters: &[SearchParameter],
375    supported_resource_names: &HashSet<String, S>,
376) -> Result<OpenAPI, OperationOutcomeError> {
377    let mut openapi_schema = create_openapi_schema(server_root, api_version);
378
379    add_resource_schemas(
380        &mut openapi_schema,
381        sds,
382        search_parameters,
383        supported_resource_names,
384        schema_base_url,
385    )?;
386
387    add_complex_type_schemas(&mut openapi_schema, sds, schema_base_url);
388
389    Ok(openapi_schema)
390}
391
392fn create_openapi_schema(server_root: &str, api_version: &str) -> OpenAPI {
393    let mut fhir_server_variables = HashMap::new();
394
395    fhir_server_variables.insert(
396        "tenant".to_string(),
397        OpenAPIServerVariable {
398            default: "my-tenant".to_string(),
399            description: Some("Tenant identifier".to_string()),
400        },
401    );
402
403    fhir_server_variables.insert(
404        "project".to_string(),
405        OpenAPIServerVariable {
406            default: "my-project".to_string(),
407            description: Some("Project identifier".to_string()),
408        },
409    );
410
411    fhir_server_variables.insert(
412        "fhir_version".to_string(),
413        OpenAPIServerVariable {
414            default: "r4".to_string(),
415            description: Some("FHIR version".to_string()),
416        },
417    );
418
419    OpenAPI {
420        openapi: "3.1.1".to_string(),
421        servers: vec![OpenAPIServer {
422            url: format!(
423                "{}/w/{}/{}/api/v1/fhir/{}",
424                server_root, "{tenant}", "{project}", "{fhir_version}"
425            ),
426            description: Some("Haste Health FHIR Server".to_string()),
427            variables: fhir_server_variables,
428        }],
429        info: OpenAPIInfo {
430            title: "Haste Health API Documentation".to_string(),
431            version: api_version.to_string(),
432        },
433        components: OpenAPIComponents {
434            schemas: HashMap::new(),
435        },
436        paths: HashMap::new(),
437    }
438}
439
440fn add_resource_schemas<S: std::hash::BuildHasher>(
441    openapi_schema: &mut OpenAPI,
442    sds: &[StructureDefinition],
443    search_parameters: &[SearchParameter],
444    supported_resource_names: &HashSet<String, S>,
445    schema_base_url: &str,
446) -> Result<(), OperationOutcomeError> {
447    let resource_sds = sds
448        .iter()
449        .filter(|sd| sd.kind == StructureDefinitionKind::resource())
450        .filter(|sd| {
451            sd.type_
452                .value
453                .as_ref()
454                .is_some_and(|name| supported_resource_names.contains(name))
455        });
456
457    for sd in resource_sds {
458        let resource_name = sd.type_.value.as_ref().ok_or_else(|| {
459            OperationOutcomeError::error(
460                IssueType::structure(),
461                format!(
462                    "StructureDefinition missing type for id {}",
463                    sd.id.as_ref().unwrap_or(&"unknown".to_string())
464                ),
465            )
466        })?;
467
468        add_resource_schema(
469            openapi_schema,
470            resource_name,
471            search_parameters,
472            schema_base_url,
473        );
474    }
475
476    Ok(())
477}
478
479fn add_resource_schema(
480    openapi_schema: &mut OpenAPI,
481    resource_name: &str,
482    search_parameters: &[SearchParameter],
483    schema_base_url: &str,
484) {
485    // Use external $ref for the resource schema — individual schemas are served
486    // from a separate endpoint to keep the main document small.
487    openapi_schema.components.schemas.insert(
488        resource_name.to_string(),
489        json!({
490            "$ref": format!("{}/{}", schema_base_url, resource_name)
491        }),
492    );
493
494    // Read operation
495    openapi_schema.paths.insert(
496        format!("/{resource_name}/{{id}}"),
497        OpenAPIPathItem {
498            get: Some(read_resource_operation(resource_name)),
499            post: None,
500            patch: Some(patch_resource_operation(resource_name)),
501            put: Some(put_resource_operation(resource_name)),
502            delete: Some(delete_instance_operation(resource_name)),
503        },
504    );
505
506    // Search/create/delete operations
507    let resource_search_parameters =
508        resource_search_parameters_schema(resource_name, search_parameters);
509
510    openapi_schema.paths.insert(
511        format!("/{resource_name}"),
512        OpenAPIPathItem {
513            get: Some(search_resource_operation(
514                resource_name,
515                resource_search_parameters.clone(),
516            )),
517            patch: None,
518            put: None,
519            post: Some(create_resource_operation(resource_name)),
520            delete: Some(delete_resource_operation(resource_search_parameters)),
521        },
522    );
523}
524
525fn add_complex_type_schemas(
526    openapi_schema: &mut OpenAPI,
527    sds: &[StructureDefinition],
528    schema_base_url: &str,
529) {
530    // Complex types (datatypes) get the same external-$ref treatment as
531    // resources — served on demand from `{schema_base_url}/{TypeName}`
532    // rather than tracked/inlined here.
533    //
534    // "Element" is matched by name too since it's FHIR's abstract base type
535    // and isn't always loaded with `kind: complex-type`.
536    for sd in sds.iter().filter(|sd| {
537        sd.kind == StructureDefinitionKind::complex_type()
538            || sd.name.value.as_deref() == Some("Element")
539    }) {
540        let Some(type_name) = sd.type_.value.as_ref() else {
541            continue;
542        };
543
544        openapi_schema.components.schemas.insert(
545            type_name.clone(),
546            json!({
547                "$ref": format!("{}/{}", schema_base_url, type_name)
548            }),
549        );
550    }
551}
552
553/// Returns the set of resource type names from the given
554/// [`StructureDefinition`]s.
555/// Useful for getting the full unfiltered set when no `CapabilityStatement`
556/// filtering is desired.
557pub fn all_resource_names(sds: &[StructureDefinition]) -> HashSet<String> {
558    sds.iter()
559        .filter(|sd| sd.kind == StructureDefinitionKind::resource())
560        .filter_map(|sd| sd.type_.value.clone())
561        .collect()
562}