diff --git a/Cargo.lock b/Cargo.lock index 78a6795..8d6d6de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,12 +49,61 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + [[package]] name = "similar" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "trafix" version = "0.1.1" @@ -65,12 +114,19 @@ version = "0.1.0" dependencies = [ "bytes", "insta", + "thiserror", ] [[package]] name = "trafix-engine" version = "0.1.0" +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index 718ebcc..1334d1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,4 @@ license = "Apache-2.0" [workspace.dependencies] bytes = "1.10.1" insta = "1.43.2" +thiserror = "2.0.17" diff --git a/trafix-codec/Cargo.toml b/trafix-codec/Cargo.toml index 698c92d..ebd49f7 100644 --- a/trafix-codec/Cargo.toml +++ b/trafix-codec/Cargo.toml @@ -10,3 +10,4 @@ license.workspace = true [dependencies] bytes.workspace = true insta.workspace = true +thiserror.workspace = true diff --git a/trafix-codec/src/constants/mod.rs b/trafix-codec/src/constants/mod.rs new file mode 100644 index 0000000..47755da --- /dev/null +++ b/trafix-codec/src/constants/mod.rs @@ -0,0 +1,5 @@ +/// ASCII SOH delimiter (0x01) used as field terminator in FIX messages. +pub(crate) const SOH: u8 = b'\x01'; + +/// ASCII equals character (=) used as delimiter between tag and value in a single field. +pub(crate) const EQUALS: u8 = b'='; diff --git a/trafix-codec/src/decoder/decode.rs b/trafix-codec/src/decoder/decode.rs new file mode 100644 index 0000000..e0f8f1d --- /dev/null +++ b/trafix-codec/src/decoder/decode.rs @@ -0,0 +1,335 @@ +//! Decoder for messages in FIX protocol. + +use crate::decoder::num::ParseFixInt as _; +use crate::digest::Digest; +use crate::message::field::Field; +use crate::message::field::value::FromFixBytes; +use crate::message::field::value::begin_string::BeginString; +use crate::message::field::value::msg_type::MsgType; +use crate::{constants, message::Message}; + +/// Length of the SOH character. +const SOH_LEN: usize = 1; + +/// Lengths of the equals ('=') character. +const EQ_LEN: usize = 1; + +/// Length of the tag for checksum ('10'). +const CKSUM_TAG_LEN: usize = 2; + +/// Extension trait for utility functions on [`Result`] type. +trait ResultExt { + /// Wraps the inner [`Result::Err`] with [`Error::BadValue`]. + fn or_bad_value(self) -> Result; +} + +impl ResultExt for Result +where + E: ToString, +{ + fn or_bad_value(self) -> Result { + self.map_err(|inner| Error::BadValue(inner.to_string())) + } +} + +/// Possible errors during decoding of [`Message`]s. +#[derive(Debug, Clone, thiserror::Error)] +pub enum Error { + /// Message did not contain mandatory field. + #[error("message is missing mandatory field '{}'", .0)] + MissingMandatoryField(&'static str), + + /// Message contained checksum before end. + #[error("checksum reached but message contains more fields")] + UnexpectedChecksum, + + /// Message checksum does not match with what we calculated. + #[error( + "calculated and expected checksums don't match 'calculated({calculated}) != ({expected})'" + )] + ChecksumMismatch { calculated: u8, expected: u8 }, + + /// Message contains invalid tag values. + #[error("invalid tag: {}", .0)] + BadTag(u16), + + /// Message body length does not match what was received. + #[error("expected body length {expected} but received {received} bytes")] + BodyLength { received: usize, expected: usize }, + + /// Message contains invalid bytes. + #[error("encountered error while parsing tokens: {}", .0)] + Lexer(#[from] LexError), + + /// Message contains invalid values. + #[error("Invalid value: {}", .0)] + BadValue(String), +} + +/// Errors that represent failures to decode symbols during lexing of FIX messages. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum LexError { + /// Found different byte than what was expected. + #[error("Expected '{expected}' but got {but_got}")] + Unexpected { expected: u8, but_got: u8 }, + + /// EOI reached but not expected. + #[error("Unexpected end of input")] + Eoi, + + /// Expected EOI but more input was found. + #[error("Expected end of input, but got {}", .0)] + ExpectedEOI(u8), + + /// Tag contains bytes that are not ASCII decimal digits. + #[error("Tag contains characters other than ascii 0-9 digits.")] + MalformedTag, +} + +/// Lexer reads the FIX message bytes and extracts tags and values from them. +struct Lexer<'input> { + /// Byte slice containing FIX Message. + input: &'input [u8], + + /// Current position in the input byte slice. + cursor: usize, +} + +impl<'input> Lexer<'input> { + /// Skip expected byte if more bytes available. + fn skip_or_eoi(&mut self, expected: u8) -> Result, LexError> { + match self.input.get(self.cursor) { + None => Ok(None), + Some(_) => self.skip(expected), + } + } + + /// Skip expected byte or error on EOI. + fn skip(&mut self, expected: u8) -> Result, LexError> { + match self.input.get(self.cursor) { + // got a byte that does not match with expected one + Some(byte) if *byte != expected => Err(LexError::Unexpected { + expected, + but_got: *byte, + }), + + // got a byte and it matches the expected one, so skip it + Some(byte) => { + self.cursor += 1; + Ok(Some(*byte)) + } + + // got EOI, but expected a byte + None => Err(LexError::Eoi), + } + } + + /// Tries to lex out the tag of field in FIX Message. + /// + /// # Errors + /// + /// Returns an error on invalid tag, or if some other token is encountered. + fn tag(&mut self) -> Result { + let start = self.cursor; + + while let Some(byte) = self.input.get(self.cursor) + && byte.is_ascii_digit() + { + self.cursor += 1; + } + + // INVARIANT: cursor is on equals sign + let end = self.cursor; + self.skip(constants::EQUALS)?; + + let tag_bytes = self.input.get(start..end).ok_or(LexError::Eoi)?; + + u16::parse_fix_int(tag_bytes).map_err(|_| LexError::MalformedTag) + } + + /// Tries to lex out the value of field in FIX Message. + /// + /// # Errors + /// + /// Returns an error on invalid value, or if some other token is encountered. + fn value(&mut self) -> Result<&'input [u8], LexError> { + // INVARIANT: Cursor position right after '=' character + let start = self.cursor; + + while let Some(byte) = self.input.get(self.cursor) + && *byte != constants::SOH + { + self.cursor += 1; + } + + // INVARIANT: We're either on SOH, or EOI + let end = self.cursor; + self.skip_or_eoi(constants::SOH)?; + + self.input.get(start..end).ok_or(LexError::Eoi) + } +} + +impl<'slice> From<&'slice [u8]> for Lexer<'slice> { + fn from(value: &'slice [u8]) -> Self { + Self { + input: value, + cursor: 0, + } + } +} + +/// Decodes a [`Message`] from a byte array-like object. The byte array must be trimmed (i.e. +/// no whitespace as prefix and/or sufix), and must contain exactly one message. Otherwise, +/// parsing will fail and return an error. +/// +/// For now, this decodes a message with fixed (no pun intended) expectations regarding protocol +/// version and message layout. That means that arbitrary protocol requirements cannot be expressed +/// in this decoder function. +/// +/// # Errors +/// +/// Returns an [`Error`] on malformed message formats. +pub fn decode(bytes: impl AsRef<[u8]>) -> Result { + let bytes = bytes.as_ref(); + let mut lexer = Lexer::from(bytes); + + let tag = lexer.tag()?; + let value = lexer.value()?; + + if tag != BeginString::tag() { + return Err(Error::BadTag(tag)); + } + + let begin_string = BeginString::from_fix_bytes(value).or_bad_value()?; + + let tag = lexer.tag()?; + let value = lexer.value()?; + + if tag != 9 { + return Err(Error::MissingMandatoryField("body length")); + } + + let body_length = usize::parse_fix_int(value).or_bad_value()?; + let body_start_cursor = lexer.cursor; + + let tag = lexer.tag()?; + + if tag != MsgType::tag() { + return Err(Error::MissingMandatoryField("message type")); + } + + let value = lexer.value()?; + let msg_type = MsgType::from_fix_bytes(value).or_bad_value()?; + + let builder = Message::builder(begin_string, msg_type); + + let mut builder = match (lexer.tag(), lexer.value()) { + (Ok(tag), Ok(value)) => builder.with_field(Field::try_new(tag, value).or_bad_value()?), + (Err(error), _) | (Ok(_), Err(error)) => return Err(Error::Lexer(error)), + }; + + while let Ok(tag) = lexer.tag() { + let value = lexer.value()?; + + if tag == 10 { + // checksum reached + if lexer.tag().is_ok() { + // there must be no fields after checksum! + return Err(Error::UnexpectedChecksum); + } + + let cursor_before_checksum = + lexer.cursor - SOH_LEN - value.len() - EQ_LEN - CKSUM_TAG_LEN; + + // at this point we can calculate the body length: + let received_body_length = cursor_before_checksum - body_start_cursor; + + if received_body_length != body_length { + return Err(Error::BodyLength { + received: received_body_length, + expected: body_length, + }); + } + + let calculated_checksum = { + let mut digest = Digest::default(); + // cursor is right after the value of checksum, so for checksum we calculate all + // bytes up to cursor - number of digits in value - 1 equals sign - 2 digits (10) + let bytes_up_to_checksum = &bytes[..cursor_before_checksum]; + digest.push(&bytes_up_to_checksum); + + digest.checksum() + }; + + let expected_checksum = u8::parse_fix_int(value).or_bad_value()?; + + if calculated_checksum != expected_checksum { + return Err(Error::ChecksumMismatch { + calculated: calculated_checksum, + expected: expected_checksum, + }); + } + } else { + builder = builder.with_field(Field::try_new(tag, value).or_bad_value()?); + } + } + + let message = builder.build(); + Ok(message) +} + +#[cfg(test)] +mod tests { + use crate::decoder::decode::Error; + use crate::message::Message; + + #[test] + fn parse_valid_message() { + let input = "8=FIX.4.4\x019=148\x0135=A\x0134=1080\x0149=TESTBUY1\x0152=20180920-18:14:19.508\x0156=TESTSELL1\x0111=636730640278898634\x0115=USD\x0121=2\x0138=7000\x0140=1\x0154=1\x0155=MSFT\x0160=20180920-18:14:19.492\x0110=089\x01"; + + let decode_result = Message::decode(input); + + assert!( + decode_result.is_ok(), + "message decoding failed: {}", + decode_result.unwrap_err() + ); + } + + #[test] + fn bad_checksum() { + let input = "8=FIX.4.4\x019=148\x0135=A\x0134=1080\x0149=TESTBUY1\x0152=20180920-18:14:19.508\x0156=TESTSELL1\x0111=636730640278898634\x0115=USD\x0121=2\x0138=7000\x0140=1\x0154=1\x0155=MSFT\x0160=20180920-18:14:19.492\x0110=000\x01"; + + let error = Message::decode(input).expect_err("checksum is not valid"); + + assert!(matches!(error, Error::ChecksumMismatch { .. })); + } + + #[test] + fn missing_msg_type() { + let input = "8=FIX.4.4\x019=148\x0134=1080\x0149=TESTBUY1\x0152=20180920-18:14:19.508\x0156=TESTSELL1\x0111=636730640278898634\x0115=USD\x0121=2\x0138=7000\x0140=1\x0154=1\x0155=MSFT\x0160=20180920-18:14:19.492\x0110=114\x01"; + + let error = Message::decode(input).expect_err("message type is missing"); + + assert!(matches!( + error, + Error::MissingMandatoryField("message type") + )); + } + + #[test] + fn bad_body_length() { + let input = "8=FIX.4.4\x019=042\x0135=A\x0134=1080\x0149=TESTBUY1\x0152=20180920-18:14:19.508\x0156=TESTSELL1\x0111=636730640278898634\x0115=USD\x0121=2\x0138=7000\x0140=1\x0154=1\x0155=MSFT\x0160=20180920-18:14:19.492\x0110=089\x01"; + + let error = Message::decode(input).expect_err("body length does not match"); + + assert!(matches!( + error, + Error::BodyLength { + expected: 42, + received: 148 + } + )); + } +} diff --git a/trafix-codec/src/decoder/mod.rs b/trafix-codec/src/decoder/mod.rs new file mode 100644 index 0000000..3b4d2a8 --- /dev/null +++ b/trafix-codec/src/decoder/mod.rs @@ -0,0 +1,4 @@ +mod decode; +pub mod num; + +pub use decode::*; diff --git a/trafix-codec/src/decoder/num.rs b/trafix-codec/src/decoder/num.rs new file mode 100644 index 0000000..5e0fcfe --- /dev/null +++ b/trafix-codec/src/decoder/num.rs @@ -0,0 +1,149 @@ +/// The error type returned on failed parsing of integers from byte slices. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub(crate) enum ParseIntError { + /// Byte slice contained bytes that are not ASCII decimal digits. + #[error("bytes contain values that are not decimal digits")] + InvalidDigit, + + /// Byte slice contained integer representation larger than what fits into the primitive type. + #[error("bytes contain number out of given number literal type's bounds")] + Overflow, + + /// Byte slice is empty, which is an invalid integer. + #[error("Unexpected empty input")] + Empty, +} + +/// Helper trait for parsing of integers from byte slices directly. Standard library exposes +/// parsing of integers for [`str`], but not for [`&[u8]`]. That is taken care of with this +/// extension trait. +pub(crate) trait ParseFixInt { + /// Parses integer from byte slice, or returns a [`ParseIntError`] if byte slice does not + /// contain valid integer. + fn parse_fix_int(bytes: T) -> Result + where + Self: Sized, + T: AsRef<[u8]>; +} + +/// Helper macro for implementation of parsing integers from byte slices intended for internal use +/// only. +macro_rules! impl_for { + ($type:ty, $is_signed:literal) => { + impl ParseFixInt for $type { + fn parse_fix_int(bytes: T) -> Result<$type, ParseIntError> + where + Self: Sized, + T: AsRef<[u8]>, + { + let mut bytes = bytes.as_ref(); + let mut value: $type = 0; + let is_negative = if bytes.starts_with(b"-") { + if $is_signed { + bytes = bytes.get(1..).ok_or(ParseIntError::Empty)?; + true + } else { + return Err(ParseIntError::Overflow); + } + } else { + false + }; + + for byte in bytes { + value = value.checked_mul(10).ok_or(ParseIntError::Overflow)?; + + if !byte.is_ascii_digit() { + return Err(ParseIntError::InvalidDigit); + } + + let to_add = (byte - b'0') + .try_into() + .expect("we checked for digits 0..=9"); + + value = if is_negative { + value.checked_sub(to_add).ok_or(ParseIntError::Overflow)? + } else { + value.checked_add(to_add).ok_or(ParseIntError::Overflow)? + }; + } + + Ok(value) + } + } + }; +} + +impl_for!(u8, false); +impl_for!(i8, true); +impl_for!(u16, false); +impl_for!(i16, true); +impl_for!(u32, false); +impl_for!(i32, true); +impl_for!(u64, false); +impl_for!(i64, true); +impl_for!(u128, false); +impl_for!(i128, true); +impl_for!(usize, false); +impl_for!(isize, true); + +#[cfg(test)] +mod tests { + use super::{ParseFixInt as _, ParseIntError}; + + #[test] + fn parse_u8() { + let value = u8::parse_fix_int(b"123"); + assert!(matches!(value, Ok(123))); + + let res = u8::parse_fix_int(b"001"); + assert!(matches!(res, Ok(1))); + + let res = u8::parse_fix_int(b"000"); + assert!(matches!(res, Ok(0))); + + let res = u8::parse_fix_int(b"256"); + assert!(matches!(res, Err(ParseIntError::Overflow))); + + let res = u8::parse_fix_int(b"1000"); + assert!(matches!(res, Err(ParseIntError::Overflow))); + + let res = u8::parse_fix_int(b"-100"); + assert!(matches!(res, Err(ParseIntError::Overflow))); + } + + #[test] + fn parse_i8() { + let value = i8::parse_fix_int(b"123"); + assert!(matches!(value, Ok(123))); + + let res = i8::parse_fix_int(b"001"); + assert!(matches!(res, Ok(1))); + + let res = i8::parse_fix_int(b"000"); + assert!(matches!(res, Ok(0))); + + let res = i8::parse_fix_int(b"128"); + assert!(matches!(res, Err(ParseIntError::Overflow))); + + let res = i8::parse_fix_int(b"-128"); + assert_eq!(res, Ok(-128)); + + let res = i8::parse_fix_int(b"-129"); + assert_eq!(res, Err(ParseIntError::Overflow)); + + let res = i8::parse_fix_int(b"1000"); + assert_eq!(res, Err(ParseIntError::Overflow)); + + let res = i8::parse_fix_int(b"-100"); + assert_eq!(res, Ok(-100)); + } + + #[test] + fn non_digits() { + let res = u8::parse_fix_int(b"abc"); + assert_eq!(res, Err(ParseIntError::InvalidDigit)); + + let res = i8::parse_fix_int(b"abc"); + assert_eq!(res, Err(ParseIntError::InvalidDigit)); + } +} diff --git a/trafix-codec/src/digest.rs b/trafix-codec/src/digest.rs new file mode 100644 index 0000000..d758394 --- /dev/null +++ b/trafix-codec/src/digest.rs @@ -0,0 +1,39 @@ +/// The [`Digest`] maintains a running checksum by performing modulo-256 addition over all +/// processed bytes, exactly as defined by the FIX checksum algorithm. This is typically used while +/// encoding and decoding FIX messages. +/// +/// # Example +/// +/// ```ignore +/// let mut digest = Digest::default(); +/// digest.push(&[1, 2, 3]); +/// let checksum = digest.checksum(); +/// +/// // (1 + 2 + 3) % 256 = 6 +/// assert_eq!(checksum, 6); +/// +/// // (1 + 2 + 3 + 251) % 256 = 257 % 256 = 1 +/// digest.push(251); +/// assert_eq!(checksum, 1); +/// ``` +#[derive(Default)] +pub(crate) struct Digest { + checksum: u8, +} + +impl Digest { + /// Updates the running checksum using the contents of a [`BytesMut`]. + /// + /// This performs modulo-256 addition across all bytes, matching the FIX + /// checksum algorithm. + pub fn push(&mut self, input: &impl AsRef<[u8]>) { + for &b in input.as_ref() { + self.checksum = self.checksum.wrapping_add(b); + } + } + + /// Returns the calculated checksum of bytes pushed so far. + pub fn checksum(&self) -> u8 { + self.checksum + } +} diff --git a/trafix-codec/src/encoder/mod.rs b/trafix-codec/src/encoder/mod.rs index ded72a9..fc233a7 100644 --- a/trafix-codec/src/encoder/mod.rs +++ b/trafix-codec/src/encoder/mod.rs @@ -2,28 +2,11 @@ use bytes::{BufMut, Bytes, BytesMut}; -use crate::message::{Body, Header, field::Field}; - -/// Computes the running FIX checksum (tag 10) while encoding. -#[derive(Default)] -struct Digest { - checksum: u8, -} - -impl Digest { - /// Updates the running checksum using the contents of a [`BytesMut`]. - /// - /// This performs modulo-256 addition across all bytes, matching the FIX - /// checksum algorithm. - pub fn push(&mut self, input: &BytesMut) { - for &b in input.as_ref() { - self.checksum = self.checksum.wrapping_add(b); - } - } -} - -/// ASCII SOH delimiter (0x01) used as field terminator in FIX messages. -const SOH: u8 = b'\x01'; +use crate::{ + constants, + digest::Digest, + message::{Body, Header, field::Field}, +}; /// Average bytes per field in a FIX Message. We can safely assume that the average number of bytes /// per field is around 15 bytes as per our measurements. @@ -58,13 +41,13 @@ fn encode_regular_fields(header: &Header, body: &Body) -> BytesMut { .encode() .as_ref(), ); - message.put_u8(SOH); + message.put_u8(constants::SOH); // Optional header fields for field in &header.fields { // field with included SOH char.. x=ab\x01 let mut field_soh = field.encode(); - field_soh.push(SOH); + field_soh.push(constants::SOH); // encode the field into the message message.extend_from_slice(field_soh.as_ref()); @@ -74,7 +57,7 @@ fn encode_regular_fields(header: &Header, body: &Body) -> BytesMut { for field in &body.fields { // field with included SOH char.. x=ab\x01 let mut field_soh = field.encode(); - field_soh.push(SOH); + field_soh.push(constants::SOH); // encode the field into the message message.extend_from_slice(field_soh.as_ref()); @@ -98,7 +81,7 @@ fn encode_framing_headers(header: &Header, regular_fields: &BytesMut) -> BytesMu .encode() .as_ref(), ); - message.put_u8(SOH); + message.put_u8(constants::SOH); // BodyLength with included SOH char message.extend_from_slice( @@ -109,7 +92,7 @@ fn encode_framing_headers(header: &Header, regular_fields: &BytesMut) -> BytesMu .encode() .as_ref(), ); - message.put_u8(SOH); + message.put_u8(constants::SOH); // append the all the regular fields message.extend_from_slice(regular_fields); @@ -126,10 +109,10 @@ fn finalize_message(mut message: BytesMut) -> Bytes { // Checksum with included SOH char let mut checksum_soh = Field::Custom { tag: 10, - value: format!("{}", digest.checksum).into_bytes(), + value: format!("{}", digest.checksum()).into_bytes(), } .encode(); - checksum_soh.push(SOH); + checksum_soh.push(constants::SOH); // encode the Checksum into the message message.put(checksum_soh.as_ref()); @@ -142,6 +125,7 @@ mod test { use bytes::Bytes; use crate::{ + constants, encoder::encode, message::{ Body, Header, @@ -155,7 +139,7 @@ mod test { /// Converts a bytes FIX frame to a `String`, making it human-readable by replacing the SOH /// character with '|'. fn humanize(encoded_message: &Bytes) -> String { - String::from_utf8_lossy(encoded_message).replace(super::SOH as char, "|") + String::from_utf8_lossy(encoded_message).replace(constants::SOH as char, "|") } #[test] diff --git a/trafix-codec/src/lib.rs b/trafix-codec/src/lib.rs index 85951ba..f5dc6cc 100644 --- a/trafix-codec/src/lib.rs +++ b/trafix-codec/src/lib.rs @@ -1,8 +1,13 @@ #![warn(clippy::pedantic)] +#![warn(missing_docs)] #![forbid(unsafe_code)] //! `trafix-codec` is a low-level library for high-performance parsing, //! encoding, and validation of FIX messages. +mod digest; + +pub(crate) mod constants; +pub(crate) mod decoder; pub mod encoder; pub mod message; diff --git a/trafix-codec/src/message/field/mod.rs b/trafix-codec/src/message/field/mod.rs index 88ea55b..6d8ab93 100644 --- a/trafix-codec/src/message/field/mod.rs +++ b/trafix-codec/src/message/field/mod.rs @@ -36,10 +36,34 @@ macro_rules! fields_macro { /// /// Useful for extension tags, firm-specific fields, or when /// working with non-standard message structures. - Custom { tag: u16, value: Vec } + Custom { + /// Tag of the custom field. + tag: u16, + /// Contents of the custom field. + value: Vec + } } impl Field { + /// Tries to construct a new [`Field`] from the given tag and value. + /// + /// # Errors + /// + /// This function might return error if invalid values are passed for the given tag. + pub fn try_new(tag: u16, bytes: &[u8]) -> Result> { + use value::FromFixBytes; + + match tag { + $( + $tag => Ok(Self::$variant(<$type as FromFixBytes>::from_fix_bytes(bytes)?)), + )* + other => Ok(Field::Custom { + tag: other, + value: bytes.into(), + }) + } + } + /// Returns the numeric FIX tag associated with this field. /// /// Example usage: @@ -48,6 +72,7 @@ macro_rules! fields_macro { /// let f = Field::MsgSeqNum(1); /// assert_eq!(f.tag(), 34); /// ``` + #[must_use] pub fn tag(&self) -> u16 { match self { $( @@ -63,6 +88,7 @@ macro_rules! fields_macro { /// For predefined fields, this returns their encoded textual /// representation (e.g. integer → ASCII). For custom fields, the /// original byte vector is cloned. + #[must_use] pub fn value(&self) -> Vec { match self { $( @@ -84,6 +110,7 @@ macro_rules! fields_macro { /// let f = Field::MsgSeqNum(4); /// assert_eq!(f.encode(), b"34=4".to_vec()); /// ``` + #[must_use] pub fn encode(&self) -> Vec { match self { $( diff --git a/trafix-codec/src/message/field/value/aliases.rs b/trafix-codec/src/message/field/value/aliases.rs index a398381..b30802b 100644 --- a/trafix-codec/src/message/field/value/aliases.rs +++ b/trafix-codec/src/message/field/value/aliases.rs @@ -3,6 +3,10 @@ //! These aliases provide clearer semantic meaning when working with //! strongly typed [`Field`](crate::message::field::Field) variants. +use std::convert::Infallible; + +use crate::message::field::value::FromFixBytes; + /// Represents the `MsgSeqNum` (`34`). /// /// This value increments with each message within a FIX session, @@ -28,3 +32,14 @@ pub type SendingTime = Vec; /// Identifies the intended recipient of the FIX message. /// Stored as raw bytes for full fidelity with on-wire data. pub type TargetCompID = Vec; + +impl FromFixBytes for Vec { + type Error<'unused> = Infallible; + + fn from_fix_bytes(bytes: &[u8]) -> Result> + where + Self: Sized, + { + Ok(bytes.into()) + } +} diff --git a/trafix-codec/src/message/field/value/begin_string.rs b/trafix-codec/src/message/field/value/begin_string.rs index ae34515..57715be 100644 --- a/trafix-codec/src/message/field/value/begin_string.rs +++ b/trafix-codec/src/message/field/value/begin_string.rs @@ -3,6 +3,8 @@ // TODO(kfejzic): Limit visibility to crate once standards are introduced. +use crate::message::field::value::FromFixBytes; + /// Represents the FIX protocol version (`8`) field value. /// /// This field value determines the message format and version-specific rules @@ -13,6 +15,14 @@ pub enum BeginString { FIX44, } +impl BeginString { + /// Returns the tag used for [`BeginString`]. + #[must_use] + pub const fn tag() -> u16 { + 8 + } +} + impl From for &'static [u8] { /// Converts a [`BeginString`] variant into its **static byte slice** /// representation. @@ -47,3 +57,26 @@ impl From for Vec { <&[u8]>::from(val).to_vec() } } + +/// The error type for failed parsing of [`MsgType`] +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum ParseError<'input> { + /// Provided byte slice contains data that is not a valid or supported FIX version. + #[error("unsupported fix version: {}", String::from_utf8_lossy(.0))] + Unsupported(&'input [u8]), +} + +impl FromFixBytes for BeginString { + type Error<'input> = ParseError<'input>; + + fn from_fix_bytes(bytes: &[u8]) -> Result> + where + Self: Sized, + { + if bytes == <&[u8]>::from(BeginString::FIX44) { + Ok(BeginString::FIX44) + } else { + Err(ParseError::Unsupported(bytes)) + } + } +} diff --git a/trafix-codec/src/message/field/value/mod.rs b/trafix-codec/src/message/field/value/mod.rs index c3ca557..0beb955 100644 --- a/trafix-codec/src/message/field/value/mod.rs +++ b/trafix-codec/src/message/field/value/mod.rs @@ -1,5 +1,34 @@ //! Implementation of the value module. +use crate::decoder::num::ParseFixInt; + pub mod aliases; pub mod begin_string; pub mod msg_type; + +/// Trait that abstracts conversion from bytes to values of FIX message fields. +// TODO(nfejzic): this trait might be obsolete if we decide to wrap used types (i.e. newtype +// pattern) and implement traits from std such as [`TryFrom`] instead. +pub(crate) trait FromFixBytes { + /// Error returned on failed conversion. + type Error<'lifetime>; + + /// Parses the input and returns an instance of self. + fn from_fix_bytes(bytes: &[u8]) -> Result> + where + Self: Sized; +} + +impl FromFixBytes for T +where + T: ParseFixInt, +{ + type Error<'unused> = crate::decoder::num::ParseIntError; + + fn from_fix_bytes(bytes: &[u8]) -> Result> + where + Self: Sized, + { + Self::parse_fix_int(bytes) + } +} diff --git a/trafix-codec/src/message/field/value/msg_type.rs b/trafix-codec/src/message/field/value/msg_type.rs index d48067e..5e3bb3b 100644 --- a/trafix-codec/src/message/field/value/msg_type.rs +++ b/trafix-codec/src/message/field/value/msg_type.rs @@ -1,5 +1,7 @@ //! Defines the [`MsgType`] enumeration representing the FIX **35 `MsgType`** field value. +use crate::message::field::value::FromFixBytes; + /// Represents the FIX message type (`35`) field value. /// /// Each variant corresponds to a well-known administrative message @@ -28,6 +30,14 @@ pub enum MsgType { Logout, } +impl MsgType { + /// Returns the tag used for [`MsgType`]. + #[must_use] + pub const fn tag() -> u16 { + 35 + } +} + impl From for &'static [u8] { /// Converts a [`MsgType`] variant into its **static byte slice** /// representation, corresponding to the FIX wire value of tag **35**. @@ -68,3 +78,31 @@ impl From for Vec { <&[u8]>::from(val).to_vec() } } + +/// The error type for failed parsing of [`MsgType`] +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum ParseError<'input> { + /// Provided byte slice contains data that is not a valid or supported message type. + #[error("unsupported message type: {}", String::from_utf8_lossy(.0))] + Unsupported(&'input [u8]), +} + +impl FromFixBytes for MsgType { + type Error<'input> = ParseError<'input>; + + fn from_fix_bytes(bytes: &[u8]) -> Result> + where + Self: Sized, + { + match bytes { + b"A" => Ok(MsgType::Logon), + b"0" => Ok(MsgType::Heartbeat), + b"1" => Ok(MsgType::TestRequest), + b"2" => Ok(MsgType::ResendRequest), + b"3" => Ok(MsgType::Reject), + b"4" => Ok(MsgType::SequenceReset), + b"5" => Ok(MsgType::Logout), + other => Err(ParseError::Unsupported(other)), + } + } +} diff --git a/trafix-codec/src/message/mod.rs b/trafix-codec/src/message/mod.rs index ab72977..06f1d0b 100644 --- a/trafix-codec/src/message/mod.rs +++ b/trafix-codec/src/message/mod.rs @@ -5,7 +5,7 @@ pub mod field; use bytes::Bytes; use crate::{ - encoder, + decoder, encoder, message::field::{ Field, value::{begin_string::BeginString, msg_type::MsgType}, @@ -17,6 +17,7 @@ use crate::{ /// The header always contains the protocol [`BeginString`] (tag 8) /// and the message type [`MsgType`] (tag 35), and may include /// additional session or routing fields. +#[derive(Debug)] pub struct Header { /// The `BeginString` identifying the FIX protocol version. #[allow(dead_code)] @@ -33,7 +34,7 @@ pub struct Header { /// Represents the body section of a FIX message. /// /// The body always contains the fields forming the message business content. -#[derive(Default)] +#[derive(Default, Debug)] pub struct Body { /// Collection of fields forming this message body. pub(crate) fields: Vec, @@ -43,6 +44,7 @@ pub struct Body { /// /// The header holds protocol and session metadata, while the body /// carries message-specific fields defined by the message type. +#[derive(Debug)] pub struct Message { /// The message header containing version, type, and optional routing fields. header: Header, @@ -89,6 +91,15 @@ impl Message { pub fn encode(self) -> Bytes { encoder::encode(&self.header, &self.body) } + + /// Decodes a [`Message`] from given bytes. See [`decode`] for more information. + /// + /// # Errors + /// + /// Returns [`Error`] on invalid input. + pub fn decode(input: impl AsRef<[u8]>) -> Result { + decoder::decode(input) + } } /// Generic builder for constructing [`Message`] instances.