Skip to main content

haste_health/commands/
hl7v2.rs

1//! Bridge HL7 v2 messages to and from the FHIR server.
2//!
3//! The receiver is an MLLP listener: it accepts framed HL7 v2 messages,
4//! converts each one to FHIR with a Jinja template, submits the result, and
5//! answers the sender with an HL7 `ACK`.
6//!
7//! # What "production ready" means here
8//!
9//! An interface engine is not a browser. It keeps one socket open for hours,
10//! sends thousands of messages down it, retries anything it does not see an
11//! `ACK` for, and treats a missing or malformed acknowledgement as a failure to
12//! deliver. The properties that follow from that:
13//!
14//! - **Every message is answered.** A parse failure, a template failure and a
15//!   write failure each produce an `ACK` with the appropriate `MSA-1`, never a
16//!   dropped connection. A sender that gets silence retries forever.
17//! - **`AR` vs `AE` is chosen deliberately.** `AR` (reject) means the sender
18//!   should not retry -- the message itself is wrong. `AE` (error) means it
19//!   should -- we could not store a message that may well be fine. Getting this
20//!   backwards either loses data or produces an infinite retry loop.
21//! - **One message cannot take down the listener.** Each connection is its own
22//!   task and each message its own error scope.
23//! - **Every wait is bounded.** A half-open socket, a sender that stops
24//!   mid-frame, or a frame that never terminates are all survivable.
25//! - **Shutdown drains.** On SIGINT/SIGTERM the listener stops accepting and
26//!   lets in-flight messages finish, so a message is not acknowledged and then
27//!   lost, or lost without being acknowledged.
28
29use crate::cli::state::CliState;
30use clap::Subcommand;
31use haste_fhir_client::FHIRClient;
32use haste_fhir_converter::Input;
33use haste_fhir_model::r4::generated::resources::Resource;
34use haste_fhir_model::r4::generated::terminology::{BundleType, IssueType};
35use haste_fhir_operation_error::OperationOutcomeError;
36use haste_hl7v2::ack::{AckCode, InboundHeader, build_ack};
37use haste_hl7v2::mllp_async::{FrameEnd, encode_frame, read_frame};
38use std::collections::HashMap;
39use std::net::SocketAddr;
40use std::sync::Arc;
41use std::time::Duration;
42use tokio::io::AsyncWriteExt;
43use tokio::net::{TcpListener, TcpStream};
44use tokio::sync::{Mutex, Semaphore};
45
46/// Bridge HL7v2 messages to and from the FHIR server.
47#[derive(Subcommand, Debug)]
48pub(crate) enum HL7v2Commands {
49    /// Listen for MLLP-framed HL7v2 messages, convert them to FHIR, and submit them.
50    Receiver {
51        /// Address to bind the MLLP listener to.
52        #[arg(short, long, default_value = "0.0.0.0")]
53        address: String,
54        /// Port to bind the MLLP listener to.
55        #[arg(short, long)]
56        port: u16,
57        /// Entry template file name (resolved within --template-dir) used to convert
58        /// incoming HL7v2 messages to FHIR.
59        #[arg(short, long)]
60        main: String,
61        /// Directory containing the conversion templates.
62        #[arg(short, long)]
63        template_dir: String,
64        /// Maximum connections served at once. Further senders wait rather than
65        /// being refused, since a refused connection looks like an outage.
66        #[arg(long, default_value_t = 64)]
67        max_connections: usize,
68        /// Close a connection that sends nothing for this long, in seconds.
69        /// Bounds half-open sockets, which TCP alone will not detect.
70        #[arg(long, default_value_t = 300)]
71        idle_timeout_secs: u64,
72        /// Give up on a partially received message after this long, in seconds.
73        #[arg(long, default_value_t = 30)]
74        read_timeout_secs: u64,
75    },
76    /// Send HL7v2 messages over MLLP.
77    Sender {
78        /// Address of the MLLP receiver to send to.
79        #[arg(short, long)]
80        address: String,
81        /// Port of the MLLP receiver to send to.
82        #[arg(short, long)]
83        port: u16,
84        /// File containing the HL7v2 message to send. Reads stdin when absent.
85        #[arg(short, long)]
86        file: Option<String>,
87        /// How long to wait for the ACK, in seconds.
88        #[arg(long, default_value_t = 30)]
89        timeout_secs: u64,
90    },
91}
92
93/// Settings a connection task needs, resolved once at startup.
94struct ReceiverConfig {
95    template_dir: String,
96    main_template: String,
97    idle_timeout: Duration,
98    read_timeout: Duration,
99}
100
101/// What handling one message produced.
102struct MessageOutcome {
103    code: AckCode,
104    detail: Option<String>,
105}
106
107impl MessageOutcome {
108    fn accepted() -> Self {
109        Self {
110            code: AckCode::ApplicationAccept,
111            detail: None,
112        }
113    }
114
115    /// The message is wrong; the sender must not retry it unchanged.
116    fn rejected(detail: impl Into<String>) -> Self {
117        Self {
118            code: AckCode::ApplicationReject,
119            detail: Some(detail.into()),
120        }
121    }
122
123    /// We could not store a message that may be fine; the sender should retry.
124    fn errored(detail: impl Into<String>) -> Self {
125        Self {
126            code: AckCode::ApplicationError,
127            detail: Some(detail.into()),
128        }
129    }
130}
131
132/// Runs the `hl7v2` command group.
133pub(crate) async fn run(
134    state: Arc<Mutex<CliState>>,
135    command: &HL7v2Commands,
136) -> Result<(), OperationOutcomeError> {
137    match command {
138        HL7v2Commands::Receiver {
139            address,
140            port,
141            main,
142            template_dir,
143            max_connections,
144            idle_timeout_secs,
145            read_timeout_secs,
146        } => {
147            let fhir_client = crate::cli::client::fhir_client(state).await?;
148
149            // Templates are compiled once at startup so a broken template is a
150            // startup failure rather than a per-message one. Building the
151            // environment per message would also reparse every file on every
152            // message, which at interface-engine volumes dominates the work.
153            let environment = haste_fhir_converter::create_environment(Some(template_dir));
154            environment.get_template(main).map_err(|e| {
155                OperationOutcomeError::fatal(
156                    IssueType::not_found(),
157                    format!("Conversion template '{main}' not found in '{template_dir}': {e}"),
158                )
159            })?;
160
161            let config = Arc::new(ReceiverConfig {
162                template_dir: template_dir.clone(),
163                main_template: main.clone(),
164                idle_timeout: Duration::from_secs(*idle_timeout_secs),
165                read_timeout: Duration::from_secs(*read_timeout_secs),
166            });
167
168            serve(
169                &format!("{address}:{port}"),
170                fhir_client,
171                config,
172                *max_connections,
173            )
174            .await
175        }
176        HL7v2Commands::Sender {
177            address,
178            port,
179            file,
180            timeout_secs,
181        } => {
182            send_message(
183                &format!("{address}:{port}"),
184                file.as_deref(),
185                Duration::from_secs(*timeout_secs),
186            )
187            .await
188        }
189    }
190}
191
192/// Accepts connections until shutdown is signalled.
193async fn serve<Client>(
194    bind: &str,
195    fhir_client: Arc<Client>,
196    config: Arc<ReceiverConfig>,
197    max_connections: usize,
198) -> Result<(), OperationOutcomeError>
199where
200    Client: FHIRClient<(), OperationOutcomeError> + Send + Sync + 'static,
201{
202    let listener = TcpListener::bind(bind).await.map_err(|e| {
203        OperationOutcomeError::fatal(
204            IssueType::exception(),
205            format!("Failed to bind the MLLP listener to {bind}: {e}"),
206        )
207    })?;
208
209    let local = listener
210        .local_addr()
211        .map_or_else(|_| bind.to_string(), |a| a.to_string());
212    tracing::info!(
213        address = %local,
214        max_connections,
215        template = %config.main_template,
216        "MLLP listener started"
217    );
218
219    // Bounds concurrent connections without refusing any: a sender that is
220    // turned away cannot tell an overloaded listener from a down one, and will
221    // usually alert rather than wait.
222    let permits = Arc::new(Semaphore::new(max_connections));
223    // Held by each in-flight connection so shutdown can wait for them.
224    let in_flight = Arc::new(tokio::sync::RwLock::new(()));
225
226    loop {
227        tokio::select! {
228            // Biased so a pending shutdown wins over a ready connection;
229            // otherwise a busy listener could accept indefinitely.
230            biased;
231
232            _ = shutdown_signal() => {
233                tracing::info!("Shutdown signalled; no longer accepting connections");
234                break;
235            }
236
237            accepted = listener.accept() => {
238                let (stream, peer) = match accepted {
239                    Ok(pair) => pair,
240                    Err(e) => {
241                        // A failed accept is usually transient (a file
242                        // descriptor limit, a connection reset during the
243                        // handshake) and must not end the listener.
244                        tracing::warn!("Failed to accept an MLLP connection: {e}");
245                        continue;
246                    }
247                };
248
249                let Ok(permit) = permits.clone().acquire_owned().await else {
250                    break;
251                };
252
253                let fhir_client = fhir_client.clone();
254                let config = config.clone();
255                let guard = in_flight.clone();
256
257                tokio::spawn(async move {
258                    let _permit = permit;
259                    let _guard = guard.read().await;
260                    handle_connection(stream, peer, fhir_client, config).await;
261                });
262            }
263        }
264    }
265
266    // Draining: every connection task holds a read guard, so the write waits
267    // until the last one finishes. A message already acknowledged has been
268    // written; one not yet acknowledged will be retried by the sender.
269    tracing::info!("Draining in-flight messages");
270    std::mem::drop(in_flight.write().await);
271    tracing::info!("MLLP listener stopped");
272
273    Ok(())
274}
275
276/// Resolves when the process is asked to stop.
277async fn shutdown_signal() {
278    let interrupt = tokio::signal::ctrl_c();
279
280    #[cfg(unix)]
281    {
282        let mut terminate =
283            match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
284                Ok(signal) => signal,
285                Err(e) => {
286                    tracing::warn!("Could not listen for SIGTERM: {e}");
287                    let _ = interrupt.await;
288                    return;
289                }
290            };
291
292        tokio::select! {
293            _ = interrupt => {}
294            _ = terminate.recv() => {}
295        }
296    }
297
298    #[cfg(not(unix))]
299    {
300        let _ = interrupt.await;
301    }
302}
303
304/// Serves one sender until it disconnects, idles out, or breaks framing.
305async fn handle_connection<Client>(
306    mut stream: TcpStream,
307    peer: SocketAddr,
308    fhir_client: Arc<Client>,
309    config: Arc<ReceiverConfig>,
310) where
311    Client: FHIRClient<(), OperationOutcomeError> + Send + Sync + 'static,
312{
313    // Messages are small and latency matters more than packet efficiency; an
314    // ACK held by Nagle's algorithm looks to the sender like a slow receiver.
315    if let Err(e) = stream.set_nodelay(true) {
316        tracing::debug!(%peer, "Could not disable Nagle: {e}");
317    }
318
319    tracing::debug!(%peer, "MLLP connection opened");
320    let mut handled: u64 = 0;
321
322    loop {
323        // The idle timeout covers waiting for the *next* message; the read
324        // timeout covers finishing one already started. They are separate
325        // because a connection may legitimately sit quiet for minutes between
326        // messages, but must never sit half-written for that long.
327        let frame = match tokio::time::timeout(config.idle_timeout, read_frame(&mut stream)).await {
328            Err(_) => {
329                tracing::debug!(%peer, handled, "Closing idle MLLP connection");
330                break;
331            }
332            Ok(Ok(FrameEnd::Closed)) => {
333                tracing::debug!(%peer, handled, "MLLP connection closed by peer");
334                break;
335            }
336            Ok(Ok(FrameEnd::Frame(payload))) => payload,
337            Ok(Err(e)) => {
338                // Framing is broken, so the stream position is unknown and no
339                // further message on it can be trusted. There is also no
340                // control id to acknowledge against.
341                tracing::warn!(%peer, handled, "MLLP framing error, closing connection: {e}");
342                break;
343            }
344        };
345
346        let message = String::from_utf8_lossy(&frame).to_string();
347        let header = InboundHeader::scan(&message);
348        let control_id = header.control_id.clone();
349
350        let outcome = tokio::time::timeout(
351            config.read_timeout,
352            handle_message(&message, fhir_client.as_ref(), config.as_ref()),
353        )
354        .await
355        .unwrap_or_else(|_| {
356            MessageOutcome::errored("Timed out converting or storing the message; please retry")
357        });
358
359        match outcome.code {
360            AckCode::ApplicationAccept => {
361                tracing::info!(%peer, control_id = %control_id, "Accepted HL7v2 message");
362            }
363            AckCode::ApplicationReject => {
364                tracing::warn!(
365                    %peer, control_id = %control_id,
366                    detail = outcome.detail.as_deref().unwrap_or(""),
367                    "Rejected HL7v2 message"
368                );
369            }
370            AckCode::ApplicationError => {
371                tracing::error!(
372                    %peer, control_id = %control_id,
373                    detail = outcome.detail.as_deref().unwrap_or(""),
374                    "Failed to store HL7v2 message"
375                );
376            }
377        }
378
379        let ack = build_ack(
380            &header,
381            outcome.code,
382            outcome.detail.as_deref(),
383            &timestamp(),
384        );
385
386        if let Err(e) = stream.write_all(&encode_frame(ack.as_bytes())).await {
387            // The message may well have been stored; the sender will retry and
388            // is expected to deduplicate on the control id.
389            tracing::warn!(%peer, control_id = %control_id, "Failed to send ACK: {e}");
390            break;
391        }
392        if let Err(e) = stream.flush().await {
393            tracing::warn!(%peer, control_id = %control_id, "Failed to flush ACK: {e}");
394            break;
395        }
396
397        handled += 1;
398    }
399}
400
401/// Converts one message and submits it.
402///
403/// Never returns an error: the caller owes the sender an acknowledgement in
404/// every case, so a failure is an [`AckCode`] rather than a `?`.
405async fn handle_message<Client>(
406    message: &str,
407    fhir_client: &Client,
408    config: &ReceiverConfig,
409) -> MessageOutcome
410where
411    Client: FHIRClient<(), OperationOutcomeError> + Send + Sync,
412{
413    if message.trim().is_empty() {
414        return MessageOutcome::rejected("Empty message");
415    }
416
417    // A message that does not parse will not parse on retry either, so this is
418    // a rejection rather than an error.
419    let hl7v2 = match haste_fhir_converter::convert_input(Input::HL7V2(message.to_string())) {
420        Ok(value) => value,
421        Err(e) => return MessageOutcome::rejected(format!("Could not parse the message: {e}")),
422    };
423
424    // Rebuilt per message rather than shared: minijinja's `Template` borrows
425    // its `Environment`, which cannot be held across an await point in a
426    // `'static` task. Startup already proved the template compiles.
427    let environment = haste_fhir_converter::create_environment(Some(&config.template_dir));
428    let Ok(template) = environment.get_template(&config.main_template) else {
429        return MessageOutcome::errored(format!(
430            "Conversion template '{}' is no longer available",
431            config.main_template
432        ));
433    };
434
435    let mut ctx = HashMap::new();
436    ctx.insert("hl7v2", hl7v2);
437
438    let output = match haste_fhir_converter::transform(
439        &template,
440        ctx,
441        &haste_fhir_converter::OutputFormat::FHIR,
442    ) {
443        Ok(output) => output,
444        Err(e) => {
445            // The template ran against this message and produced something that
446            // is not a FHIR resource. That is a property of the message (a
447            // missing field the template requires), so the sender is told.
448            return MessageOutcome::rejected(format!("Could not convert the message to FHIR: {e}"));
449        }
450    };
451
452    let haste_fhir_converter::Output::FHIR(resource) = output else {
453        return MessageOutcome::errored("The conversion template did not produce a FHIR resource");
454    };
455
456    submit(*resource, fhir_client).await
457}
458
459/// Writes the converted resource to the server.
460async fn submit<Client>(resource: Resource, fhir_client: &Client) -> MessageOutcome
461where
462    Client: FHIRClient<(), OperationOutcomeError> + Send + Sync,
463{
464    // A storage failure is `AE`, not `AR`: the message was fine and the sender
465    // should send it again.
466    let stored = match resource {
467        Resource::Bundle(bundle) => match &bundle.type_ {
468            t if t == &BundleType::transaction() => fhir_client.transaction((), bundle).await,
469            t if t == &BundleType::batch() => fhir_client.batch((), bundle).await,
470            other => {
471                return MessageOutcome::rejected(format!(
472                    "A converted Bundle must be a transaction or a batch, got {:?}",
473                    other.as_str().unwrap_or("(none)")
474                ));
475            }
476        },
477        resource => {
478            let resource_type = resource.resource_type();
479            fhir_client
480                .create((), resource_type, resource)
481                .await
482                .map(|_| Default::default())
483        }
484    };
485
486    match stored {
487        Ok(_) => MessageOutcome::accepted(),
488        Err(e) => MessageOutcome::errored(format!("Could not store the message: {e}")),
489    }
490}
491
492/// `MSH-7` format: `YYYYMMDDHHMMSS`.
493fn timestamp() -> String {
494    chrono::Utc::now().format("%Y%m%d%H%M%S").to_string()
495}
496
497/// Sends one message and waits for its acknowledgement.
498async fn send_message(
499    target: &str,
500    file: Option<&str>,
501    timeout: Duration,
502) -> Result<(), OperationOutcomeError> {
503    let message = match file {
504        Some(path) => tokio::fs::read_to_string(path).await.map_err(|e| {
505            OperationOutcomeError::fatal(
506                IssueType::not_found(),
507                format!("Could not read '{path}': {e}"),
508            )
509        })?,
510        None => {
511            use std::io::Read as _;
512            let mut buf = String::new();
513            std::io::stdin().read_to_string(&mut buf).map_err(|e| {
514                OperationOutcomeError::fatal(
515                    IssueType::exception(),
516                    format!("Could not read the message from stdin: {e}"),
517                )
518            })?;
519            buf
520        }
521    };
522
523    // HL7 v2 separates segments with a carriage return. A file edited on any
524    // platform tends to carry newlines, and a receiver splitting on `\r` alone
525    // would see one enormous segment.
526    let message = normalize_segment_separators(&message);
527
528    let mut stream = tokio::time::timeout(timeout, TcpStream::connect(target))
529        .await
530        .map_err(|_| {
531            OperationOutcomeError::fatal(
532                IssueType::timeout(),
533                format!("Timed out connecting to {target}"),
534            )
535        })?
536        .map_err(|e| {
537            OperationOutcomeError::fatal(
538                IssueType::exception(),
539                format!("Could not connect to {target}: {e}"),
540            )
541        })?;
542
543    let _ = stream.set_nodelay(true);
544
545    stream
546        .write_all(&encode_frame(message.as_bytes()))
547        .await
548        .map_err(|e| {
549            OperationOutcomeError::fatal(
550                IssueType::exception(),
551                format!("Could not send the message: {e}"),
552            )
553        })?;
554    stream.flush().await.map_err(|e| {
555        OperationOutcomeError::fatal(
556            IssueType::exception(),
557            format!("Could not flush the message: {e}"),
558        )
559    })?;
560
561    let frame = tokio::time::timeout(timeout, read_frame(&mut stream))
562        .await
563        .map_err(|_| {
564            OperationOutcomeError::fatal(
565                IssueType::timeout(),
566                format!("No acknowledgement from {target} within {timeout:?}"),
567            )
568        })??;
569
570    let FrameEnd::Frame(payload) = frame else {
571        return Err(OperationOutcomeError::fatal(
572            IssueType::incomplete(),
573            "The receiver closed the connection without acknowledging".to_string(),
574        ));
575    };
576
577    let ack = String::from_utf8_lossy(&payload).to_string();
578    println!("{ack}");
579
580    // The exit status reflects MSA-1, so a script can tell acceptance from
581    // rejection without parsing the reply.
582    if ack_was_accepted(&ack) {
583        Ok(())
584    } else {
585        Err(OperationOutcomeError::error(
586            IssueType::processing(),
587            format!("The receiver did not accept the message: {}", ack.trim()),
588        ))
589    }
590}
591
592/// Rewrites bare newlines to carriage returns, leaving `\r\n` intact.
593fn normalize_segment_separators(message: &str) -> String {
594    message.replace("\r\n", "\r").replace('\n', "\r")
595}
596
597/// Whether `MSA-1` is `AA` (or `CA`, its commit-level equivalent).
598fn ack_was_accepted(ack: &str) -> bool {
599    ack.split(['\r', '\n'])
600        .find(|segment| segment.starts_with("MSA"))
601        .and_then(|msa| msa.split('|').nth(1))
602        .is_some_and(|code| code == "AA" || code == "CA")
603}