diff --git a/Cargo.lock b/Cargo.lock index a18488f..f2b60cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "bytes" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" + [[package]] name = "cc" version = "1.0.83" @@ -54,15 +60,30 @@ version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +[[package]] +name = "memchr" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" + [[package]] name = "proc-macro2" -version = "1.0.70" +version = "1.0.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +checksum = "95fc56cda0b5c3325f5fbbd7ff9fda9e02bb00bb3dac51252d2f1bfa1cb8cc8c" dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", +] + [[package]] name = "quickfix" version = "0.1.0" @@ -81,20 +102,29 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "quickfix-spec-parser" +version = "0.1.0" +dependencies = [ + "bytes", + "quick-xml", + "thiserror", +] + [[package]] name = "quote" -version = "1.0.33" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] [[package]] name = "syn" -version = "2.0.40" +version = "2.0.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13fa70a4ee923979ffb522cacce59d34421ebdea5625e1073c4326ef9d2dd42e" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" dependencies = [ "proc-macro2", "quote", @@ -103,18 +133,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.50" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" +checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.50" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" +checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 6d9ea1d..a389e8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] resolver = "2" -members = ["quickfix-ffi", "quickfix"] +members = ["quickfix-ffi", "quickfix-spec-parser", "quickfix"] diff --git a/quickfix-spec-parser/Cargo.toml b/quickfix-spec-parser/Cargo.toml new file mode 100644 index 0000000..0d0fbcc --- /dev/null +++ b/quickfix-spec-parser/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "quickfix-spec-parser" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +bytes = "1.5.0" +quick-xml = "0.31.0" +thiserror = "1.0.56" diff --git a/quickfix-spec-parser/examples/spec_read_write.rs b/quickfix-spec-parser/examples/spec_read_write.rs new file mode 100644 index 0000000..3902a7e --- /dev/null +++ b/quickfix-spec-parser/examples/spec_read_write.rs @@ -0,0 +1,23 @@ +use std::fs; + +use quickfix_spec_parser::{parse_spec, write_spec}; + +fn main() { + // Parse XML spec. + let spec = parse_spec(include_bytes!( + "../../quickfix-ffi/libquickfix/spec/FIXT11.xml" + )) + .unwrap(); + + // Print it. + println!("spec: {spec:#?}"); + + // Rewrite it as XML and apply change to make it match with original spec format. + let out = write_spec(&spec).unwrap(); + let txt = String::from_utf8(out.to_vec()) + .unwrap() + .replace('\"', "'") + .replace("/>", " />"); + + fs::write("out.xml", txt).unwrap(); +} diff --git a/quickfix-spec-parser/src/error.rs b/quickfix-spec-parser/src/error.rs new file mode 100644 index 0000000..689dc45 --- /dev/null +++ b/quickfix-spec-parser/src/error.rs @@ -0,0 +1,36 @@ +use std::{num::ParseIntError, string::FromUtf8Error}; + +use thiserror::Error; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum FixSpecError { + #[error("invalid document: {0}")] + InvalidDocument(&'static str), + + #[error("invalid attribute: {0}")] + InvalidAttribute(String), + + #[error("invalid content: {0}")] + InvalidContent(String), + + #[error("xml error: {0}")] + Xml(String), +} + +impl From for FixSpecError { + fn from(err: FromUtf8Error) -> Self { + Self::InvalidContent(err.to_string()) + } +} + +impl From for FixSpecError { + fn from(err: ParseIntError) -> Self { + Self::InvalidContent(err.to_string()) + } +} + +impl From for FixSpecError { + fn from(err: quick_xml::Error) -> Self { + Self::Xml(err.to_string()) + } +} diff --git a/quickfix-spec-parser/src/lib.rs b/quickfix-spec-parser/src/lib.rs new file mode 100644 index 0000000..08a31a5 --- /dev/null +++ b/quickfix-spec-parser/src/lib.rs @@ -0,0 +1,59 @@ +use bytes::Bytes; +use quick_xml::{events::Event, Reader, Writer}; + +mod error; +mod model { + mod component; + mod component_spec; + mod field; + mod field_allowed_value; + mod field_spec; + mod field_type; + mod field_value; + mod group; + mod message; + mod message_category; + mod spec; + + pub use self::component::Component; + pub use self::component_spec::ComponentSpec; + pub use self::field::Field; + pub use self::field_allowed_value::FieldAllowedValue; + pub use self::field_spec::FieldSpec; + pub use self::field_type::FieldType; + pub use self::field_value::FieldValue; + pub use self::group::Group; + pub use self::message::Message; + pub use self::message_category::MessageCategory; + pub use self::spec::FixSpec; +} +mod xml_ext; + +pub use error::*; +pub use model::*; +pub use xml_ext::*; + +type XmlWriter = Writer>; +type XmlReader<'a> = Reader<&'a [u8]>; + +pub fn parse_spec(input: &[u8]) -> Result { + let mut reader = Reader::from_reader(input); + reader.trim_text(true); + + match reader.read_event()? { + // If we are at start of FIX spec. + Event::Start(e) if e.name().as_ref() == FixSpec::TAG_NAME.as_bytes() => { + FixSpec::parse_xml_tree(&e, &mut reader) + } + // Otherwise document is invalid + _ => Err(FixSpecError::InvalidDocument("invalid root")), + } +} + +pub fn write_spec(spec: &FixSpec) -> Result { + let mut writer = Writer::new_with_indent(Vec::new(), b' ', 1); + + spec.write_xml(&mut writer)?; + + Ok(Bytes::from(writer.into_inner())) +} diff --git a/quickfix-spec-parser/src/model/component.rs b/quickfix-spec-parser/src/model/component.rs new file mode 100644 index 0000000..9f2d871 --- /dev/null +++ b/quickfix-spec-parser/src/model/component.rs @@ -0,0 +1,31 @@ +use quick_xml::events::BytesStart; + +use crate::{read_attribute, FixSpecError, XmlObject, XmlReadable, XmlWritable, XmlWriter}; + +#[derive(Debug)] +pub struct Component { + pub name: String, + pub required: bool, +} + +impl XmlObject for Component { + const TAG_NAME: &'static str = "component"; +} + +impl XmlReadable for Component { + fn parse_xml_node(element: &BytesStart) -> Result { + let name = read_attribute(element, "name")?; + let required = read_attribute(element, "required")? == "Y"; + Ok(Self { name, required }) + } +} + +impl XmlWritable for Component { + fn write_xml<'a>(&self, writer: &'a mut XmlWriter) -> quick_xml::Result<&'a mut XmlWriter> { + writer + .create_element(Self::TAG_NAME) + .with_attribute(("name", self.name.as_str())) + .with_attribute(("required", if self.required { "Y" } else { "N" })) + .write_empty() + } +} diff --git a/quickfix-spec-parser/src/model/component_spec.rs b/quickfix-spec-parser/src/model/component_spec.rs new file mode 100644 index 0000000..5cefdc1 --- /dev/null +++ b/quickfix-spec-parser/src/model/component_spec.rs @@ -0,0 +1,46 @@ +use quick_xml::events::BytesStart; + +use crate::{ + read_attribute, write_xml_list, FieldValue, FixSpecError, XmlObject, XmlReadable, XmlReader, + XmlWritable, XmlWriter, +}; + +#[derive(Debug)] +pub struct ComponentSpec { + pub name: String, + pub values: Vec, +} + +impl XmlObject for ComponentSpec { + const TAG_NAME: &'static str = "component"; +} + +impl XmlReadable for ComponentSpec { + fn parse_xml_node(element: &BytesStart) -> Result { + let name = read_attribute(element, "name")?; + Ok(Self { + name, + values: Vec::new(), + }) + } + + fn parse_xml_tree(element: &BytesStart, reader: &mut XmlReader) -> Result { + let mut output = Self::parse_xml_node(element)?; + output.values = FieldValue::parse_xml_tree(reader, Self::TAG_NAME)?; + Ok(output) + } +} + +impl XmlWritable for ComponentSpec { + fn write_xml<'a>(&self, writer: &'a mut XmlWriter) -> quick_xml::Result<&'a mut XmlWriter> { + let element = writer + .create_element(Self::TAG_NAME) + .with_attribute(("name", self.name.as_str())); + + if self.values.is_empty() { + element.write_empty() + } else { + element.write_inner_content(|writer| write_xml_list(writer, &self.values)) + } + } +} diff --git a/quickfix-spec-parser/src/model/field.rs b/quickfix-spec-parser/src/model/field.rs new file mode 100644 index 0000000..e8b1dae --- /dev/null +++ b/quickfix-spec-parser/src/model/field.rs @@ -0,0 +1,31 @@ +use quick_xml::events::BytesStart; + +use crate::{read_attribute, FixSpecError, XmlObject, XmlReadable, XmlWritable, XmlWriter}; + +#[derive(Debug)] +pub struct Field { + pub name: String, + pub required: bool, +} + +impl XmlObject for Field { + const TAG_NAME: &'static str = "field"; +} + +impl XmlReadable for Field { + fn parse_xml_node(element: &BytesStart) -> Result { + let name = read_attribute(element, "name")?; + let required = read_attribute(element, "required")? == "Y"; + Ok(Self { name, required }) + } +} + +impl XmlWritable for Field { + fn write_xml<'a>(&self, writer: &'a mut XmlWriter) -> quick_xml::Result<&'a mut XmlWriter> { + writer + .create_element(Self::TAG_NAME) + .with_attribute(("name", self.name.as_str())) + .with_attribute(("required", if self.required { "Y" } else { "N" })) + .write_empty() + } +} diff --git a/quickfix-spec-parser/src/model/field_allowed_value.rs b/quickfix-spec-parser/src/model/field_allowed_value.rs new file mode 100644 index 0000000..0880f9c --- /dev/null +++ b/quickfix-spec-parser/src/model/field_allowed_value.rs @@ -0,0 +1,31 @@ +use quick_xml::events::BytesStart; + +use crate::{read_attribute, FixSpecError, XmlObject, XmlReadable, XmlWritable, XmlWriter}; + +#[derive(Debug)] +pub struct FieldAllowedValue { + pub value: String, + pub description: String, +} + +impl XmlObject for FieldAllowedValue { + const TAG_NAME: &'static str = "value"; +} + +impl XmlReadable for FieldAllowedValue { + fn parse_xml_node(element: &BytesStart) -> Result { + let value = read_attribute(element, "enum")?; + let description = read_attribute(element, "description")?; + Ok(Self { value, description }) + } +} + +impl XmlWritable for FieldAllowedValue { + fn write_xml<'a>(&self, writer: &'a mut XmlWriter) -> quick_xml::Result<&'a mut XmlWriter> { + writer + .create_element(Self::TAG_NAME) + .with_attribute(("enum", self.value.as_str())) + .with_attribute(("description", self.description.as_str())) + .write_empty() + } +} diff --git a/quickfix-spec-parser/src/model/field_spec.rs b/quickfix-spec-parser/src/model/field_spec.rs new file mode 100644 index 0000000..283386d --- /dev/null +++ b/quickfix-spec-parser/src/model/field_spec.rs @@ -0,0 +1,55 @@ +use quick_xml::events::BytesStart; + +use crate::{ + parse_xml_list, read_attribute, write_xml_list, FieldAllowedValue, FieldType, FixSpecError, + XmlObject, XmlReadable, XmlReader, XmlWritable, XmlWriter, +}; + +#[derive(Debug)] +pub struct FieldSpec { + pub number: u32, + pub name: String, + pub r#type: FieldType, + pub values: Vec, +} + +impl XmlObject for FieldSpec { + const TAG_NAME: &'static str = "field"; +} + +impl XmlReadable for FieldSpec { + fn parse_xml_node(element: &BytesStart) -> Result { + let number = read_attribute(element, "number")?.parse()?; + let name = read_attribute(element, "name")?; + let r#type = read_attribute(element, "type")?.parse()?; + + Ok(Self { + number, + name, + r#type, + values: Vec::new(), + }) + } + + fn parse_xml_tree(element: &BytesStart, reader: &mut XmlReader) -> Result { + let mut output = Self::parse_xml_node(element)?; + output.values = parse_xml_list(reader, Self::TAG_NAME)?; + Ok(output) + } +} + +impl XmlWritable for FieldSpec { + fn write_xml<'a>(&self, writer: &'a mut XmlWriter) -> quick_xml::Result<&'a mut XmlWriter> { + let element = writer + .create_element(Self::TAG_NAME) + .with_attribute(("number", self.number.to_string().as_str())) + .with_attribute(("name", self.name.as_str())) + .with_attribute(("type", self.r#type.as_static_str())); + + if self.values.is_empty() { + element.write_empty() + } else { + element.write_inner_content(|writer| write_xml_list(writer, &self.values)) + } + } +} diff --git a/quickfix-spec-parser/src/model/field_type.rs b/quickfix-spec-parser/src/model/field_type.rs new file mode 100644 index 0000000..fd3dee6 --- /dev/null +++ b/quickfix-spec-parser/src/model/field_type.rs @@ -0,0 +1,146 @@ +use std::str::FromStr; + +use crate::FixSpecError; + +#[derive(Debug)] +#[non_exhaustive] +pub enum FieldType { + // ⬆️ Add in FIX 4.0 + Char, + Int, + Float, + Time, + Date, + Length, + Data, + // ⬆️ Add in FIX 4.1 + MonthYear, + DayOfMonth, + // ⬆️ Add in FIX 4.2 + String, + Price, + Amount, + Quantity, + Currency, + MultipleValueString, + Exchange, + UtcTimeStamp, + Boolean, + LocalMarketDate, + PriceOffset, + UtcDate, + UtcTimeOnly, + // ⬆️ Add in FIX 4.3 + SequenceNumber, + NumberInGroup, + Percentage, + Country, + // ⬆️ Add in FIX 4.4 + UtcDateOnly, + // ⬆️ Add in FIX 5.0 + MultipleCharValue, + MultipleStringValue, + TzTimeOnly, + TzTimestamp, // How can a timestamp include a timezone 🤨 + // ⬆️ Add in FIX 5.0 SP1 + XmlData, + // ⬆️ Add in FIX 5.0 SP2 + Language, + TagNumber, + XidRef, + Xid, + LocalMarketTime, +} + +impl FieldType { + pub const fn as_static_str(&self) -> &'static str { + match self { + Self::Char => "CHAR", + Self::Int => "INT", + Self::Float => "FLOAT", + Self::Time => "TIME", + Self::Date => "DATE", + Self::Length => "LENGTH", + Self::Data => "DATA", + Self::MonthYear => "MONTHYEAR", + Self::DayOfMonth => "DAYOFMONTH", + Self::String => "STRING", + Self::Price => "PRICE", + Self::Amount => "AMT", + Self::Quantity => "QTY", + Self::Currency => "CURRENCY", + Self::MultipleValueString => "MULTIPLEVALUESTRING", + Self::Exchange => "EXCHANGE", + Self::UtcTimeStamp => "UTCTIMESTAMP", + Self::Boolean => "BOOLEAN", + Self::LocalMarketDate => "LOCALMKTDATE", + Self::PriceOffset => "PRICEOFFSET", + Self::UtcDate => "UTCDATE", + Self::UtcTimeOnly => "UTCTIMEONLY", + Self::SequenceNumber => "SEQNUM", + Self::NumberInGroup => "NUMINGROUP", + Self::Percentage => "PERCENTAGE", + Self::Country => "COUNTRY", + Self::UtcDateOnly => "UTCDATEONLY", + Self::MultipleCharValue => "MULTIPLECHARVALUE", + Self::MultipleStringValue => "MULTIPLESTRINGVALUE", + Self::TzTimeOnly => "TZTIMEONLY", + Self::TzTimestamp => "TZTIMESTAMP", + Self::XmlData => "XMLDATA", + Self::Language => "LANGUAGE", + Self::TagNumber => "TAGNUM", + Self::XidRef => "XIDREF", + Self::Xid => "XID", + Self::LocalMarketTime => "LOCALMKTTIME", + } + } +} + +impl FromStr for FieldType { + type Err = FixSpecError; + + fn from_str(input: &str) -> Result { + match input { + "CHAR" => Ok(Self::Char), + "INT" => Ok(Self::Int), + "FLOAT" => Ok(Self::Float), + "TIME" => Ok(Self::Time), + "DATE" => Ok(Self::Date), + "LENGTH" => Ok(Self::Length), + "DATA" => Ok(Self::Data), + "MONTHYEAR" => Ok(Self::MonthYear), + "DAYOFMONTH" => Ok(Self::DayOfMonth), + "STRING" => Ok(Self::String), + "PRICE" => Ok(Self::Price), + "AMT" => Ok(Self::Amount), + "QTY" => Ok(Self::Quantity), + "CURRENCY" => Ok(Self::Currency), + "MULTIPLEVALUESTRING" => Ok(Self::MultipleValueString), + "EXCHANGE" => Ok(Self::Exchange), + "UTCTIMESTAMP" => Ok(Self::UtcTimeStamp), + "BOOLEAN" => Ok(Self::Boolean), + "LOCALMKTDATE" => Ok(Self::LocalMarketDate), + "PRICEOFFSET" => Ok(Self::PriceOffset), + "UTCDATE" => Ok(Self::UtcDate), + "UTCTIMEONLY" => Ok(Self::UtcTimeOnly), + "SEQNUM" => Ok(Self::SequenceNumber), + "NUMINGROUP" => Ok(Self::NumberInGroup), + "PERCENTAGE" => Ok(Self::Percentage), + "COUNTRY" => Ok(Self::Country), + "UTCDATEONLY" => Ok(Self::UtcDateOnly), + "MULTIPLECHARVALUE" => Ok(Self::MultipleCharValue), + "MULTIPLESTRINGVALUE" => Ok(Self::MultipleStringValue), + "TZTIMEONLY" => Ok(Self::TzTimeOnly), + "TZTIMESTAMP" => Ok(Self::TzTimestamp), + "XMLDATA" => Ok(Self::XmlData), + "LANGUAGE" => Ok(Self::Language), + "TAGNUM" => Ok(Self::TagNumber), + "XIDREF" => Ok(Self::XidRef), + "XID" => Ok(Self::Xid), + "LOCALMKTTIME" => Ok(Self::LocalMarketTime), + x => Err(FixSpecError::InvalidContent(format!( + "unknown field type: {x}" + ))), + } + } +} diff --git a/quickfix-spec-parser/src/model/field_value.rs b/quickfix-spec-parser/src/model/field_value.rs new file mode 100644 index 0000000..bcfa317 --- /dev/null +++ b/quickfix-spec-parser/src/model/field_value.rs @@ -0,0 +1,58 @@ +use quick_xml::events::Event; + +use crate::{ + Component, Field, FixSpecError, Group, XmlObject, XmlReadable, XmlReader, XmlWritable, + XmlWriter, +}; + +#[derive(Debug)] +pub enum FieldValue { + Field(Field), + Group(Group), + Component(Component), +} + +impl FieldValue { + pub fn parse_xml_tree( + reader: &mut XmlReader, + end_tag: &str, + ) -> Result, FixSpecError> { + let mut values = Vec::new(); + + loop { + match reader.read_event()? { + Event::Empty(element) | Event::Start(element) + if element.name().as_ref() == Field::TAG_NAME.as_bytes() => + { + values.push(Self::Field(Field::parse_xml_tree(&element, reader)?)); + } + Event::Empty(element) | Event::Start(element) + if element.name().as_ref() == Group::TAG_NAME.as_bytes() => + { + values.push(Self::Group(Group::parse_xml_tree(&element, reader)?)); + } + Event::Empty(element) | Event::Start(element) + if element.name().as_ref() == Component::TAG_NAME.as_bytes() => + { + values.push(Self::Component(Component::parse_xml_tree( + &element, reader, + )?)); + } + Event::End(element) if element.name().as_ref() == end_tag.as_bytes() => { + return Ok(values); + } + _ => {} + } + } + } +} + +impl XmlWritable for FieldValue { + fn write_xml<'a>(&self, writer: &'a mut XmlWriter) -> quick_xml::Result<&'a mut XmlWriter> { + match self { + Self::Field(field) => field.write_xml(writer), + Self::Group(group) => group.write_xml(writer), + Self::Component(component) => component.write_xml(writer), + } + } +} diff --git a/quickfix-spec-parser/src/model/group.rs b/quickfix-spec-parser/src/model/group.rs new file mode 100644 index 0000000..7f2ff9a --- /dev/null +++ b/quickfix-spec-parser/src/model/group.rs @@ -0,0 +1,45 @@ +use quick_xml::events::BytesStart; + +use crate::{ + read_attribute, write_xml_list, FieldValue, FixSpecError, XmlObject, XmlReadable, XmlReader, + XmlWritable, XmlWriter, +}; + +#[derive(Debug)] +pub struct Group { + pub name: String, + pub required: bool, + pub values: Vec, +} + +impl XmlObject for Group { + const TAG_NAME: &'static str = "group"; +} + +impl XmlReadable for Group { + fn parse_xml_node(element: &BytesStart) -> Result { + let name = read_attribute(element, "name")?; + let required = read_attribute(element, "required")? == "Y"; + Ok(Self { + name, + required, + values: Vec::new(), + }) + } + + fn parse_xml_tree(element: &BytesStart, reader: &mut XmlReader) -> Result { + let mut output = Self::parse_xml_node(element)?; + output.values = FieldValue::parse_xml_tree(reader, Self::TAG_NAME)?; + Ok(output) + } +} + +impl XmlWritable for Group { + fn write_xml<'a>(&self, writer: &'a mut XmlWriter) -> quick_xml::Result<&'a mut XmlWriter> { + writer + .create_element(Self::TAG_NAME) + .with_attribute(("name", self.name.as_str())) + .with_attribute(("required", if self.required { "Y" } else { "N" })) + .write_inner_content(|writer| write_xml_list(writer, &self.values)) + } +} diff --git a/quickfix-spec-parser/src/model/message.rs b/quickfix-spec-parser/src/model/message.rs new file mode 100644 index 0000000..ca61b6d --- /dev/null +++ b/quickfix-spec-parser/src/model/message.rs @@ -0,0 +1,55 @@ +use quick_xml::events::BytesStart; + +use crate::{ + read_attribute, write_xml_list, FieldValue, FixSpecError, MessageCategory, XmlObject, + XmlReadable, XmlReader, XmlWritable, XmlWriter, +}; + +#[derive(Debug)] +pub struct Message { + pub name: String, + pub msg_type: String, + pub category: MessageCategory, + pub values: Vec, +} + +impl XmlObject for Message { + const TAG_NAME: &'static str = "message"; +} + +impl XmlReadable for Message { + fn parse_xml_node(element: &BytesStart) -> Result { + let name = read_attribute(element, "name")?; + let msg_type = read_attribute(element, "msgtype")?; + let category = read_attribute(element, "msgcat")?.parse()?; + + Ok(Self { + name, + msg_type, + category, + values: Vec::new(), + }) + } + + fn parse_xml_tree(element: &BytesStart, reader: &mut XmlReader) -> Result { + let mut output = Self::parse_xml_node(element)?; + output.values = FieldValue::parse_xml_tree(reader, Self::TAG_NAME)?; + Ok(output) + } +} + +impl XmlWritable for Message { + fn write_xml<'a>(&self, writer: &'a mut XmlWriter) -> quick_xml::Result<&'a mut XmlWriter> { + let element = writer + .create_element(Self::TAG_NAME) + .with_attribute(("name", self.name.as_str())) + .with_attribute(("msgtype", self.msg_type.as_str())) + .with_attribute(("msgcat", self.category.as_static_str())); + + if self.values.is_empty() { + element.write_empty() + } else { + element.write_inner_content(|writer| write_xml_list(writer, &self.values)) + } + } +} diff --git a/quickfix-spec-parser/src/model/message_category.rs b/quickfix-spec-parser/src/model/message_category.rs new file mode 100644 index 0000000..f1be0b8 --- /dev/null +++ b/quickfix-spec-parser/src/model/message_category.rs @@ -0,0 +1,30 @@ +use std::str::FromStr; + +use crate::FixSpecError; + +#[derive(Debug)] +pub enum MessageCategory { + App, + Admin, +} + +impl MessageCategory { + pub const fn as_static_str(&self) -> &'static str { + match self { + MessageCategory::App => "app", + MessageCategory::Admin => "admin", + } + } +} + +impl FromStr for MessageCategory { + type Err = FixSpecError; + + fn from_str(input: &str) -> Result { + match input { + "admin" => Ok(Self::Admin), + "app" => Ok(Self::App), + x => Err(FixSpecError::InvalidContent(format!("invalid msgcat: {x}"))), + } + } +} diff --git a/quickfix-spec-parser/src/model/spec.rs b/quickfix-spec-parser/src/model/spec.rs new file mode 100644 index 0000000..72811cb --- /dev/null +++ b/quickfix-spec-parser/src/model/spec.rs @@ -0,0 +1,87 @@ +use quick_xml::events::{BytesStart, Event}; + +use crate::{ + parse_xml_list, read_attribute, write_xml_container, ComponentSpec, FieldSpec, FieldValue, + FixSpecError, XmlObject, XmlReadable, XmlReader, XmlWritable, XmlWriter, +}; + +use super::message::Message; + +#[derive(Debug)] +pub struct FixSpec { + pub version: (u8, u8, u8), + pub is_fixt: bool, + pub headers: Vec, + pub messages: Vec, + pub trailers: Vec, + pub component_specs: Vec, + pub field_specs: Vec, +} + +impl XmlObject for FixSpec { + const TAG_NAME: &'static str = "fix"; +} + +impl XmlReadable for FixSpec { + fn parse_xml_node(element: &BytesStart) -> Result { + let version = ( + read_attribute(element, "major")?.parse()?, + read_attribute(element, "minor")?.parse()?, + read_attribute(element, "servicepack")?.parse()?, + ); + + let is_fixt = read_attribute(element, "type")? == "FIXT"; + + Ok(Self { + version, + is_fixt, + headers: Vec::new(), + messages: Vec::new(), + trailers: Vec::new(), + component_specs: Vec::new(), + field_specs: Vec::new(), + }) + } + + fn parse_xml_tree(element: &BytesStart, reader: &mut XmlReader) -> Result { + let mut output = Self::parse_xml_node(element)?; + + loop { + match reader.read_event()? { + Event::Start(element) => match element.name().as_ref() { + b"header" => output.headers = FieldValue::parse_xml_tree(reader, "header")?, + b"messages" => output.messages = parse_xml_list(reader, "messages")?, + b"trailer" => output.trailers = FieldValue::parse_xml_tree(reader, "trailer")?, + b"components" => output.component_specs = parse_xml_list(reader, "components")?, + b"fields" => output.field_specs = parse_xml_list(reader, "fields")?, + _ => {} + }, + Event::End(element) if element.name().as_ref() == Self::TAG_NAME.as_bytes() => { + break + } + _ => {} + } + } + + Ok(output) + } +} + +impl XmlWritable for FixSpec { + fn write_xml<'a>(&self, writer: &'a mut XmlWriter) -> quick_xml::Result<&'a mut XmlWriter> { + writer + .create_element(Self::TAG_NAME) + .with_attribute(("type", if self.is_fixt { "FIXT" } else { "FIX" })) + .with_attribute(("major", self.version.0.to_string().as_str())) + .with_attribute(("minor", self.version.1.to_string().as_str())) + .with_attribute(("servicepack", self.version.2.to_string().as_str())) + .write_inner_content(|writer| { + write_xml_container(writer, "header", &self.headers)?; + write_xml_container(writer, "messages", &self.messages)?; + write_xml_container(writer, "trailer", &self.trailers)?; + write_xml_container(writer, "components", &self.component_specs)?; + write_xml_container(writer, "fields", &self.field_specs)?; + Ok(()) + }) + } +} diff --git a/quickfix-spec-parser/src/xml_ext.rs b/quickfix-spec-parser/src/xml_ext.rs new file mode 100644 index 0000000..99319d0 --- /dev/null +++ b/quickfix-spec-parser/src/xml_ext.rs @@ -0,0 +1,83 @@ +use quick_xml::events::{BytesStart, Event}; + +use crate::{FixSpecError, XmlReader, XmlWriter}; + +pub fn read_attribute(item: &BytesStart, name: &str) -> Result { + let attr = item + .attributes() + .filter_map(|x| x.ok()) + .find(|x| x.key.as_ref() == name.as_bytes()) + .ok_or_else(|| FixSpecError::InvalidAttribute(name.to_string()))?; + + let value = String::from_utf8(attr.value.to_vec())?; + + Ok(value) +} + +pub trait XmlObject { + const TAG_NAME: &'static str; +} + +pub trait XmlWritable { + fn write_xml<'a>(&self, writer: &'a mut XmlWriter) -> quick_xml::Result<&'a mut XmlWriter>; +} + +pub fn write_xml_list( + writer: &mut XmlWriter, + items: &[T], +) -> quick_xml::Result<()> { + for item in items { + item.write_xml(writer)?; + } + Ok(()) +} + +pub fn write_xml_container<'a, T: XmlWritable>( + writer: &'a mut XmlWriter, + tag_name: &'a str, + items: &[T], +) -> quick_xml::Result<&'a mut XmlWriter> { + let element = writer.create_element(tag_name); + + if items.is_empty() { + element.write_empty() + } else { + element.write_inner_content(|writer| write_xml_list(writer, items)) + } +} + +pub trait XmlReadable: XmlObject { + fn parse_xml_node(element: &BytesStart) -> Result + where + Self: Sized; + + #[allow(unused_variables)] + fn parse_xml_tree(element: &BytesStart, reader: &mut XmlReader) -> Result + where + Self: Sized, + { + Self::parse_xml_node(element) + } +} + +pub fn parse_xml_list( + reader: &mut XmlReader, + end_tag: &str, +) -> Result, FixSpecError> { + let mut output = Vec::new(); + + loop { + match reader.read_event()? { + Event::Start(element) if element.name().as_ref() == T::TAG_NAME.as_bytes() => { + output.push(T::parse_xml_tree(&element, reader)?); + } + Event::Empty(element) if element.name().as_ref() == T::TAG_NAME.as_bytes() => { + output.push(T::parse_xml_node(&element)?); + } + Event::End(element) if element.name().as_ref() == end_tag.as_bytes() => { + return Ok(output); + } + _ => {} + } + } +} diff --git a/quickfix-spec-parser/tests/data/commented_file.xml b/quickfix-spec-parser/tests/data/commented_file.xml new file mode 100644 index 0000000..e43370c --- /dev/null +++ b/quickfix-spec-parser/tests/data/commented_file.xml @@ -0,0 +1,262 @@ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/quickfix-spec-parser/tests/test_derive.rs b/quickfix-spec-parser/tests/test_derive.rs new file mode 100644 index 0000000..56dd7df --- /dev/null +++ b/quickfix-spec-parser/tests/test_derive.rs @@ -0,0 +1,110 @@ +use std::fmt::Debug; + +use quickfix_spec_parser::*; + +macro_rules! s { + ($x:expr) => { + $x.to_string() + }; +} + +fn check(obj: T, expected: &str) { + assert_eq!(format!("{obj:?}"), expected); +} + +#[test] +fn test_error() { + assert_eq!( + format!("{:?}", FixSpecError::InvalidDocument("bad header")), + "InvalidDocument(\"bad header\")" + ); + assert_eq!( + format!("{}", FixSpecError::InvalidDocument("bad header")), + "invalid document: bad header" + ); + assert_ne!( + FixSpecError::InvalidDocument("Bad header"), + FixSpecError::Xml(s!("hello")) + ); +} + +#[test] +fn test_debug() { + // This test are here only to make code coverage happy ... 😒 + + check( + Component { + name: s!("foo"), + required: false, + }, + "Component { name: \"foo\", required: false }", + ); + check( + ComponentSpec { + name: s!("bar"), + values: vec![], + }, + "ComponentSpec { name: \"bar\", values: [] }", + ); + check(MessageCategory::Admin, "Admin"); + check(FieldType::SequenceNumber, "SequenceNumber"); + check( + FieldAllowedValue { + value: s!("hello"), + description: s!("Some value"), + }, + "FieldAllowedValue { value: \"hello\", description: \"Some value\" }", + ); + check( + FieldSpec { + number: 42, + name: s!("The Ultimate Question of Life"), + r#type: FieldType::Amount, + values: vec![], + }, + "FieldSpec { number: 42, name: \"The Ultimate Question of Life\", type: Amount, values: [] }", + ); + check( + Field { + name: s!("X"), + required: false, + }, + "Field { name: \"X\", required: false }", + ); + check( + Group { + name: s!("X"), + required: true, + values: vec![], + }, + "Group { name: \"X\", required: true, values: [] }", + ); + check( + FieldValue::Field(Field { + name: s!("X"), + required: false, + }), + "Field(Field { name: \"X\", required: false })", + ); + check( + Message { + name: s!("foo"), + category: MessageCategory::App, + msg_type: s!("bar"), + values: vec![], + }, + "Message { name: \"foo\", msg_type: \"bar\", category: App, values: [] }", + ); + check( + FixSpec { + version: (4, 8, 3), + is_fixt: false, + headers: vec![], + messages: vec![], + trailers: vec![], + component_specs: vec![], + field_specs: vec![], + }, + "FixSpec { version: (4, 8, 3), is_fixt: false, headers: [], messages: [], trailers: [], component_specs: [], field_specs: [] }", + ); +} diff --git a/quickfix-spec-parser/tests/test_enums.rs b/quickfix-spec-parser/tests/test_enums.rs new file mode 100644 index 0000000..2ffbe18 --- /dev/null +++ b/quickfix-spec-parser/tests/test_enums.rs @@ -0,0 +1,17 @@ +use quickfix_spec_parser::*; + +#[test] +fn test_invalid_field_type() { + assert_eq!( + "foo".parse::().unwrap_err(), + FixSpecError::InvalidContent("unknown field type: foo".to_string()) + ); +} + +#[test] +fn test_invalid_message_category() { + assert_eq!( + "foo".parse::().unwrap_err(), + FixSpecError::InvalidContent("invalid msgcat: foo".to_string()) + ); +} diff --git a/quickfix-spec-parser/tests/test_invalid_spec.rs b/quickfix-spec-parser/tests/test_invalid_spec.rs new file mode 100644 index 0000000..73aa2e3 --- /dev/null +++ b/quickfix-spec-parser/tests/test_invalid_spec.rs @@ -0,0 +1,26 @@ +use quickfix_spec_parser::*; + +fn check_text(expected: &[u8]) { + assert!(parse_spec(expected).is_err(),); +} + +#[test] +fn test_invalid_parse() { + // Bad root node. + check_text(b""); + + // Bad version. + check_text(r#""#.as_bytes()); + check_text(r#""#.as_bytes()); + check_text(r#""#.as_bytes()); + check_text(r#""#.as_bytes()); + + // Missing fix type. + check_text(r#""#.as_bytes()); + + // Invalid XML. + check_text(b"", " />"); + + assert_eq!(txt.as_bytes(), expected); +} + +#[test] +fn test_fix40() { + check(include_bytes!( + "../../quickfix-ffi/libquickfix/spec/FIX40.xml" + )); +} + +#[test] +fn test_fix41() { + check(include_bytes!( + "../../quickfix-ffi/libquickfix/spec/FIX41.xml" + )); +} + +#[test] +fn test_fix42() { + check(include_bytes!( + "../../quickfix-ffi/libquickfix/spec/FIX42.xml" + )); +} + +#[test] +fn test_fix43() { + check(include_bytes!( + "../../quickfix-ffi/libquickfix/spec/FIX43.xml" + )); +} + +#[test] +fn test_fix44() { + check(include_bytes!( + "../../quickfix-ffi/libquickfix/spec/FIX44.xml" + )); +} + +#[test] +fn test_fix50() { + check(include_bytes!( + "../../quickfix-ffi/libquickfix/spec/FIX50.xml" + )); +} + +#[test] +fn test_fix50sp1() { + check(include_bytes!( + "../../quickfix-ffi/libquickfix/spec/FIX50SP1.xml" + )); +} + +#[test] +fn test_fix50sp2() { + check(include_bytes!( + "../../quickfix-ffi/libquickfix/spec/FIX50SP2.xml" + )); +} + +#[test] +fn test_fixt11() { + check(include_bytes!( + "../../quickfix-ffi/libquickfix/spec/FIXT11.xml" + )); +} + +#[test] +fn test_parse_with_comment() { + // This test has multiple purposes: + // 1. Increase code coverage + // 2. Check parser do not crash if there is unhandled node + parse_spec(include_bytes!("data/commented_file.xml")).unwrap(); +} diff --git a/quickfix-spec-parser/tests/test_xml.rs b/quickfix-spec-parser/tests/test_xml.rs new file mode 100644 index 0000000..6fb0267 --- /dev/null +++ b/quickfix-spec-parser/tests/test_xml.rs @@ -0,0 +1,17 @@ +use quick_xml::events::BytesStart; +use quickfix_spec_parser::{read_attribute, FixSpecError}; + +#[test] +fn test_read_attributes() { + let div = + BytesStart::new("div").with_attributes([(b"class".as_slice(), b"test-me".as_slice())]); + + // Check valid. + assert_eq!(read_attribute(&div, "class").as_deref(), Ok("test-me")); + + // Check not found. + assert_eq!( + read_attribute(&div, "style"), + Err(FixSpecError::InvalidAttribute("style".to_string())) + ); +}