Skip to main content

haste_health/commands/
api.rs

1use crate::{cli::state::CliState, utilities};
2use clap::Subcommand;
3use haste_fhir_client::{FHIRClient, url::ParsedParameters};
4use haste_fhir_model::r4::generated::{
5    resources::{Bundle, Resource, ResourceType},
6    terminology::IssueType,
7};
8use haste_fhir_operation_error::OperationOutcomeError;
9use std::sync::Arc;
10use tokio::sync::Mutex;
11
12/// Make FHIR REST API calls against the active profile's server.
13///
14/// Commands that take a resource body accept it via `--data` (inline JSON), `--file`
15/// (a JSON file), or, if neither is given, a single line read from stdin.
16#[derive(Subcommand, Debug)]
17pub(crate) enum ApiCommands {
18    /// Create a resource (`POST [base]/[type]`).
19    Create {
20        /// Resource JSON, inline.
21        #[arg(short, long)]
22        data: Option<String>,
23        /// Path to a file containing the resource JSON.
24        #[arg(short, long)]
25        file: Option<String>,
26        /// FHIR resource type to create.
27        resource_type: String,
28    },
29    /// Read the current version of a resource (`GET [base]/[type]/[id]`).
30    Read { resource_type: String, id: String },
31
32    /// Read a specific historical version of a resource (`GET [base]/[type]/[id]/_history/[vid]`).
33    VersionRead {
34        resource_type: String,
35        id: String,
36        version_id: String,
37    },
38
39    /// Apply a JSON Patch to a resource (`PATCH [base]/[type]/[id]`).
40    Patch {
41        /// JSON Patch document, inline.
42        #[arg(short, long)]
43        data: Option<String>,
44        /// Path to a file containing the JSON Patch document.
45        #[arg(short, long)]
46        file: Option<String>,
47        resource_type: String,
48        id: String,
49    },
50    /// Create or replace a resource at a known ID (`PUT [base]/[type]/[id]`).
51    Update {
52        /// Resource JSON, inline.
53        #[arg(short, long)]
54        data: Option<String>,
55        /// Path to a file containing the resource JSON.
56        #[arg(short, long)]
57        file: Option<String>,
58        resource_type: String,
59        id: String,
60    },
61    /// Submit a transaction Bundle (`POST [base]`, type `transaction`).
62    Transaction {
63        /// Bundle JSON, inline.
64        #[arg(short, long)]
65        data: Option<String>,
66        /// Submit the same bundle this many times concurrently. Defaults to 1.
67        #[arg(short, long)]
68        parallel: Option<usize>,
69        /// Path to a file containing the Bundle JSON.
70        #[arg(short, long)]
71        file: Option<String>,
72        /// Print each response bundle to stdout.
73        #[arg(short, long)]
74        output: Option<bool>,
75    },
76    /// Submit a batch Bundle (`POST [base]`, type `batch`).
77    Batch {
78        /// Bundle JSON, inline.
79        #[arg(short, long)]
80        data: Option<String>,
81        /// Path to a file containing the Bundle JSON.
82        #[arg(short, long)]
83        file: Option<String>,
84        /// Print the response bundle to stdout.
85        #[arg(short, long)]
86        output: Option<bool>,
87    },
88
89    /// Fetch the system-wide history (`GET [base]/_history`).
90    HistorySystem {
91        /// FHIR search-style parameters, e.g. `_since=2024-01-01`.
92        parameters: Option<String>,
93    },
94
95    /// Fetch the history of a resource type (`GET [base]/[type]/_history`).
96    HistoryType {
97        resource_type: String,
98        /// FHIR search-style parameters, e.g. `_since=2024-01-01`.
99        parameters: Option<String>,
100    },
101
102    /// Fetch the history of a single resource instance (`GET [base]/[type]/[id]/_history`).
103    HistoryInstance {
104        resource_type: String,
105        id: String,
106        /// FHIR search-style parameters, e.g. `_since=2024-01-01`.
107        parameters: Option<String>,
108    },
109
110    /// Search a resource type (`GET [base]/[type]?...`).
111    SearchType {
112        resource_type: String,
113        /// FHIR search parameters, e.g. `name=eve&_count=20`.
114        parameters: Option<String>,
115    },
116
117    /// Search across all resource types (`GET [base]?...`).
118    SearchSystem {
119        /// FHIR search parameters, e.g. `_lastUpdated=gt2024-01-01`.
120        parameters: Option<String>,
121    },
122
123    /// Invoke a system-level operation (`POST [base]/$[operation_name]`).
124    InvokeSystem {
125        /// Parameters resource JSON, inline.
126        #[arg(short, long)]
127        data: Option<String>,
128        /// Path to a file containing the Parameters resource JSON.
129        #[arg(short, long)]
130        file: Option<String>,
131        operation_name: String,
132    },
133
134    /// Invoke a type-level operation (`POST [base]/[type]/$[operation_name]`).
135    InvokeType {
136        /// Parameters resource JSON, inline.
137        #[arg(short, long)]
138        data: Option<String>,
139        /// Path to a file containing the Parameters resource JSON.
140        #[arg(short, long)]
141        file: Option<String>,
142        resource_type: String,
143        operation_name: String,
144    },
145
146    /// Fetch the server's CapabilityStatement (`GET [base]/metadata`).
147    Capabilities {},
148
149    /// Delete a single resource instance (`DELETE [base]/[type]/[id]`).
150    DeleteInstance { resource_type: String, id: String },
151
152    /// Delete all resources of a type matching search parameters (`DELETE [base]/[type]?...`).
153    DeleteType {
154        resource_type: String,
155        /// FHIR search parameters selecting which resources to delete.
156        parameters: Option<String>,
157    },
158
159    /// Delete all resources matching system-level search parameters (`DELETE [base]?...`).
160    DeleteSystem {
161        /// FHIR search parameters selecting which resources to delete.
162        parameters: Option<String>,
163    },
164
165    /// Invoke an instance-level operation (`POST [base]/[type]/[id]/$[operation_name]`).
166    InvokeInstance {
167        /// Parameters resource JSON, inline.
168        #[arg(short, long)]
169        data: Option<String>,
170        /// Path to a file containing the Parameters resource JSON.
171        #[arg(short, long)]
172        file: Option<String>,
173        resource_type: String,
174        id: String,
175        operation_name: String,
176    },
177}
178
179async fn derive_resource_data_arg_file_arg_or_stdin<Type: serde::de::DeserializeOwned>(
180    data_arg: &Option<String>,
181    file_path: &Option<String>,
182) -> Result<Type, OperationOutcomeError> {
183    if let Some(data) = data_arg {
184        serde_json::from_str::<Type>(data).map_err(|e| {
185            OperationOutcomeError::error(
186                IssueType::exception(),
187                format!("Failed to parse transaction data: {}", e),
188            )
189        })
190    } else if let Some(file_path) = file_path {
191        let file_content = tokio::fs::read_to_string(file_path).await.map_err(|e| {
192            OperationOutcomeError::error(
193                IssueType::exception(),
194                format!("Failed to read transaction file: {}", e),
195            )
196        })?;
197
198        serde_json::from_str::<Type>(&file_content).map_err(|e| {
199            OperationOutcomeError::error(
200                IssueType::exception(),
201                format!("Failed to parse file: {}", e),
202            )
203        })
204    } else {
205        // Read from stdin
206        let mut buffer = String::new();
207        utilities::read_stdin(&mut buffer)?;
208
209        serde_json::from_str::<Type>(&buffer).map_err(|e| {
210            OperationOutcomeError::error(
211                IssueType::exception(),
212                format!("Failed to parse transaction from stdin: {}", e),
213            )
214        })
215    }
216}
217
218/// Runs the `api` command group.
219pub(crate) async fn run(
220    state: Arc<Mutex<CliState>>,
221    command: &ApiCommands,
222) -> Result<(), OperationOutcomeError> {
223    let fhir_client = crate::cli::client::fhir_client(state).await?;
224
225    match command {
226        ApiCommands::Create {
227            data,
228            resource_type,
229            file,
230        } => {
231            let resource_type = ResourceType::try_from(resource_type.as_str()).map_err(|e| {
232                OperationOutcomeError::error(
233                    IssueType::invalid(),
234                    format!(
235                        "'{}' is not a valid FHIR resource type: {}",
236                        resource_type, e
237                    ),
238                )
239            })?;
240
241            let resource =
242                derive_resource_data_arg_file_arg_or_stdin::<Resource>(data, file).await?;
243
244            let result = fhir_client.create((), resource_type, resource).await?;
245
246            println!(
247                "{}",
248                serde_json::to_string_pretty(&result).expect("Failed to serialize response")
249            );
250
251            Ok(())
252        }
253        ApiCommands::Read { resource_type, id } => {
254            let resource_type = ResourceType::try_from(resource_type.as_str()).map_err(|e| {
255                OperationOutcomeError::error(
256                    IssueType::invalid(),
257                    format!(
258                        "'{}' is not a valid FHIR resource type: {}",
259                        resource_type, e
260                    ),
261                )
262            })?;
263
264            let result = fhir_client.read((), resource_type, id.clone()).await?;
265
266            println!(
267                "{}",
268                serde_json::to_string_pretty(&result).expect("Failed to serialize response")
269            );
270
271            Ok(())
272        }
273        ApiCommands::Patch {
274            resource_type,
275            id,
276            data,
277            file,
278        } => {
279            let patches = if let Some(file) = file {
280                let file_content = tokio::fs::read_to_string(file).await.map_err(|e| {
281                    OperationOutcomeError::error(
282                        IssueType::exception(),
283                        format!("Failed to read transaction file: {}", e),
284                    )
285                })?;
286
287                serde_json::from_str::<json_patch::Patch>(&file_content).map_err(|e| {
288                    OperationOutcomeError::error(
289                        IssueType::invalid(),
290                        format!("Failed to parse patch JSON: {}", e),
291                    )
292                })?
293            } else if let Some(data) = data {
294                serde_json::from_str::<json_patch::Patch>(&data).map_err(|e| {
295                    OperationOutcomeError::error(
296                        IssueType::invalid(),
297                        format!("Failed to parse patch JSON: {}", e),
298                    )
299                })?
300            } else {
301                return Err(OperationOutcomeError::error(
302                    IssueType::invalid(),
303                    "Either --data or --file must be provided for patch operation.".to_string(),
304                ));
305            };
306
307            let resource_type = ResourceType::try_from(resource_type.as_str()).map_err(|e| {
308                OperationOutcomeError::error(
309                    IssueType::invalid(),
310                    format!(
311                        "'{}' is not a valid FHIR resource type: {}",
312                        resource_type, e
313                    ),
314                )
315            })?;
316
317            let result = fhir_client
318                .patch((), resource_type, id.clone(), patches)
319                .await?;
320
321            println!(
322                "{}",
323                serde_json::to_string_pretty(&result).expect("Failed to serialize response")
324            );
325
326            Ok(())
327        }
328        ApiCommands::Update {
329            resource_type,
330            id,
331            data,
332            file,
333        } => {
334            let resource_type = ResourceType::try_from(resource_type.as_str()).map_err(|e| {
335                OperationOutcomeError::error(
336                    IssueType::invalid(),
337                    format!(
338                        "'{}' is not a valid FHIR resource type: {}",
339                        resource_type, e
340                    ),
341                )
342            })?;
343
344            let resource =
345                derive_resource_data_arg_file_arg_or_stdin::<Resource>(data, file).await?;
346
347            let result = fhir_client
348                .update((), resource_type, id.clone(), resource)
349                .await?;
350
351            println!(
352                "{}",
353                serde_json::to_string_pretty(&result).expect("Failed to serialize response")
354            );
355            Ok(())
356        }
357        ApiCommands::Transaction {
358            data,
359            file,
360            output,
361            parallel,
362        } => {
363            let bundle = derive_resource_data_arg_file_arg_or_stdin::<Bundle>(data, file).await?;
364
365            let parallel = parallel.unwrap_or(1);
366
367            let mut futures = tokio::task::JoinSet::new();
368
369            for _ in 0..parallel {
370                let client = fhir_client.clone();
371                let bundle = bundle.clone();
372                let res = async move { client.transaction((), bundle).await };
373                futures.spawn(res);
374            }
375
376            let res = futures.join_all().await;
377
378            for bundle_result in res {
379                let bundle = bundle_result?;
380                if let Some(true) = output {
381                    println!(
382                        "{}",
383                        serde_json::to_string_pretty(&bundle)
384                            .expect("Failed to serialize response")
385                    );
386                }
387            }
388
389            Ok(())
390        }
391        ApiCommands::VersionRead {
392            resource_type,
393            id,
394            version_id,
395        } => {
396            let resource_type = ResourceType::try_from(resource_type.as_str()).map_err(|e| {
397                OperationOutcomeError::error(
398                    IssueType::invalid(),
399                    format!(
400                        "'{}' is not a valid FHIR resource type: {}",
401                        resource_type, e
402                    ),
403                )
404            })?;
405
406            let result = fhir_client
407                .vread((), resource_type, id.clone(), version_id.clone())
408                .await?;
409
410            println!(
411                "{}",
412                serde_json::to_string_pretty(&result).expect("Failed to serialize response")
413            );
414
415            Ok(())
416        }
417        ApiCommands::Batch { data, file, output } => {
418            let bundle = derive_resource_data_arg_file_arg_or_stdin::<Bundle>(data, file).await?;
419
420            let result = fhir_client.batch((), bundle).await?;
421
422            if let Some(true) = output {
423                println!(
424                    "{}",
425                    serde_json::to_string_pretty(&result).expect("Failed to serialize response")
426                );
427            }
428
429            Ok(())
430        }
431        ApiCommands::HistorySystem { parameters } => {
432            let result = fhir_client
433                .history_system(
434                    (),
435                    ParsedParameters::try_from(parameters.clone().unwrap_or_default().as_str())?,
436                )
437                .await?;
438
439            println!(
440                "{}",
441                serde_json::to_string_pretty(&result).expect("Failed to serialize response")
442            );
443
444            Ok(())
445        }
446        ApiCommands::HistoryType {
447            resource_type,
448            parameters,
449        } => {
450            let resource_type = ResourceType::try_from(resource_type.as_str()).map_err(|e| {
451                OperationOutcomeError::error(
452                    IssueType::invalid(),
453                    format!(
454                        "'{}' is not a valid FHIR resource type: {}",
455                        resource_type, e
456                    ),
457                )
458            })?;
459
460            let result = fhir_client
461                .history_type(
462                    (),
463                    resource_type,
464                    ParsedParameters::try_from(parameters.clone().unwrap_or_default().as_str())?,
465                )
466                .await?;
467
468            println!(
469                "{}",
470                serde_json::to_string_pretty(&result).expect("Failed to serialize response")
471            );
472
473            Ok(())
474        }
475        ApiCommands::HistoryInstance {
476            resource_type,
477            id,
478            parameters,
479        } => {
480            let resource_type = ResourceType::try_from(resource_type.as_str()).map_err(|e| {
481                OperationOutcomeError::error(
482                    IssueType::invalid(),
483                    format!(
484                        "'{}' is not a valid FHIR resource type: {}",
485                        resource_type, e
486                    ),
487                )
488            })?;
489
490            let result = fhir_client
491                .history_instance(
492                    (),
493                    resource_type,
494                    id.clone(),
495                    ParsedParameters::try_from(parameters.clone().unwrap_or_default().as_str())?,
496                )
497                .await?;
498
499            println!(
500                "{}",
501                serde_json::to_string_pretty(&result).expect("Failed to serialize response")
502            );
503
504            Ok(())
505        }
506        ApiCommands::SearchType {
507            resource_type,
508            parameters,
509        } => {
510            let resource_type = ResourceType::try_from(resource_type.as_str()).map_err(|e| {
511                OperationOutcomeError::error(
512                    IssueType::invalid(),
513                    format!(
514                        "'{}' is not a valid FHIR resource type: {}",
515                        resource_type, e
516                    ),
517                )
518            })?;
519
520            let result = fhir_client
521                .search_type(
522                    (),
523                    resource_type,
524                    ParsedParameters::try_from(parameters.clone().unwrap_or_default().as_str())?,
525                )
526                .await?;
527
528            println!(
529                "{}",
530                serde_json::to_string_pretty(&result).expect("Failed to serialize response")
531            );
532
533            Ok(())
534        }
535        ApiCommands::SearchSystem { parameters } => {
536            let result = fhir_client
537                .search_system(
538                    (),
539                    ParsedParameters::try_from(parameters.clone().unwrap_or_default().as_str())?,
540                )
541                .await?;
542
543            println!(
544                "{}",
545                serde_json::to_string_pretty(&result).expect("Failed to serialize response")
546            );
547
548            Ok(())
549        }
550        ApiCommands::InvokeSystem {
551            operation_name,
552            file,
553            data,
554        } => {
555            let parameters = derive_resource_data_arg_file_arg_or_stdin::<
556                haste_fhir_model::r4::generated::resources::Parameters,
557            >(data, file)
558            .await?;
559
560            let result = fhir_client
561                .invoke_system((), operation_name.clone(), parameters)
562                .await?;
563
564            println!(
565                "{}",
566                serde_json::to_string_pretty(&result).expect("Failed to serialize response")
567            );
568
569            Ok(())
570        }
571        ApiCommands::InvokeType {
572            resource_type,
573            operation_name,
574            file,
575            data,
576        } => {
577            let resource_type = ResourceType::try_from(resource_type.as_str()).map_err(|e| {
578                OperationOutcomeError::error(
579                    IssueType::invalid(),
580                    format!(
581                        "'{}' is not a valid FHIR resource type: {}",
582                        resource_type, e
583                    ),
584                )
585            })?;
586
587            let parameters = derive_resource_data_arg_file_arg_or_stdin::<
588                haste_fhir_model::r4::generated::resources::Parameters,
589            >(data, file)
590            .await?;
591
592            let result = fhir_client
593                .invoke_type((), resource_type, operation_name.clone(), parameters)
594                .await?;
595
596            println!(
597                "{}",
598                serde_json::to_string_pretty(&result).expect("Failed to serialize response")
599            );
600
601            Ok(())
602        }
603        ApiCommands::InvokeInstance {
604            resource_type,
605            id,
606            operation_name,
607            file,
608            data,
609        } => {
610            let resource_type = ResourceType::try_from(resource_type.as_str()).map_err(|e| {
611                OperationOutcomeError::error(
612                    IssueType::invalid(),
613                    format!(
614                        "'{}' is not a valid FHIR resource type: {}",
615                        resource_type, e
616                    ),
617                )
618            })?;
619
620            let parameters = derive_resource_data_arg_file_arg_or_stdin::<
621                haste_fhir_model::r4::generated::resources::Parameters,
622            >(data, file)
623            .await?;
624
625            let result = fhir_client
626                .invoke_instance(
627                    (),
628                    resource_type,
629                    id.clone(),
630                    operation_name.clone(),
631                    parameters,
632                )
633                .await?;
634
635            println!(
636                "{}",
637                serde_json::to_string_pretty(&result).expect("Failed to serialize response")
638            );
639
640            Ok(())
641        }
642        ApiCommands::Capabilities {} => {
643            let result = fhir_client.capabilities(()).await?;
644
645            println!(
646                "{}",
647                serde_json::to_string_pretty(&result).expect("Failed to serialize response")
648            );
649
650            Ok(())
651        }
652        ApiCommands::DeleteInstance { resource_type, id } => {
653            let resource_type = ResourceType::try_from(resource_type.as_str()).map_err(|e| {
654                OperationOutcomeError::error(
655                    IssueType::invalid(),
656                    format!(
657                        "'{}' is not a valid FHIR resource type: {}",
658                        resource_type, e
659                    ),
660                )
661            })?;
662
663            fhir_client
664                .delete_instance((), resource_type.clone(), id.clone())
665                .await?;
666
667            println!(
668                "Resource of type '{}' with ID '{}' deleted.",
669                resource_type.as_ref(),
670                id
671            );
672
673            Ok(())
674        }
675        ApiCommands::DeleteType {
676            resource_type,
677            parameters,
678        } => {
679            let resource_type = ResourceType::try_from(resource_type.as_str()).map_err(|e| {
680                OperationOutcomeError::error(
681                    IssueType::invalid(),
682                    format!(
683                        "'{}' is not a valid FHIR resource type: {}",
684                        resource_type, e
685                    ),
686                )
687            })?;
688
689            let parsed_parameters =
690                ParsedParameters::try_from(parameters.clone().unwrap_or_default().as_str())?;
691
692            fhir_client
693                .delete_type((), resource_type.clone(), parsed_parameters)
694                .await?;
695
696            println!(
697                "Resources of type '{}' deleted based on provided parameters.",
698                resource_type.as_ref()
699            );
700
701            Ok(())
702        }
703        ApiCommands::DeleteSystem { parameters } => {
704            let parsed_parameters =
705                ParsedParameters::try_from(parameters.clone().unwrap_or_default().as_str())?;
706
707            fhir_client.delete_system((), parsed_parameters).await?;
708
709            println!("Resources deleted based on provided system-level parameters.");
710
711            Ok(())
712        }
713    }
714}