Skip to content

Commit cbdf260

Browse files
committed
Add reject as inbound decision variant
1 parent f356917 commit cbdf260

4 files changed

Lines changed: 261 additions & 6 deletions

File tree

crates/hotfix/src/application.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,26 @@ pub trait Application: Send + Sync + 'static {
2020
async fn on_logon(&mut self);
2121
}
2222

23+
/// Standard FIX Business Reject Reason values (tag 380).
24+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25+
#[repr(u32)]
26+
pub enum BusinessRejectReason {
27+
Other = 0,
28+
UnknownId = 1,
29+
UnknownSecurity = 2,
30+
UnsupportedMessageType = 3,
31+
ApplicationNotAvailable = 4,
32+
ConditionallyRequiredFieldMissing = 5,
33+
NotAuthorized = 6,
34+
DeliverToFirmNotAvailable = 7,
35+
}
36+
2337
pub enum InboundDecision {
2438
Accept,
39+
Reject {
40+
reason: BusinessRejectReason,
41+
text: Option<String>,
42+
},
2543
TerminateSession,
2644
}
2745

crates/hotfix/src/message.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub(crate) use hotfix_message::message::{Config, Message};
55
use hotfix_message::session_fields::{MSG_SEQ_NUM, SENDER_COMP_ID, SENDING_TIME, TARGET_COMP_ID};
66
pub use hotfix_message::{Part, RepeatingGroup};
77

