Skip to main content

haste_openapi_schema_generator/
lib.rs

1use std::collections::HashMap;
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 read_resource_operation(resource_name: &str) -> OpenAPIOperation {
79    OpenAPIOperation {
80        request_body: None,
81        responses: HashMap::from([
82            (
83                "200".to_string(),
84                OpenAPIOperationContent {
85                    description: format!("Successful read of {} resource", resource_name),
86                    content: Some(HashMap::from([(
87                        "application/json".to_string(),
88                        json!({ "schema": {"$ref": format!("#/components/schemas/{}", resource_name) }}),
89                    )])),
90                },
91            ),
92            (
93                "400".to_string(),
94                OpenAPIOperationContent {
95                    description: "Client error".to_string(),
96                    content: Some(HashMap::from([(
97                        "application/json".to_string(),
98                        json!({ "schema": {"$ref": "#/components/schemas/OperationOutcome" }}),
99                    )])),
100                },
101            ),
102            (
103                "500".to_string(),
104                OpenAPIOperationContent {
105                    description: "Server error".to_string(),
106                    content: Some(HashMap::from([(
107                        "application/json".to_string(),
108                        json!({ "schema": {"$ref": "#/components/schemas/OperationOutcome" }}),
109                    )])),
110                },
111            ),
112        ]),
113        parameters: vec![json!({
114            "name": "id",
115            "in": "path",
116            "required": true,
117            "schema": {
118                "type": "string"
119            },
120            "description": format!("The ID of the {} resource", resource_name)
121        })],
122    }
123}
124
125fn put_resource_operation(resource_name: &str) -> OpenAPIOperation {
126    OpenAPIOperation {
127        request_body: Some(OpenAPIOperationContent {
128            description: format!("The {} resource to create or update", resource_name),
129            content: Some(HashMap::from([(
130                "application/json".to_string(),
131                json!({ "schema": {"$ref": format!("#/components/schemas/{}", resource_name) }}),
132            )])),
133        }),
134        responses: HashMap::from([
135            (
136                "200".to_string(),
137                OpenAPIOperationContent {
138                    description: format!("Successful put/creation of {} resource", resource_name),
139                    content: Some(HashMap::from([(
140                        "application/json".to_string(),
141                        json!({ "schema": {"$ref": format!("#/components/schemas/{}", resource_name) }}),
142                    )])),
143                },
144            ),
145            (
146                "400".to_string(),
147                OpenAPIOperationContent {
148                    description: "Client error".to_string(),
149                    content: Some(HashMap::from([(
150                        "application/json".to_string(),
151                        json!({ "schema": {"$ref": "#/components/schemas/OperationOutcome" }}),
152                    )])),
153                },
154            ),
155            (
156                "500".to_string(),
157                OpenAPIOperationContent {
158                    description: "Server error".to_string(),
159                    content: Some(HashMap::from([(
160                        "application/json".to_string(),
161                        json!({ "schema": {"$ref": "#/components/schemas/OperationOutcome" }}),
162                    )])),
163                },
164            ),
165        ]),
166        parameters: vec![json!({
167            "name": "id",
168            "in": "path",
169            "required": true,
170            "schema": {
171                "type": "string"
172            },
173            "description": format!("The ID of the {} resource", resource_name)
174        })],
175    }
176}
177
178fn delete_instance_operation(resource_name: &str) -> OpenAPIOperation {
179    OpenAPIOperation {
180        request_body: None,
181        responses: HashMap::from([
182            (
183                "200".to_string(),
184                OpenAPIOperationContent {
185                    description: format!("Successful deletion of {} resource", resource_name),
186                    content: None,
187                },
188            ),
189            (
190                "400".to_string(),
191                OpenAPIOperationContent {
192                    description: "Client error".to_string(),
193                    content: Some(HashMap::from([(
194                        "application/json".to_string(),
195                        json!({ "schema": {"$ref": "#/components/schemas/OperationOutcome" }}),
196                    )])),
197                },
198            ),
199        ]),
200        parameters: vec![json!({
201            "name": "id",
202            "in": "path",
203            "required": true,
204            "schema": {
205                "type": "string"
206            },
207            "description": format!("The ID of the {} resource", resource_name)
208        })],
209    }
210}
211
212fn patch_resource_operation(resource_name: &str) -> OpenAPIOperation {
213    OpenAPIOperation {
214        request_body: Some(OpenAPIOperationContent {
215            description: format!("JSON Patch operation for {} resource.", resource_name),
216            content: Some(HashMap::from([(
217                "application/json".to_string(),
218                json!({ "schema": {"type": "array" }}),
219            )])),
220        }),
221        responses: HashMap::from([
222            (
223                "200".to_string(),
224                OpenAPIOperationContent {
225                    description: format!("Successful patch of {} resource", resource_name),
226                    content: Some(HashMap::from([(
227                        "application/json".to_string(),
228                        json!({ "schema": {"$ref": format!("#/components/schemas/{}", resource_name) }}),
229                    )])),
230                },
231            ),
232            (
233                "400".to_string(),
234                OpenAPIOperationContent {
235                    description: "Client error".to_string(),
236                    content: Some(HashMap::from([(
237                        "application/json".to_string(),
238                        json!({ "schema": {"$ref": "#/components/schemas/OperationOutcome" }}),
239                    )])),
240                },
241            ),
242        ]),
243        parameters: vec![json!({
244            "name": "id",
245            "in": "path",
246            "required": true,
247            "schema": {
248                "type": "string"
249            },
250            "description": format!("The ID of the {} resource", resource_name)
251        })],
252    }
253}
254
255fn resource_search_parameters_schema(
256    resource_name: &str,
257    search_parameters: &Vec<SearchParameter>,
258) -> Vec<serde_json::Value> {
259    let mut params = vec![];
260
261    for sp in search_parameters.iter().filter(|sp| {
262        sp.base.iter().any(|b| {
263            let base: Option<String> = b.as_ref().into();
264            let base = base.as_ref().map(|s| s.as_str());
265            base == Some(resource_name)
266                || base == Some("Resource")
267                || base == Some("DomainResource")
268        }) && !matches!(sp.type_.as_ref(), &SearchParamType::Composite(_))
269    }) {
270        let search_type = match sp.type_.as_ref() {
271            SearchParamType::Quantity(_)
272            | SearchParamType::Special(_)
273            | SearchParamType::Token(_)
274            | SearchParamType::Uri(_)
275            | SearchParamType::Null(_)
276            | SearchParamType::Reference(_)
277            | SearchParamType::Composite(_)
278            | SearchParamType::Date(_)
279            | SearchParamType::String(_) => "string",
280
281            SearchParamType::Number(_) => "number",
282        };
283
284        params.push(json!({
285            "name": sp.code.value,
286            "in": "query",
287            "required": false,
288            "schema": {
289                "type": search_type
290            },
291            "description": sp.description.value.as_ref().map(|s| s.as_str()).unwrap_or("")
292        }));
293    }
294
295    params
296}
297
298fn create_resource_operation(resource_name: &str) -> OpenAPIOperation {
299    OpenAPIOperation {
300        request_body: Some(OpenAPIOperationContent {
301            description: format!("The {} resource to create", resource_name),
302            content: Some(HashMap::from([(
303                "application/json".to_string(),
304                json!({ "schema": {"$ref": format!("#/components/schemas/{}", resource_name) }}),
305            )])),
306        }),
307        responses: HashMap::from([
308            (
309                "200".to_string(),
310                OpenAPIOperationContent {
311                    description: format!("Successful creation of {} resource", resource_name),
312                    content: Some(HashMap::from([(
313                        "application/json".to_string(),
314                        json!({ "schema": {"$ref": format!("#/components/schemas/{}", resource_name) }}),
315                    )])),
316                },
317            ),
318            (
319                "400".to_string(),
320                OpenAPIOperationContent {
321                    description: "Client error".to_string(),
322                    content: Some(HashMap::from([(
323                        "application/json".to_string(),
324                        json!({ "schema": {"$ref": "#/components/schemas/OperationOutcome" }}),
325                    )])),
326                },
327            ),
328        ]),
329        parameters: vec![],
330    }
331}
332
333fn search_resource_operation(
334    resource_name: &str,
335    parameters: Vec<serde_json::Value>,
336) -> OpenAPIOperation {
337    OpenAPIOperation {
338        request_body: None,
339        responses: HashMap::from([
340            (
341                "200".to_string(),
342                OpenAPIOperationContent {
343                    description: "Successful search operation".to_string(),
344                    content: Some(HashMap::from([(
345                        "application/json".to_string(),
346                        json!({ "schema": haste_sd_to_json_schema::bundle_of_resource(json!({
347                            "$ref": format!("#/components/schemas/{}", resource_name)
348                        })) }),
349                    )])),
350                },
351            ),
352            (
353                "400".to_string(),
354                OpenAPIOperationContent {
355                    description: "Client error".to_string(),
356                    content: Some(HashMap::from([(
357                        "application/json".to_string(),
358                        json!({ "schema": {"$ref": "#/components/schemas/OperationOutcome" }}),
359                    )])),
360                },
361            ),
362        ]),
363        parameters,
364    }
365}
366
367fn delete_resource_operation(parameters: Vec<serde_json::Value>) -> OpenAPIOperation {
368    OpenAPIOperation {
369        request_body: None,
370        responses: HashMap::from([
371            (
372                "200".to_string(),
373                OpenAPIOperationContent {
374                    description: "Successful delete operation".to_string(),
375                    content: None,
376                },
377            ),
378            (
379                "400".to_string(),
380                OpenAPIOperationContent {
381                    description: "Client error".to_string(),
382                    content: Some(HashMap::from([(
383                        "application/json".to_string(),
384                        json!({ "schema": {"$ref": "#/components/schemas/OperationOutcome" }}),
385                    )])),
386                },
387            ),
388        ]),
389        parameters,
390    }
391}
392
393pub fn open_api_schema_generator(
394    server_root: &str,
395    api_version: &str,
396    sds: &Vec<StructureDefinition>,
397    search_parameters: &Vec<SearchParameter>,
398) -> Result<OpenAPI, OperationOutcomeError> {
399    let mut fhir_server_variables = HashMap::new();
400    fhir_server_variables.insert(
401        "tenant".to_string(),
402        OpenAPIServerVariable {
403            default: "my-tenant".to_string(),
404            description: Some("Tenant identifier".to_string()),
405        },
406    );
407    fhir_server_variables.insert(
408        "project".to_string(),
409        OpenAPIServerVariable {
410            default: "my-project".to_string(),
411            description: Some("Project identifier".to_string()),
412        },
413    );
414    fhir_server_variables.insert(
415        "fhir_version".to_string(),
416        OpenAPIServerVariable {
417            default: "r4".to_string(),
418            description: Some("FHIR version".to_string()),
419        },
420    );
421    let mut openapi_schema = OpenAPI {
422        openapi: "3.1.1".to_string(),
423        servers: vec![OpenAPIServer {
424            url: format!(
425                "{}/w/{}/{}/api/v1/fhir/{}",
426                server_root, "{tenant}", "{project}", "{fhir_version}"
427            ),
428            description: Some("Haste Health FHIR Server".to_string()),
429            variables: fhir_server_variables,
430        }],
431        info: OpenAPIInfo {
432            title: "Haste Health API Documentation".to_string(),
433            version: api_version.to_string(),
434        },
435        components: OpenAPIComponents {
436            schemas: HashMap::new(),
437        },
438
439        paths: HashMap::new(),
440    };
441
442    let complex_sds = sds.iter().filter(|sd| match sd.kind.as_ref() {
443        StructureDefinitionKind::ComplexType(_) => true,
444        _ => false,
445    });
446
447    for sd in complex_sds {
448        let json_schema = haste_sd_to_json_schema::isolated_schema("#/components/schemas", sd)?;
449        let type_name = sd.type_.value.as_ref().ok_or_else(|| {
450            OperationOutcomeError::error(
451                IssueType::Structure(None),
452                format!(
453                    "StructureDefinition missing type for id {}",
454                    sd.id.as_ref().unwrap_or(&"unknown".to_string())
455                ),
456            )
457        })?;
458        openapi_schema
459            .components
460            .schemas
461            .insert(type_name.clone(), json_schema);
462    }
463
464    let resource_sds = sds.iter().filter(|sd| match sd.kind.as_ref() {
465        StructureDefinitionKind::Resource(_) => true,
466        _ => false,
467    });
468
469    for sd in resource_sds {
470        let json_schema = haste_sd_to_json_schema::isolated_schema("#/components/schemas", sd)?;
471        let resource_name = sd.type_.value.as_ref().ok_or_else(|| {
472            OperationOutcomeError::error(
473                IssueType::Structure(None),
474                format!(
475                    "StructureDefinition missing type for id {}",
476                    sd.id.as_ref().unwrap_or(&"unknown".to_string())
477                ),
478            )
479        })?;
480
481        // Read Operation
482        openapi_schema.paths.insert(
483            format!("/{}/{{id}}", resource_name),
484            OpenAPIPathItem {
485                get: Some(read_resource_operation(&resource_name)),
486                post: None,
487                patch: Some(patch_resource_operation(&resource_name)),
488                put: Some(put_resource_operation(&resource_name)),
489                delete: Some(delete_instance_operation(&resource_name)),
490            },
491        );
492
493        let resource_search_parameters =
494            resource_search_parameters_schema(&resource_name, search_parameters);
495
496        openapi_schema.paths.insert(
497            format!("/{}", resource_name),
498            OpenAPIPathItem {
499                get: Some(search_resource_operation(
500                    &resource_name,
501                    resource_search_parameters.clone(),
502                )),
503                patch: None,
504                put: None,
505                post: Some(create_resource_operation(&resource_name)),
506                delete: Some(delete_resource_operation(resource_search_parameters)),
507            },
508        );
509
510        openapi_schema
511            .components
512            .schemas
513            .insert(resource_name.clone(), json_schema);
514    }
515
516    openapi_schema.components.schemas.insert(
517        "Element".to_string(),
518        json!({
519            "additionalProperties": false,
520            "properties": {
521                "extension": {
522                    "items": {
523                        "$ref": "#/components/schemas/Extension"
524                    },
525                    "type": "array"
526                },
527                "id": {
528                    "type": "string"
529                }
530            },
531            "required": [],
532            "type": "object"
533        }),
534    );
535
536    Ok(openapi_schema)
537}