diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/warden-core/Cargo.toml | 13 | ||||
-rw-r--r-- | lib/warden-core/build.rs | 13 | ||||
-rw-r--r-- | lib/warden-core/src/google.rs | 2 | ||||
-rw-r--r-- | lib/warden-core/src/google/parser.rs | 4 | ||||
-rw-r--r-- | lib/warden-core/src/google/parser/dt.rs | 233 | ||||
-rw-r--r-- | lib/warden-core/src/google/parser/money.rs | 293 | ||||
-rw-r--r-- | lib/warden-core/src/lib.rs | 5 | ||||
-rw-r--r-- | lib/warden-core/src/message.rs | 1 |
8 files changed, 561 insertions, 3 deletions
diff --git a/lib/warden-core/Cargo.toml b/lib/warden-core/Cargo.toml index 9cd4617..bdf0af0 100644 --- a/lib/warden-core/Cargo.toml +++ b/lib/warden-core/Cargo.toml @@ -15,6 +15,7 @@ rustdoc-args = ["--cfg", "docsrs"] prost = { workspace = true, optional = true } serde = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } +time = { workspace = true, optional = true } tonic = { workspace = true, optional = true } tonic-types = { version = "0.14.0", optional = true } utoipa = { workspace = true, optional = true } @@ -23,7 +24,17 @@ utoipa = { workspace = true, optional = true } default = [] iso20022 = ["dep:prost", "dep:tonic", "dep:tonic-types"] serde = ["dep:serde", "serde/derive", "dep:serde_json"] -openapi = ["dep:utoipa", "serde"] +serde-time = [ + "time", + "serde", + "time/serde", +] +time = [ + "time/parsing", + "time/formatting", + "time/macros", +] +openapi = ["dep:utoipa", "serde-time", "utoipa/time"] [build-dependencies] tonic-prost-build = { version = "0.14.0", features = ["cleanup-markdown"] } diff --git a/lib/warden-core/build.rs b/lib/warden-core/build.rs index 7ae6b6a..1998137 100644 --- a/lib/warden-core/build.rs +++ b/lib/warden-core/build.rs @@ -14,6 +14,7 @@ impl Entity { vec![ "proto/iso20022/pacs_008_001_12.proto", "proto/iso20022/pacs_002_001_12.proto", + "proto/warden_message.proto", ] } @@ -78,10 +79,18 @@ fn build_proto(package: &str, entity: Entity) -> Result<(), Box<dyn std::error:: #[cfg(all(feature = "serde", feature = "iso20022"))] fn add_serde(config: tonic_prost_build::Builder) -> tonic_prost_build::Builder { - config.type_attribute( + let config = config.type_attribute( ".", "#[derive(serde::Serialize, serde::Deserialize)] #[serde(rename_all = \"snake_case\")]", - ) + ); + + #[cfg(feature = "time")] + let config = config.type_attribute( + ".google.protobuf.Timestamp", + "#[serde(try_from = \"time::OffsetDateTime\")] #[serde(into = \"String\")]", + ); + + config } #[cfg(all(feature = "openapi", feature = "iso20022"))] diff --git a/lib/warden-core/src/google.rs b/lib/warden-core/src/google.rs index 365766c..0e9487d 100644 --- a/lib/warden-core/src/google.rs +++ b/lib/warden-core/src/google.rs @@ -1,3 +1,5 @@ +mod parser; + /// Well known types pub mod protobuf { include!(concat!(env!("OUT_DIR"), "/google.protobuf.rs")); diff --git a/lib/warden-core/src/google/parser.rs b/lib/warden-core/src/google/parser.rs new file mode 100644 index 0000000..7405077 --- /dev/null +++ b/lib/warden-core/src/google/parser.rs @@ -0,0 +1,4 @@ +#[cfg(feature = "time")] +mod dt; + +//mod money; diff --git a/lib/warden-core/src/google/parser/dt.rs b/lib/warden-core/src/google/parser/dt.rs new file mode 100644 index 0000000..ced6f12 --- /dev/null +++ b/lib/warden-core/src/google/parser/dt.rs @@ -0,0 +1,233 @@ +use crate::google::{protobuf::Timestamp, r#type::Date}; + +impl From<time::OffsetDateTime> for Date { + fn from(dt: time::OffsetDateTime) -> Self { + Self { + year: dt.year(), + month: dt.month() as i32, + day: dt.day() as i32, + } + } +} + +impl From<time::Date> for Date { + fn from(value: time::Date) -> Self { + Self { + year: value.year(), + month: value.month() as i32, + day: value.day() as i32, + } + } +} + +impl TryFrom<Date> for time::Date { + type Error = time::Error; + + fn try_from(value: Date) -> Result<Self, Self::Error> { + Ok(Self::from_calendar_date( + value.year, + time::Month::try_from(value.month as u8)?, + value.day as u8, + )?) + } +} + +impl std::str::FromStr for Date { + type Err = time::Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let date = time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339) + .map(Date::from); + + match date { + Ok(dt) => Ok(dt), + Err(_e) => { + let my_format = time::macros::format_description!("[year]-[month]-[day]"); + let date = time::Date::parse(s, &my_format)?; + Ok(Date::from(date)) + } + } + } +} + +impl TryFrom<String> for Date { + type Error = time::Error; + + fn try_from(value: String) -> Result<Self, Self::Error> { + <Date as std::str::FromStr>::from_str(&value) + } +} + +impl TryFrom<DateItem> for Date { + type Error = time::Error; + + fn try_from(value: DateItem) -> Result<Self, Self::Error> { + match value { + DateItem::String(ref string) => <Date as std::str::FromStr>::from_str(string), + #[cfg(feature = "iso20022")] + DateItem::Date { year, month, day } => Ok(Date { year, month, day }), + DateItem::Timestamp { seconds, nanos } => { + let odt = time::OffsetDateTime::try_from(crate::google::protobuf::Timestamp { + seconds, + nanos, + })?; + Ok(Self { + year: odt.year(), + month: odt.month() as i32, + day: odt.day() as i32, + }) + } + } + } +} + +impl From<Date> for String { + fn from(value: Date) -> Self { + let prepend = |value: i32| -> String { + match value.lt(&10) { + true => format!("0{}", value), + false => value.to_string(), + } + }; + format!( + "{}-{}-{}", + value.year, + prepend(value.month), + prepend(value.day), + ) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(untagged))] +/// Date utility +#[derive(Clone, Debug)] +pub enum DateItem { + /// string + String(String), + /// ts + Timestamp { seconds: i64, nanos: i32 }, + /// date + #[cfg(feature = "iso20022")] + Date { year: i32, month: i32, day: i32 }, +} + +impl TryFrom<DateItem> for Timestamp { + type Error = time::Error; + + fn try_from(value: DateItem) -> Result<Self, Self::Error> { + match value { + DateItem::String(ref string) => <Timestamp as std::str::FromStr>::from_str(string), + #[cfg(feature = "iso20022")] + DateItem::Date { year, month, day } => { + let date = time::Date::try_from(crate::google::r#type::Date { year, month, day })?; + let time = time::Time::MIDNIGHT; + let offset = time::UtcOffset::UTC; + Ok(date.with_time(time).assume_offset(offset).into()) + } + DateItem::Timestamp { seconds, nanos } => Ok(Self { seconds, nanos }), + } + } +} + +impl From<time::OffsetDateTime> for Timestamp { + fn from(dt: time::OffsetDateTime) -> Self { + Timestamp { + seconds: dt.unix_timestamp(), + nanos: dt.nanosecond() as i32, + } + } +} + +impl From<Timestamp> for String { + fn from(value: Timestamp) -> Self { + let odt = time::OffsetDateTime::try_from(value).expect("invalid date"); + odt.format(&time::format_description::well_known::Rfc3339) + .expect("format is not rfc3339") + } +} + +impl std::str::FromStr for Timestamp { + type Err = time::Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let timestamp = + time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)?; + + Ok(Timestamp::from(timestamp)) + } +} + +impl TryFrom<String> for Timestamp { + type Error = time::Error; + + fn try_from(value: String) -> Result<Self, Self::Error> { + <Timestamp as std::str::FromStr>::from_str(&value) + } +} + +impl TryFrom<Timestamp> for time::OffsetDateTime { + type Error = time::Error; + + fn try_from(value: Timestamp) -> Result<Self, Self::Error> { + let dt = time::OffsetDateTime::from_unix_timestamp(value.seconds)?; + + Ok(dt.replace_nanosecond(value.nanos as u32)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use time::{Duration, OffsetDateTime}; + + #[test] + fn test_offsetdatetime_to_timestamp() { + let now = OffsetDateTime::now_utc(); + let timestamp: Timestamp = now.into(); + + assert_eq!(timestamp.seconds, now.unix_timestamp()); + assert_eq!(timestamp.nanos, now.nanosecond() as i32); + } + + #[test] + fn test_timestamp_to_offsetdatetime() { + let now = OffsetDateTime::now_utc(); + let timestamp: Timestamp = now.into(); + let dt: OffsetDateTime = timestamp.try_into().unwrap(); + + assert_eq!(dt, now); + } + + #[test] + fn test_timestamp_to_offsetdatetime_with_nanos() { + let now = OffsetDateTime::now_utc(); + let nanos = 123456789; + let dt = now + Duration::nanoseconds(nanos); + let timestamp: Timestamp = dt.into(); + let dt_from_timestamp: OffsetDateTime = timestamp.try_into().unwrap(); + + assert_eq!(dt_from_timestamp, dt); + } + + #[test] + fn test_timestamp_to_offsetdatetime_with_negative_nanos() { + let now = OffsetDateTime::now_utc(); + let nanos = -123456789; + let dt = now + Duration::nanoseconds(nanos); + let timestamp: Timestamp = dt.into(); + let dt_from_timestamp: OffsetDateTime = timestamp.try_into().unwrap(); + + assert_eq!(dt_from_timestamp, dt); + } + + #[test] + fn test_timestamp_to_offsetdatetime_invalid_seconds() { + let timestamp = Timestamp { + seconds: i64::MIN, + nanos: 0, + }; + let result: Result<OffsetDateTime, time::Error> = timestamp.try_into(); + assert!(result.is_err()); + } +} diff --git a/lib/warden-core/src/google/parser/money.rs b/lib/warden-core/src/google/parser/money.rs new file mode 100644 index 0000000..54ed5fa --- /dev/null +++ b/lib/warden-core/src/google/parser/money.rs @@ -0,0 +1,293 @@ +use crate::google::r#type::Date; + +/// If money cannot be created +#[derive(Debug, PartialEq)] +pub enum MoneyError { + /// Invalid currency code + InvalidCurrencyCode, +} + +impl<T> TryFrom<(f64, T)> for Money +where + T: AsRef<str>, +{ + type Error = MoneyError; + + fn try_from((value, ccy): (f64, T)) -> Result<Self, Self::Error> { + let currency_code = ccy.as_ref(); + + let is_valid = + currency_code.len() == 3 && currency_code.chars().all(|c| c.is_ascii_uppercase()); + + if !is_valid { + return Err(MoneyError::InvalidCurrencyCode); + } + + let units = value.trunc() as i64; + let nanos_raw = ((value - units as f64) * 1_000_000_000.0).round() as i32; + + let (units, nanos) = if units > 0 && nanos_raw < 0 { + (units - 1, nanos_raw + 1_000_000_000) + } else if units < 0 && nanos_raw > 0 { + (units + 1, nanos_raw - 1_000_000_000) + } else { + (units, nanos_raw) + }; + + Ok(Money { + currency_code: currency_code.to_string(), + units, + nanos, + }) + } +} + +impl From<Money> for f64 { + fn from(m: Money) -> Self { + m.units as f64 + (m.nanos as f64) / 1_000_000_000.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::convert::TryFrom; + + #[test] + fn test_valid_money_conversion() { + let money = Money::try_from((1.75, "USD")).unwrap(); + assert_eq!( + money, + Money { + currency_code: "USD".to_string(), + units: 1, + nanos: 750_000_000 + } + ); + + let money = Money::try_from((-1.75, "EUR")).unwrap(); + assert_eq!( + money, + Money { + currency_code: "EUR".to_string(), + units: -1, + nanos: -750_000_000 + } + ); + + let money = Money::try_from((0.333_333_333, "JPY")).unwrap(); + assert_eq!( + money, + Money { + currency_code: "JPY".to_string(), + units: 0, + nanos: 333_333_333 + } + ); + } + + #[test] + fn test_sign_correction() { + let m = Money::try_from((1.000_000_001, "USD")).unwrap(); + assert_eq!(m.units, 1); + assert_eq!(m.nanos, 1); + + let m = Money::try_from((-1.000_000_001, "USD")).unwrap(); + assert_eq!(m.units, -1); + assert_eq!(m.nanos, -1); + + let m = Money::try_from((0.000_000_001, "USD")).unwrap(); + assert_eq!(m.units, 0); + assert_eq!(m.nanos, 1); + + let m = Money::try_from((-0.000_000_001, "USD")).unwrap(); + assert_eq!(m.units, 0); + assert_eq!(m.nanos, -1); + } + + #[test] + fn test_invalid_currency_code() { + let cases = ["usd", "US", "USDD", "12A", "€€€", ""]; + + for &code in &cases { + let result = Money::try_from((1.0, code)); + assert_eq!(result, Err(MoneyError::InvalidCurrencyCode)); + } + } + + #[test] + fn test_money_to_f64_positive() { + let money = Money { + currency_code: "USD".to_string(), + units: 1, + nanos: 750_000_000, + }; + let value: f64 = money.into(); + assert_eq!(value, 1.75); + } + + #[test] + fn test_money_to_f64_negative() { + let money = Money { + currency_code: "EUR".to_string(), + units: -1, + nanos: -250_000_000, + }; + let value: f64 = money.into(); + assert_eq!(value, -1.25); + } + + #[test] + fn test_money_to_f64_zero() { + let money = Money { + currency_code: "JPY".to_string(), + units: 0, + nanos: 0, + }; + let value: f64 = money.into(); + assert_eq!(value, 0.0); + } + + #[test] + fn test_money_to_f64_fractional_positive() { + let money = Money { + currency_code: "USD".to_string(), + units: 0, + nanos: 1, + }; + let value: f64 = money.into(); + assert_eq!(value, 1.0e-9); + } + + #[test] + fn test_money_to_f64_fractional_negative() { + let money = Money { + currency_code: "USD".to_string(), + units: 0, + nanos: -1, + }; + let value: f64 = money.into(); + assert_eq!(value, -1.0e-9); + } + + #[test] + fn test_round_trip_conversion() { + let original = 1234.567_890_123; + let money = Money::try_from((original, "USD")).unwrap(); + let back: f64 = money.into(); + assert!( + (original - back).abs() < 1e-9, + "got {}, expected {}", + back, + original + ); + } +} + +#[cfg(feature = "time")] +impl From<time::OffsetDateTime> for Date { + fn from(dt: time::OffsetDateTime) -> Self { + Self { + year: dt.year(), + month: dt.month() as i32, + day: dt.day() as i32, + } + } +} + +#[cfg(feature = "time")] +impl From<time::Date> for Date { + fn from(value: time::Date) -> Self { + Self { + year: value.year(), + month: value.month() as i32, + day: value.day() as i32, + } + } +} + +#[cfg(feature = "time")] +impl TryFrom<Date> for time::Date { + type Error = time::Error; + + fn try_from(value: Date) -> Result<Self, Self::Error> { + Ok(Self::from_calendar_date( + value.year, + time::Month::try_from(value.month as u8)?, + value.day as u8, + )?) + } +} + +#[cfg(feature = "time")] +impl std::str::FromStr for Date { + type Err = time::Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let date = time::OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc3339) + .map(Date::from); + + match date { + Ok(dt) => Ok(dt), + Err(_e) => { + let my_format = time::macros::format_description!("[year]-[month]-[day]"); + let date = time::Date::parse(&s, &my_format)?; + Ok(Date::from(date)) + } + } + } +} + +#[cfg(feature = "time")] +impl TryFrom<String> for Date { + type Error = time::Error; + + fn try_from(value: String) -> Result<Self, Self::Error> { + <Date as std::str::FromStr>::from_str(&value) + } +} + +#[cfg(feature = "time")] +impl TryFrom<super::helpers::time_util::DateItem> for Date { + type Error = time::Error; + + fn try_from(value: super::helpers::time_util::DateItem) -> Result<Self, Self::Error> { + match value { + super::helpers::time_util::DateItem::String(ref string) => { + <Date as std::str::FromStr>::from_str(string) + } + #[cfg(feature = "iso20022")] + super::helpers::time_util::DateItem::Date { year, month, day } => { + Ok(Date { year, month, day }) + } + super::helpers::time_util::DateItem::Timestamp { seconds, nanos } => { + let odt = time::OffsetDateTime::try_from(crate::google::protobuf::Timestamp { + seconds, + nanos, + })?; + Ok(Self { + year: odt.year(), + month: odt.month() as i32, + day: odt.day() as i32, + }) + } + } + } +} + +impl From<Date> for String { + fn from(value: Date) -> Self { + let prepend = |value: i32| -> String { + match value.lt(&10) { + true => format!("0{}", value), + false => value.to_string(), + } + }; + format!( + "{}-{}-{}", + value.year, + prepend(value.month), + prepend(value.day), + ) + } +} diff --git a/lib/warden-core/src/lib.rs b/lib/warden-core/src/lib.rs index 111ce5d..19d02f3 100644 --- a/lib/warden-core/src/lib.rs +++ b/lib/warden-core/src/lib.rs @@ -15,3 +15,8 @@ pub mod google; #[allow(missing_docs)] #[cfg(feature = "iso20022")] pub mod iso20022; + +/// Message in transit +#[allow(missing_docs)] +#[cfg(feature = "iso20022")] +pub mod message; diff --git a/lib/warden-core/src/message.rs b/lib/warden-core/src/message.rs new file mode 100644 index 0000000..4b8142b --- /dev/null +++ b/lib/warden-core/src/message.rs @@ -0,0 +1 @@ +tonic::include_proto!("message"); |