8+
pub mod business_reject;
89
pub mod heartbeat;
910
pub mod logon;
1011
pub mod logout;
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
use crate::application::BusinessRejectReason;
2+
use crate::message::OutboundMessage;
3+
use hotfix_message::dict::{FieldLocation, FixDatatype};
4+
use hotfix_message::message::Message;
5+
use hotfix_message::session_fields::{REF_MSG_TYPE, REF_SEQ_NUM, TEXT};
6+
use hotfix_message::{Buffer, FieldType, HardCodedFixFieldDefinition, Part};
7+
8+
#[allow(dead_code)]
9+
const BUSINESS_REJECT_REF_ID: &HardCodedFixFieldDefinition = &HardCodedFixFieldDefinition {
10+
name: "BusinessRejectRefID",
11+
tag: 379,
12+
data_type: FixDatatype::String,
13+
location: FieldLocation::Body,
14+
};
15+
16+
const BUSINESS_REJECT_REASON: &HardCodedFixFieldDefinition = &HardCodedFixFieldDefinition {
17+
name: "BusinessRejectReason",
18+
tag: 380,
19+
data_type: FixDatatype::Int,
20+
location: FieldLocation::Body,
21+
};
22+
23+
impl<'a> FieldType<'a> for BusinessRejectReason {
24+
type Error = ();
25+
type SerializeSettings = ();
26+
27+
fn serialize_with<B>(&self, buffer: &mut B, _settings: Self::SerializeSettings) -> usize
28+
where
29+
B: Buffer,
30+
{
31+
let value = *self as u32;
32+
value.serialize(buffer)
33+
}
34+
35+
fn deserialize(data: &'a [u8]) -> Result<Self, Self::Error> {
36+
let value = u32::deserialize(data).map_err(|_| ())?;
37+
match value {
38+
0 => Ok(Self::Other),
39+
1 => Ok(Self::UnknownId),
40+
2 => Ok(Self::UnknownSecurity),
41+
3 => Ok(Self::UnsupportedMessageType),
42+
4 => Ok(Self::ApplicationNotAvailable),
43+
5 => Ok(Self::ConditionallyRequiredFieldMissing),
44+
6 => Ok(Self::NotAuthorized),
45+
7 => Ok(Self::DeliverToFirmNotAvailable),
46+
_ => Err(()),
47+
}
48+
}
49+
}
50+
51+
#[derive(Clone, Debug)]
52+
pub(crate) struct BusinessReject {
53+
ref_msg_type: String,
54+
reason: BusinessRejectReason,
55+
ref_seq_num: Option<u64>,
56+
text: Option<String>,
57+
}
58+
59+
impl BusinessReject {
60+
pub(crate) fn new(ref_msg_type: &str, reason: BusinessRejectReason) -> Self {
61+
Self {
62+
ref_msg_type: ref_msg_type.to_string(),
63+
reason,
64+
ref_seq_num: None,
65+
text: None,
66+
}
67+
}
68+
69+
pub(crate) fn ref_seq_num(mut self, ref_seq_num: u64) -> Self {
70+
self.ref_seq_num = Some(ref_seq_num);
71+
self
72+
}
73+
74+
pub(crate) fn text(mut self, text: &str) -> Self {
75+
self.text = Some(text.to_string());
76+
self
77+
}
78+
79+
#[cfg(test)]
80+
fn parse(message: &Message) -> Self {
81+
Self {
82+
#[allow(clippy::expect_used)]
83+
ref_msg_type: message
84+
.get::<&str>(REF_MSG_TYPE)
85+
.expect("ref_msg_type should be present")
86+
.to_string(),
87+
#[allow(clippy::expect_used)]
88+
reason: message
89+
.get(BUSINESS_REJECT_REASON)
90+
.expect("reason should be present"),
91+
ref_seq_num: message.get(REF_SEQ_NUM).ok(),
92+
text: message.get::<&str>(TEXT).ok().map(|s| s.to_string()),
93+
}
94+
}
95+
}
96+
97+
impl OutboundMessage for BusinessReject {
98+
fn write(&self, msg: &mut Message) {
99+
msg.set(REF_MSG_TYPE, self.ref_msg_type.as_str());
100+
msg.set(BUSINESS_REJECT_REASON, self.reason);
101+
102+
if let Some(ref_seq_num) = self.ref_seq_num {
103+
msg.set(REF_SEQ_NUM, ref_seq_num);
104+
}
105+
if let Some(text) = &self.text {
106+
msg.set(TEXT, text.as_str());
107+
}
108+
}
109+
110+
fn message_type(&self) -> &str {
111+
"j"
112+
}
113+
}
114+
115+
#[cfg(test)]
116+
mod tests {
117+
use super::*;
118+
use hotfix_message::message::Message;
119+
120+
#[test]
121+
fn test_write_business_reject_with_required_fields_only() {
122+
let reject = BusinessReject::new("D", BusinessRejectReason::UnsupportedMessageType);
123+
124+
let mut msg = Message::new("FIX.4.4", "j");
125+
reject.write(&mut msg);
126+
127+
assert_eq!(msg.get::<&str>(REF_MSG_TYPE).unwrap(), "D");
128+
assert_eq!(
129+
msg.get::<BusinessRejectReason>(BUSINESS_REJECT_REASON)
130+
.unwrap(),
131+
BusinessRejectReason::UnsupportedMessageType
132+
);
133+
assert!(msg.get::<u64>(REF_SEQ_NUM).is_err());
134+
assert!(msg.get::<&str>(TEXT).is_err());
135+
}
136+
137+
#[test]
138+
fn test_write_business_reject_with_all_fields() {
139+
let reject = BusinessReject::new("8", BusinessRejectReason::NotAuthorized)
140+
.ref_seq_num(456)
141+
.text("Not authorized for execution reports");
142+
143+
let mut msg = Message::new("FIX.4.4", "j");
144+
reject.write(&mut msg);
145+
146+
assert_eq!(msg.get::<&str>(REF_MSG_TYPE).unwrap(), "8");
147+
assert_eq!(
148+
msg.get::<BusinessRejectReason>(BUSINESS_REJECT_REASON)
149+
.unwrap(),
150+
BusinessRejectReason::NotAuthorized
151+
);
152+
assert_eq!(msg.get::<u64>(REF_SEQ_NUM).unwrap(), 456);
153+
assert_eq!(
154+
msg.get::<&str>(TEXT).unwrap(),
155+
"Not authorized for execution reports"
156+
);
157+
}
158+
159+
#[test]
160+
fn test_round_trip_serialization() {
161+
let original =
162+
BusinessReject::new("D", BusinessRejectReason::ConditionallyRequiredFieldMissing)
163+
.ref_seq_num(789)
164+
.text("ClOrdID is required");
165+
166+
let mut msg = Message::new("FIX.4.4", "j");
167+
original.write(&mut msg);
168+
169+
let parsed = BusinessReject::parse(&msg);
170+
171+
assert_eq!(parsed.ref_msg_type, original.ref_msg_type);
172+
assert_eq!(parsed.reason, original.reason);
173+
assert_eq!(parsed.ref_seq_num, original.ref_seq_num);
174+
assert_eq!(parsed.text, original.text);
175+
}
176+
177+
#[test]
178+
fn test_round_trip_with_minimal_fields() {
179+
let original = BusinessReject::new("0", BusinessRejectReason::Other);
180+
181+
let mut msg = Message::new("FIX.4.4", "j");
182+
original.write(&mut msg);
183+
184+
let parsed = BusinessReject::parse(&msg);
185+
186+
assert_eq!(parsed.ref_msg_type, original.ref_msg_type);
187+
assert_eq!(parsed.reason, original.reason);
188+
assert_eq!(parsed.ref_seq_num, original.ref_seq_num);
189+
assert_eq!(parsed.text, original.text);
190+
}
191+
192+
#[test]
193+
fn test_message_type() {
194+
let reject = BusinessReject::new("D", BusinessRejectReason::Other);
195+
assert_eq!(reject.message_type(), "j");
196+
}
197+
198+
#[test]
199+
fn test_all_reject_reasons_round_trip() {
200+
let reasons = [
201+
BusinessRejectReason::Other,
202+
BusinessRejectReason::UnknownId,
203+
BusinessRejectReason::UnknownSecurity,
204+
BusinessRejectReason::UnsupportedMessageType,
205+
BusinessRejectReason::ApplicationNotAvailable,
206+
BusinessRejectReason::ConditionallyRequiredFieldMissing,
207+
BusinessRejectReason::NotAuthorized,
208+
BusinessRejectReason::DeliverToFirmNotAvailable,
209+
];
210+
211+
for reason in reasons {
212+
let reject = BusinessReject::new("D", reason);
213+
let mut msg = Message::new("FIX.4.4", "j");
214+
reject.write(&mut msg);
215+
216+
let parsed = BusinessReject::parse(&msg);
217+
assert_eq!(parsed.reason, reason, "Round-trip failed for {reason:?}");
218+
}
219+
}
220+
}

