Skip to content

Commit 579d0cf

Browse files
committed
Add validation for required fields inside groups
1 parent 27b4b41 commit 579d0cf

4 files changed

Lines changed: 141 additions & 8 deletions

File tree

crates/hotfix-message/src/builder.rs

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ impl MessageBuilder {
214214
field_def.tag(),
215215
ParentScope::Message(message_def),
216216
)?;
217+
Self::check_required_fields_in_groups(&groups, group_def)?;
217218
body.set_groups(groups).map_err(|err| {
218219
ParserError::Malformed(format!(
219220
"failed to set groups for tag {}: {err}",
@@ -386,6 +387,32 @@ impl MessageBuilder {
386387
}
387388
}
388389

390+
fn check_required_fields_in_groups(
391+
groups: &[RepeatingGroup],
392+
group_def: &GroupSpecification,
393+
) -> ParserResult<()> {
394+
let group_tag = group_def.number_of_entries_tag().get();
395+
for entry in groups {
396+
let field_map = entry.get_fields();
397+
398+
for field_def in group_def.fields() {
399+
if field_def.is_required && !field_map.fields.contains_key(&field_def.tag) {
400+
return Err(ParserError::RequiredFieldMissing {
401+
tag: field_def.tag.get(),
402+
group_tag: Some(group_tag),
403+
});
404+
}
405+
}
406+
407+
for (nested_tag, nested_spec) in &group_def.nested_groups {
408+
if let Some(nested_entries) = field_map.groups.get(nested_tag) {
409+
Self::check_required_fields_in_groups(nested_entries, nested_spec)?;
410+
}
411+
}
412+
}
413+
Ok(())
414+
}
415+
389416
fn get_dict_field_by_tag(&self, tag: u32) -> ParserResult<hotfix_dictionary::Field<'_>> {
390417
self.dict
391418
.field_by_tag(tag)
@@ -495,22 +522,19 @@ fn parser_error_to_parsed_message(err: ParserError, header: Header) -> ParsedMes
495522
reason: InvalidReason::InvalidMsgType(msg_type),
496523
message: Message::with_header(header),
497524
},
525+
ParserError::RequiredFieldMissing { tag, group_tag } => ParsedMessage::Invalid {
526+
reason: InvalidReason::RequiredFieldMissing { tag, group_tag },
527+
message: Message::with_header(header),
528+
},
498529
ParserError::Malformed(_) => ParsedMessage::Garbled(GarbledReason::Malformed),
499530
}
500531
}
501532

502533
struct FieldSpecification {
503534
pub(crate) tag: TagU32,
504-
#[allow(dead_code)]
505535
pub(crate) is_required: bool,
506536
}
507537

