quickfix_tokio/application.rs
1//! The application callback interface — the async equivalent of the seven
2//! classic QuickFIX callbacks.
3
4use async_trait::async_trait;
5use tokio::sync::mpsc;
6
7use crate::error::RejectError;
8use crate::message::Message;
9use crate::session_id::SessionId;
10
11/// Returned by `from_admin` / `from_app` to influence session behavior.
12#[derive(Debug)]
13pub enum ApplicationError {
14 /// Veto a counterparty Logon: the session logs out and disconnects.
15 RejectLogon(String),
16 /// Emit a session-level Reject (35=3) for this message.
17 Reject(RejectError),
18 /// Emit a BusinessMessageReject (35=j) with BusinessRejectReason(380)=3.
19 UnsupportedMessageType,
20}
21
22/// Returned by `to_app` to veto an outgoing (or resent) message.
23#[derive(Debug)]
24pub struct DoNotSend;
25
26/// Implemented by users of the engine. All callbacks run on the session's
27/// task: a slow callback delays that session (only), exactly like the
28/// single-threaded session model of the reference engines.
29///
30/// Because callbacks run on the session task, do not `await` a
31/// [`crate::SessionHandle`] operation for the *same* session inside a
32/// callback — forward work to another task instead (see the executor
33/// example).
34#[async_trait]
35#[allow(clippy::wrong_self_convention)] // from_admin/from_app are the canonical QuickFIX names
36pub trait Application: Send + Sync + 'static {
37 /// A session was created at engine start.
38 async fn on_create(&self, _session_id: &SessionId) {}
39
40 /// The session completed a logon exchange.
41 async fn on_logon(&self, _session_id: &SessionId) {}
42
43 /// The session went offline (logout or disconnect).
44 async fn on_logout(&self, _session_id: &SessionId) {}
45
46 /// An admin message is about to be sent; last chance to mutate it
47 /// (e.g. add credentials to a Logon).
48 async fn to_admin(&self, _msg: &mut Message, _session_id: &SessionId) {}
49
50 /// An application message is about to be sent (also called for resends,
51 /// with PossDupFlag=Y). Return `Err(DoNotSend)` to suppress it — a resend
52 /// is then gap-filled.
53 async fn to_app(
54 &self,
55 _msg: &mut Message,
56 _session_id: &SessionId,
57 ) -> Result<(), DoNotSend> {
58 Ok(())
59 }
60
61 /// An admin message was received and verified.
62 async fn from_admin(
63 &self,
64 _msg: &Message,
65 _session_id: &SessionId,
66 ) -> Result<(), ApplicationError> {
67 Ok(())
68 }
69
70 /// An application message was received and verified. This is the main
71 /// inbound entry point for business messages.
72 async fn from_app(
73 &self,
74 _msg: &Message,
75 _session_id: &SessionId,
76 ) -> Result<(), ApplicationError> {
77 Ok(())
78 }
79}
80
81// ----- channel adapter -----
82
83/// A protocol event surfaced by [`event_channel`]. Every variant carries the
84/// [`SessionId`] it happened on, so one receiver can serve many sessions —
85/// match on the id to demultiplex.
86#[derive(Debug)]
87pub enum SessionEvent {
88 /// A session was created at engine start ([`Application::on_create`]).
89 Created(SessionId),
90 /// A logon exchange completed ([`Application::on_logon`]).
91 LoggedOn(SessionId),
92 /// The session went offline ([`Application::on_logout`]).
93 LoggedOut(SessionId),
94 /// An application (business) message arrived ([`Application::from_app`]).
95 App(Message, SessionId),
96 /// An admin message arrived ([`Application::from_admin`]) — Heartbeat,
97 /// TestRequest, Reject, etc. Usually ignored; here for observability.
98 Admin(Message, SessionId),
99}
100
101/// The [`Application`] half of [`event_channel`]: it forwards the
102/// *notification* callbacks onto the channel and accepts everything else with
103/// the trait defaults. Construct it only via [`event_channel`].
104pub struct ChannelApplication {
105 tx: mpsc::UnboundedSender<SessionEvent>,
106}
107
108/// Bridge the callback interface to a tokio channel: returns an
109/// [`Application`] to hand to [`crate::Engine::start`], plus a receiver you
110/// drain from your own task or `select!` loop. This is the tokio-native
111/// alternative to implementing [`Application`] by hand — inbound events and
112/// your outbound [`crate::SessionHandle`] sends live in one place, with no
113/// callback reentrancy hazard.
114///
115/// The channel is **unbounded on purpose**: pushing an event never blocks the
116/// session task, so a slow consumer never stalls the protocol (heartbeats,
117/// gap-fills). The cost is that a consumer which stops draining entirely will
118/// grow memory — that's a consumer bug, since inbound rate is paced by one
119/// socket.
120///
121/// This adapter is **notify-only**. It cannot carry the *decision* hooks —
122/// [`Application::to_app`]/[`DoNotSend`], [`Application::from_admin`] ->
123/// [`ApplicationError::RejectLogon`], or [`Application::to_admin`] mutation —
124/// because those need a synchronous verdict the engine waits on, which a
125/// fire-and-forward channel has nowhere to return. Implement [`Application`]
126/// directly if you need to veto messages, reject logons, or stamp
127/// credentials.
128pub fn event_channel() -> (ChannelApplication, mpsc::UnboundedReceiver<SessionEvent>) {
129 let (tx, rx) = mpsc::unbounded_channel();
130 (ChannelApplication { tx }, rx)
131}
132
133#[async_trait]
134impl Application for ChannelApplication {
135 async fn on_create(&self, session_id: &SessionId) {
136 let _ = self.tx.send(SessionEvent::Created(session_id.clone()));
137 }
138 async fn on_logon(&self, session_id: &SessionId) {
139 let _ = self.tx.send(SessionEvent::LoggedOn(session_id.clone()));
140 }
141 async fn on_logout(&self, session_id: &SessionId) {
142 let _ = self.tx.send(SessionEvent::LoggedOut(session_id.clone()));
143 }
144 async fn from_admin(
145 &self,
146 msg: &Message,
147 session_id: &SessionId,
148 ) -> Result<(), ApplicationError> {
149 let _ = self.tx.send(SessionEvent::Admin(msg.clone(), session_id.clone()));
150 Ok(())
151 }
152 async fn from_app(
153 &self,
154 msg: &Message,
155 session_id: &SessionId,
156 ) -> Result<(), ApplicationError> {
157 let _ = self.tx.send(SessionEvent::App(msg.clone(), session_id.clone()));
158 Ok(())
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[tokio::test]
167 async fn event_channel_forwards_notifications() {
168 let (app, mut rx) = event_channel();
169 let sid = SessionId::new("FIX.4.4", "ME", "YOU");
170
171 app.on_logon(&sid).await;
172 let mut msg = Message::default();
173 msg.set(35, "D"); // NewOrderSingle
174 app.from_app(&msg, &sid).await.unwrap();
175 app.on_logout(&sid).await;
176
177 assert!(matches!(rx.recv().await, Some(SessionEvent::LoggedOn(id)) if id == sid));
178 assert!(matches!(rx.recv().await, Some(SessionEvent::App(_, id)) if id == sid));
179 assert!(matches!(rx.recv().await, Some(SessionEvent::LoggedOut(id)) if id == sid));
180 }
181
182 #[tokio::test]
183 async fn send_never_blocks_and_survives_dropped_receiver() {
184 let (app, rx) = event_channel();
185 let sid = SessionId::new("FIX.4.4", "ME", "YOU");
186 drop(rx); // consumer went away
187 // Forwarding must not panic or block even with no receiver.
188 app.on_logon(&sid).await;
189 app.from_app(&Message::default(), &sid).await.unwrap();
190 }
191}