crates/hotfix/src/session.rs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use crate::message::heartbeat::Heartbeat;
2525
use crate::message::logon::{Logon, ResetSeqNumConfig};
2626
use crate::message::logout::Logout;
2727
use crate::message::parser::RawFixMessage;
28+
use crate::message::business_reject::BusinessReject;
2829
use crate::message::reject::Reject;
2930
use crate::message::resend_request::ResendRequest;
3031
use crate::message::sequence_reset::SequenceReset;
@@ -241,12 +242,27 @@ where
241242
) -> Result<(), SessionOperationError> {
242243
match self.verify_message(message, true) {
243244
Ok(_) => {
244-
if matches!(
245-
self.application.on_inbound_message(message).await,
246-
InboundDecision::TerminateSession
247-
) {
248-
error!("failed to send inbound message to application");
249-
self.state.disconnect_writer().await;
245+
match self.application.on_inbound_message(message).await {
246+
InboundDecision::Accept => {}
247+
InboundDecision::Reject { reason, text } => {
248+
let msg_type: &str = message
249+
.header()
250+
.get(MSG_TYPE)
251+
.map_err(|_| SessionOperationError::MissingField("MSG_TYPE"))?;
252+
let mut reject =
253+
BusinessReject::new(msg_type, reason)
254+
.ref_seq_num(get_msg_seq_num(message));
255+
if let Some(text) = text {
256+
reject = reject.text(&text);
257+
}
258+
self.send_message(reject)
259+
.await
260+
.with_send_context("business message reject")?;
261+
}
262+
InboundDecision::TerminateSession => {
263+
error!("failed to send inbound message to application");
264+
self.state.disconnect_writer().await;
265+
}
250266
}
251267
self.store.increment_target_seq_number().await?;
252268
}

0 commit comments

Comments
 (0)