508-
/// The enclosing scope of a repeating-group parse.
509-
///
510-
/// When a tag is encountered that does not belong to the group currently being
511-
/// parsed, the parser uses the parent scope to decide whether the tag closes
512-
/// the group (because it is recognised by the enclosing scope) or whether it
513-
/// should be treated as part of the current group / rejected.
514538
#[derive(Clone, Copy)]
515539
enum ParentScope<'a> {
516540
Message(&'a MessageSpecification),
@@ -1144,4 +1168,80 @@ mod tests {
11441168
let party_b = message.get_group(fix44::NO_PARTY_I_DS, 1).unwrap();
11451169
assert_eq!(party_b.get::<&str>(fix44::PARTY_ID).unwrap(), "PARTYB");
11461170
}
1171+
1172+
#[test]
1173+
fn test_group_entry_missing_required_field_is_rejected() {
1174+
let dict = Dictionary::fix44();
1175+
let builder = MessageBuilder::new(dict, CONFIG).unwrap();
1176+
1177+
// Find any (msg_type, group, required-non-delim-field) combination so
1178+
// we can construct a minimal message that's structurally well-formed
1179+
// except for the missing group field.
1180+
let candidate = builder
1181+
.message_specification
1182+
.iter()
1183+
.find_map(|(msg_type, message_def)| {
1184+
message_def
1185+
.groups
1186+
.iter()
1187+
.find_map(|(group_tag, group_spec)| {
1188+
let required_non_delim = group_spec
1189+
.fields()
1190+
.iter()
1191+
.find(|f| f.is_required && f.tag != group_spec.delimiter_tag())?;
1192+
Some((
1193+
msg_type.clone(),
1194+
group_tag.get(),
1195+
group_spec.delimiter_tag().get(),
1196+
required_non_delim.tag.get(),
1197+
))
1198+
})
1199+
});
1200+
1201+
let Some((msg_type, group_tag, delimiter_tag, missing_tag)) = candidate else {
1202+
// The dictionary has no group with a required non-delimiter field.
1203+
return;
1204+
};
1205+
1206+
let body = format!(
1207+
"35={msg_type}|49=S|56=T|34=1|52=20231103-12:00:00|{group_tag}=1|{delimiter_tag}=X|"
1208+
);
1209+
let raw = build_pipe_separated_message(&body);
1210+
let parsed = builder.build(&raw);
1211+
1212+
match parsed {
1213+
ParsedMessage::Invalid {
1214+
reason:
1215+
InvalidReason::RequiredFieldMissing {
1216+
tag,
1217+
group_tag: Some(g),
1218+
},
1219+
..
1220+
} => {
1221+
assert_eq!(tag, missing_tag);
1222+
assert_eq!(g, group_tag);
1223+
}
1224+
other => panic!(
1225+
"expected RequiredFieldMissing(tag={missing_tag}, group_tag={group_tag}); \
1226+
msg_type={msg_type}, delimiter={delimiter_tag}, got: {}",
1227+
match &other {
1228+
ParsedMessage::Valid(_) => "Valid".to_string(),
1229+
ParsedMessage::Invalid { reason, .. } => match reason {
1230+
InvalidReason::InvalidField(t) => format!("InvalidField({t})"),
1231+
InvalidReason::InvalidGroup(t) => format!("InvalidGroup({t})"),
1232+
InvalidReason::InvalidOrderInGroup { tag, group_tag } => {
1233+
format!("InvalidOrderInGroup(tag={tag}, group_tag={group_tag})")
1234+
}
1235+
InvalidReason::InvalidComponent(s) => format!("InvalidComponent({s})"),
1236+
InvalidReason::InvalidMsgType(s) => format!("InvalidMsgType({s})"),
1237+
InvalidReason::RequiredFieldMissing { tag, group_tag } => {
1238+
format!("RequiredFieldMissing(tag={tag}, group_tag={group_tag:?})")
1239+
}
1240+
},
1241+
ParsedMessage::Garbled(_) => "Garbled".to_string(),
1242+
ParsedMessage::UnexpectedError(_) => "UnexpectedError".to_string(),
1243+
}
1244+
),
1245+
}
1246+
}
11471247
}

crates/hotfix-message/src/error.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ pub enum ParserError {
2727
InvalidComponent(String),
2828
#[error("MsgType {0} is not a valid message type")]
2929
InvalidMsgType(String),
30+
#[error(
31+
"required field (tag = {tag}) is missing{}",
32+
match group_tag { Some(g) => format!(" from repeating group (group tag = {g})"), None => String::new() }
33+
)]
34+
RequiredFieldMissing { tag: u32, group_tag: Option<u32> },
3035
#[error("malformed message: {0}")]
3136
Malformed(String),
3237
}

crates/hotfix-message/src/parsed_message.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,16 @@ impl ParsedMessage {
2424
pub enum InvalidReason {
2525
InvalidField(u32),
2626
InvalidGroup(u32),
27-
InvalidOrderInGroup { tag: u32, group_tag: u32 },
27+
InvalidOrderInGroup {
28+
tag: u32,
29+
group_tag: u32,
30+
},
2831
InvalidComponent(String),
2932
InvalidMsgType(String),
33+
RequiredFieldMissing {
34+
tag: u32,
35+
group_tag: Option<u32>,
36+
},
3037
}
3138

3239
#[derive(Debug)]

crates/hotfix/src/session.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,27 @@ where
185185
}
186186
}
187187
}
188+
InvalidReason::RequiredFieldMissing { tag, group_tag } => {
189+
match message.header().get(MSG_SEQ_NUM) {
190+
Ok(msg_seq_num) => {
191+
let text = match group_tag {
192+
Some(group_tag) => {
193+
format!("required tag missing: {tag} (in group {group_tag})")
194+
}
195+
None => format!("required tag missing: {tag}"),
196+
};
197+
let reject = Reject::new(msg_seq_num)
198+
.session_reject_reason(SessionRejectReason::RequiredTagMissing)
199+
.text(&text);
200+
self.send_message(reject)
201+
.await
202+
.with_send_context("reject for missing required field")?;
203+
}
204+
Err(err) => {
205+
error!("failed to get message seq num: {:?}", err);
206+
}
207+
}
208+
}
188209
},
189210
ParsedMessage::UnexpectedError(err) => {
190211
error!("unexpected error: {:?}", err);

0 commit comments

Comments
 (0)