From ff9dc5ee4028e18f79140005da25111eca2d3f63 Mon Sep 17 00:00:00 2001 From: Rudi Floren Date: Thu, 22 Jan 2026 18:48:59 +0100 Subject: [PATCH 1/5] add fee-1.0 extension --- src/extensions/fee/duration.rs | 318 +++++ src/extensions/fee/mod.rs | 1182 +++++++++++++++++ src/lib.rs | 1 + .../request/extensions/fee/check.xml | 24 + .../request/extensions/fee/create.xml | 28 + .../request/extensions/fee/delete.xml | 12 + .../request/extensions/fee/renew.xml | 19 + .../request/extensions/fee/transfer_query.xml | 15 + .../extensions/fee/transfer_request.xml | 21 + .../request/extensions/fee/update.xml | 20 + .../response/extensions/fee/check.xml | 75 ++ .../response/extensions/fee/create.xml | 27 + .../response/extensions/fee/delete.xml | 19 + .../response/extensions/fee/renew.xml | 25 + .../extensions/fee/transfer_query.xml | 30 + .../extensions/fee/transfer_request.xml | 29 + .../response/extensions/fee/update.xml | 18 + 17 files changed, 1863 insertions(+) create mode 100644 src/extensions/fee/duration.rs create mode 100644 src/extensions/fee/mod.rs create mode 100644 tests/resources/request/extensions/fee/check.xml create mode 100644 tests/resources/request/extensions/fee/create.xml create mode 100644 tests/resources/request/extensions/fee/delete.xml create mode 100644 tests/resources/request/extensions/fee/renew.xml create mode 100644 tests/resources/request/extensions/fee/transfer_query.xml create mode 100644 tests/resources/request/extensions/fee/transfer_request.xml create mode 100644 tests/resources/request/extensions/fee/update.xml create mode 100644 tests/resources/response/extensions/fee/check.xml create mode 100644 tests/resources/response/extensions/fee/create.xml create mode 100644 tests/resources/response/extensions/fee/delete.xml create mode 100644 tests/resources/response/extensions/fee/renew.xml create mode 100644 tests/resources/response/extensions/fee/transfer_query.xml create mode 100644 tests/resources/response/extensions/fee/transfer_request.xml create mode 100644 tests/resources/response/extensions/fee/update.xml diff --git a/src/extensions/fee/duration.rs b/src/extensions/fee/duration.rs new file mode 100644 index 0000000..a501382 --- /dev/null +++ b/src/extensions/fee/duration.rs @@ -0,0 +1,318 @@ +//! XSD Duration format + +use std::ops::Div; +use std::str::FromStr; + +use instant_xml::{FromXml, ToXml}; + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct XsdDuration { + months: i64, + seconds: f64, +} + +impl XsdDuration { + pub fn new(months: i64, seconds: f64) -> Result { + if months < 0 && seconds > 0.0 || months > 0 && seconds < 0.0 { + return Err(InvalidMinutesOrSeconds); + } + Ok(Self { months, seconds }) + } + + fn is_zero(&self) -> bool { + self.months == 0 && self.seconds == 0.0 + } +} + +impl FromStr for XsdDuration { + type Err = ParseError; + + /// Parses an XSD duration string into [`XsdDuration`]. + /// + /// Algorithm based on https://www.w3.org/TR/xmlschema11-2/#f-durationMap + /// + /// DUR consists of possibly a leading '-', followed by 'P' and then an instance Y of duYearMonthFrag and/or an instance D of duDayTimeFrag: + /// Return a duration whose: + /// * months value is 12 * Y + M + /// * seconds value is 86400 * D + (3600 * H + 60 * M + S) + /// + /// where Y, M, D, H, M, S are the values parsed from the string. + /// + /// All values default to 0 if not present. + fn from_str(s: &str) -> Result { + // DUR consists of possibly a leading '-', followed by 'P' and then an instance Y of duYearMonthFrag and/or an instance D of duDayTimeFrag: + let sgn = if s.starts_with('-') { -1 } else { 1 }; + let s = s.trim_start_matches('-'); + if !s.starts_with('P') { + return Err(ParseError); + } + let s = &s[1..]; + + // duYearMonthFrag + let (s, months) = { + let mut y = 0; + let s = match s.split_once("Y") { + Some((l, r)) => { + y = l.parse::().map_err(|_| ParseError)?; + r + } + None => s, + }; + let mut m = 0; + let s = match s.split_once("M") { + // skip months if the M was part of duDayTimeFrag + Some((l, r)) if !l.contains('T') => { + m = l.parse::().map_err(|_| ParseError)?; + r + } + _ => s, + }; + + (s, 12 * (y as i64) + (m as i64) * sgn) + }; + + // duDayTimeFrag + let seconds = { + let mut d = 0; + + let s = match s.split_once('D') { + Some((l, r)) => { + d = l.parse::().map_err(|_| ParseError)?; + r + } + None => s, + }; + if !s.starts_with("T") { + return Ok(Self { + months, + seconds: (86400 * d) as f64, + }); + } + let s = &s[1..]; + let t = { + let mut h = 0; + let mut m = 0; + let mut ss = 0.0; + let s = match s.split_once('H') { + Some((l, r)) => { + h = l.parse::().map_err(|_| ParseError)?; + r + } + None => s, + }; + let s = match s.split_once('M') { + Some((l, r)) => { + m = l.parse::().map_err(|_| ParseError)?; + r + } + None => s, + }; + if let Some((l, _r)) = s.split_once('S') { + ss = l.parse::().map_err(|_| ParseError)?; + } + + (3600 * h) as f64 + (60 * m) as f64 + ss + }; + + (86400 * d) as f64 + t + }; + + Ok(Self { months, seconds }) + } +} + +impl<'xml> FromXml<'xml> for XsdDuration { + fn matches(id: instant_xml::Id<'_>, field: Option>) -> bool { + match field { + Some(field) => id == field, + None => false, + } + } + + fn deserialize<'cx>( + into: &mut Self::Accumulator, + field: &'static str, + deserializer: &mut instant_xml::Deserializer<'cx, 'xml>, + ) -> Result<(), instant_xml::Error> { + if into.is_some() { + return Err(instant_xml::Error::DuplicateValue(field)); + } + + if let Some(value) = deserializer.take_str()? { + let duration = value.parse().map_err(|_| { + instant_xml::Error::Other(format!("failed to parse xsd duration: {}", value)) + })?; + *into = Some(duration); + } + + Ok(()) + } + + type Accumulator = Option; + + const KIND: instant_xml::Kind = instant_xml::Kind::Scalar; +} + +impl ToXml for XsdDuration { + fn serialize( + &self, + _field: Option>, + serializer: &mut instant_xml::Serializer, + ) -> Result<(), instant_xml::Error> { + serializer.write_str(&format_duration_inner(self))?; + Ok(()) + } +} + +#[derive(Debug)] +pub struct InvalidMinutesOrSeconds; + +impl std::error::Error for InvalidMinutesOrSeconds {} + +impl std::fmt::Display for InvalidMinutesOrSeconds { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "invalid minutes or seconds value") + } +} + +#[derive(Debug)] +pub struct ParseError; + +impl std::error::Error for ParseError {} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "failed to parse xsd duration") + } +} + +/// Serialize duration to XML duration string +/// +/// See https://www.w3.org/TR/xmlschema11-2/#duration +pub fn format_duration(duration: D) -> Result +where + D: TryInto, +{ + let duration: XsdDuration = duration.try_into()?; + Ok(format_duration_inner(&duration)) +} +/// Serialize duration to XML duration string +/// +/// https://www.w3.org/TR/xmlschema11-2/#f-durationCanMap +fn format_duration_inner(duration: &XsdDuration) -> String { + if duration.is_zero() { + return "P0D".to_owned(); + } + + let mut buf = if duration.months < 0 || duration.seconds < 0.0 { + String::from("-P") + } else { + String::from("P") + }; + // https://www.w3.org/TR/xmlschema11-2/#f-duYMCan + let years = (duration.months / 12) as u64; + let months = (duration.months % 12) as u64; + + if years > 0 { + buf.push_str(&format!("{}Y", years)); + } + if months > 0 { + buf.push_str(&format!("{}M", months)); + } + // https://www.w3.org/TR/xmlschema11-2/#f-duDTCan + if duration.seconds == 0.0 { + return buf; + } + + let days = (duration.seconds.div_euclid(86400.0)) as u64; + let hours = ((duration.seconds % 86400.0).div_euclid(3600.0)) as u64; + let minutes = ((duration.seconds % 3600.0).div(60.0)) as u64; + let seconds = duration.seconds % 60.0; + + if duration.seconds != 0.0 { + if days > 0 { + buf.push_str(&format!("{}D", days)); + } + + if hours == 0 && minutes == 0 && seconds == 0.0 { + return buf; + } + + buf.push('T'); + + if hours > 0 { + buf.push_str(&format!("{}H", hours)); + } + if minutes > 0 { + buf.push_str(&format!("{}M", minutes)); + } + if seconds > 0.0 { + if seconds.fract() > 0.0 { + buf.push_str(&format!("{:.4}S", seconds)); + } else { + buf.push_str(&format!("{}S", seconds.trunc() as u64)); + } + } + } + + buf +} + +#[cfg(test)] +mod tests { + use super::*; + + const DAY: u64 = 86400; + + #[test] + fn construct() { + let _ = XsdDuration::new(12, 3600.0).unwrap(); + let _ = XsdDuration::new(-12, -3600.0).unwrap(); + assert!(XsdDuration::new(12, -3600.0).is_err()); + assert!(XsdDuration::new(-12, 3600.0).is_err()); + } + + #[test] + fn ser() { + let dur = XsdDuration::new(0, (3600 + 60 + 1) as f64).unwrap(); // 1 hour, 1 minute, 1 second + let s = format_duration(dur).unwrap(); + assert_eq!(s, "PT1H1M1S"); + + let dur = XsdDuration::new(0, 0.0).unwrap(); + let s = format_duration(dur).unwrap(); + assert_eq!(s, "P0D"); + + // This is totally flawed but the spec demands it + let dur = XsdDuration::new(13, (DAY + 3600 + 60 + 1) as f64).unwrap(); // 1 year, 1 month, 1 day, 1 hour, 1 minute, 1 second + let s = format_duration(dur).unwrap(); + assert_eq!(s, "P1Y1M1DT1H1M1S"); + + let dur = XsdDuration::new(13, (5 * DAY) as f64).unwrap(); // 1 year, 1 month, 5 days + let s = format_duration(dur).unwrap(); + assert_eq!(s, "P1Y1M5D"); + } + + #[test] + fn deser() { + let s = "PT1H1M1S"; + let dur: XsdDuration = s.parse().unwrap(); + assert_eq!(dur, XsdDuration::new(0, (3600 + 60 + 1) as f64).unwrap()); + + let s = "P0D"; + let dur: XsdDuration = s.parse().unwrap(); + assert_eq!(dur, XsdDuration::new(0, 0.0).unwrap()); + let s = "P1Y1M1DT1H1M1S"; + let dur: XsdDuration = s.parse().unwrap(); + assert_eq!( + dur, + XsdDuration::new(12 * 30, (30 * DAY + DAY + 3600 + 60 + 1) as f64).unwrap() + ); + + let s = "P1Y1M5DT0H0M0S"; + let dur: XsdDuration = s.parse().unwrap(); + assert_eq!( + dur, + XsdDuration::new(12 * 30, (30 * DAY + 5 * DAY) as f64).unwrap() + ); + } +} diff --git a/src/extensions/fee/mod.rs b/src/extensions/fee/mod.rs new file mode 100644 index 0000000..d6ae5f9 --- /dev/null +++ b/src/extensions/fee/mod.rs @@ -0,0 +1,1182 @@ +//! Types for EPP fee request and responses (version 1.0) +//! +//! As described in [Registry Fee Extension for the Extensible Provisioning Protocol](https://datatracker.ietf.org/doc/rfc8748/) + +use std::borrow::Cow; +use std::ops::Deref; + +use instant_xml::Id; +use instant_xml::{FromXml, ToXml}; + +use crate::domain::{ + check::DomainCheck, transfer::DomainTransfer, DomainCreate, DomainDelete, DomainRenew, + DomainUpdate, +}; +use crate::extensions::fee::duration::XsdDuration; +use crate::request::{Extension, Transaction}; + +// Todo: Should this be part of instant_xml? +mod duration; +pub use duration::format_duration; + +/// Type for EPP XML `` element +/// +/// Used in commands. +/// +/// Serializes to: +/// ```xml +/// +/// USD +/// +/// 2 +/// +/// +/// +/// +/// +/// ``` +#[derive(Debug, ToXml, Default)] +#[xml(rename = "check", ns(XMLNS))] +pub struct Check<'a> { + pub currency: Option, + #[xml(rename = "command")] + pub commands: Vec>, +} + +impl<'a> Check<'a> { + /// Create a new Fee Check request + /// + /// You can add commands using the `push` method. + /// + /// Example: + /// ```rust + /// use instant_epp::extensions::fee::{Check, Command, Currency}; + /// let fee_check = Check::new() + /// .push(Command::create()) + /// .push(Command::renew()).with_currency(Currency::Usd); + /// ``` + pub fn new() -> Self { + Self::default() + } + + pub fn push(mut self, command: impl Into>) -> Self { + self.commands.push(command.into()); + self + } + + pub fn with_currency(mut self, currency: Currency) -> Self { + self.currency = Some(currency); + self + } +} + +/// Type for EPP XML `` element +/// +/// Used in commands. +/// +/// Serializes to: +/// ```xml +/// +/// USD +/// create +/// +/// ``` +#[derive(Debug, ToXml)] +#[xml(rename = "create", ns(XMLNS))] +pub struct Create { + pub inner: TransformType, +} + +impl Create { + /// Create a new fee create request + /// + /// This uses the default currency of the account. To change the currency, use + /// [`Self::with_currency`]. + /// + /// # Note + /// Use the same fee obtained from the check command. + // Todo: Should we add a From<&CheckData> impl here? + pub fn new(fee: FeeType) -> Self { + Self { + inner: TransformType { + currency: Default::default(), + fees: vec![fee], + credits: Default::default(), + }, + } + } + + /// Set the currency for the fee create request + pub fn with_currency(mut self, currency: Currency) -> Self { + self.inner.currency = Some(currency); + self + } +} + +/// Type for EPP XML `` element +/// +/// Used in commands. +/// +/// Serializes to: +/// ```xml +/// +/// USD +/// 5.00 +/// +/// ``` +#[derive(Debug, ToXml)] +#[xml(rename = "renew", ns(XMLNS))] +pub struct Renew { + pub inner: TransformType, +} + +impl Renew { + /// Create a new fee renew request + /// + /// This uses the default currency of the account. To change the currency, use + /// [`Self::with_currency`]. + /// + /// # Note + /// Use the same fee obtained from the check command. + // Todo: Should we add a From<&CheckData> impl here? + pub fn new(fee: FeeType) -> Self { + Self { + inner: TransformType { + currency: Default::default(), + fees: vec![fee], + credits: Default::default(), + }, + } + } + + /// Set the currency for the fee renew request + pub fn with_currency(mut self, currency: Currency) -> Self { + self.inner.currency = Some(currency); + self + } +} + +/// Type for EPP XML `` element +/// +/// Used in commands. +/// +/// Serializes to: +/// ```xml +/// +/// USD +/// 5.00 +/// +/// ``` +#[derive(Debug, ToXml)] +#[xml(rename = "update", ns(XMLNS))] +pub struct Update { + pub inner: TransformType, +} + +impl Update { + /// Create a new fee update request + /// + /// This uses the default currency of the account. To change the currency, use + /// [`Self::with_currency`]. + /// + /// # Note + /// Use the same fee obtained from the check command. + // Todo: Should we add a From<&CheckData> impl here? + pub fn new(fee: FeeType) -> Self { + Self { + inner: TransformType { + currency: Default::default(), + fees: vec![fee], + credits: Default::default(), + }, + } + } + + /// Set the currency for the fee update request + pub fn with_currency(mut self, currency: Currency) -> Self { + self.inner.currency = Some(currency); + self + } +} + +/// Type for EPP XML `` element +/// +/// Used in commands. +/// +/// This extension adds elements to both the EPP command and +/// response, when the extension has been selected during a +/// command. +/// +/// TransferRequest serializes to: +/// ```xml +/// +/// USD +/// 5.00 +/// +/// ``` +#[derive(Debug, ToXml)] +#[xml(forward)] +pub enum Transfer { + // This extension does not add any elements to the EPP query + // command, but does include elements in the response, when the + // extension has been selected during a command. + Query(TransferQuery), + // This extension adds elements to both the EPP command and + // response, when the value of the "op" attribute of the + // command element is "request", and the extension has been selected + // during the command. + Request(TransferRequest), +} + +impl Transfer { + /// Create a new fee transfer query + pub fn query() -> Self { + Self::Query(TransferQuery) + } + + /// Create a new fee transfer request + /// + /// # Note + /// Use the same fee obtained from the check command. + pub fn request(fee: FeeType) -> Self { + Self::Request(TransferRequest { + inner: TransformType { + currency: Default::default(), + fees: vec![fee], + credits: Default::default(), + }, + }) + } +} + +/// Type for EPP XML `` element in query op +/// +/// Used in commands. +/// +/// Does not add any elements to the request. +#[derive(Debug)] +pub struct TransferQuery; + +// TODO: Find solution to avoid these. +// This is needed to avoid writing an empty tag into +// instant-epp currently requires ToXml impls for all command types +// and we need a way to make infer the response type added by the extension +// for this command. +// This will still add the tag currently, but it will be empty. +// Some EPP servers are not happy about en empty tag though. +impl ToXml for TransferQuery { + fn serialize( + &self, + _field: Option>, + _serializer: &mut instant_xml::Serializer, + ) -> Result<(), instant_xml::Error> { + Ok(()) + } +} + +/// Type for EPP XML `` element in request op +/// +/// Used in commands. +#[derive(Debug, ToXml)] +#[xml(rename = "transfer", ns(XMLNS))] +pub struct TransferRequest { + pub inner: TransformType, +} + +/// Inner type for general transform commands +/// +/// general transform (create, renew, update, transfer) command +/// See fee:transformCommandType in the spec. +#[derive(Debug, ToXml)] +#[xml(transparent)] +pub struct TransformType { + pub currency: Option, + #[xml(rename = "fee")] + pub fees: Vec, + #[xml(rename = "credit")] + pub credits: Vec, +} + +/// Type for EPP XML `` element +/// +/// Used in commands. +#[derive(Debug)] +pub struct Delete; + +// TODO: Find solution to avoid these. +// This is needed to avoid writing an empty tag into +// instant-epp currently requires ToXml impls for all command types +// and we need a way to make infer the response type added by the extension +// for this command. +// This will still add the tag currently, but it will be empty. +// Some EPP servers are not happy about en empty tag though. +impl ToXml for Delete { + fn serialize( + &self, + _field: Option>, + _serializer: &mut instant_xml::Serializer, + ) -> Result<(), instant_xml::Error> { + Ok(()) + } +} + +/// Type for EPP XML `` tag implements fee:commandType. +#[derive(Debug, FromXml, ToXml)] +#[xml(rename = "command", ns(XMLNS))] +pub struct Command<'a> { + #[xml(attribute)] + phase: Option>, + #[xml(attribute)] + subphase: Option>, + #[xml(attribute, rename = "customName")] + custom_name: Option>, + #[xml(attribute)] + name: CommandEnum, + #[xml(direct, rename = "period")] + period: Option, +} + +impl<'a> Command<'a> { + /// Create a `` for domain creation + pub fn create() -> Self { + Command { + name: CommandEnum::Create, + phase: None, + subphase: None, + custom_name: None, + period: None, + } + } + + /// Create a `` for domain renewal + pub fn renew() -> Self { + Command { + name: CommandEnum::Renew, + phase: None, + subphase: None, + custom_name: None, + period: None, + } + } + + /// Create a `` for domain transfer + pub fn transfer() -> Self { + Command { + name: CommandEnum::Transfer, + phase: None, + subphase: None, + custom_name: None, + period: None, + } + } + + /// Create a `` for domain restoration + pub fn restore() -> Self { + Command { + name: CommandEnum::Restore, + phase: None, + subphase: None, + custom_name: None, + period: None, + } + } + + pub fn with_phase(mut self, phase: impl Into>) -> Self { + self.phase = Some(phase.into()); + self + } + + pub fn with_subphase(mut self, subphase: impl Into>) -> Self { + self.subphase = Some(subphase.into()); + self + } + + pub fn with_custom_name(mut self, custom_name: impl Into>) -> Self { + self.custom_name = Some(custom_name.into()); + self + } + + pub fn with_period(mut self, period: impl Into) -> Self { + self.period = Some(period.into()); + self + } + + pub fn phase(&self) -> Option<&str> { + self.phase.as_deref() + } + + pub fn subphase(&self) -> Option<&str> { + self.subphase.as_deref() + } + + pub fn command(&self) -> CommandEnum { + self.name + } +} + +/// Type for EPP XML `` extension response +#[derive(Debug, FromXml)] +#[xml(rename = "chkData", ns(XMLNS))] +pub struct CheckData { + pub currency: Currency, + #[xml(rename = "cd")] + pub data: Vec, +} + +/// Type for EPP XML `` tag implements fee:objectCDType +#[derive(Debug, FromXml)] +#[xml(rename = "cd", ns(XMLNS))] +pub struct ObjectCDType { + /// Defaults to true. + /// + /// If "avail" is false, then the `` or the `` element MUST contain a + /// `` element (as described in Section 3.9), and the server MAY eliminate some + /// or all of the `` element(s). + // Todo: Make this non-optional with default true, once instant-xml supports default values. + #[xml(attribute)] + pub avail: Option, + /// The object identifier, e.g domain references in the command + #[xml(rename = "objID")] + pub obj_id: String, + pub class: Option, + pub command: Vec, + pub reason: Option, +} + +/// Type for EPP XML `` tag implements fee:reasonType. +/// +/// Provides server-specific text in an effort to better explain why +/// a command did not complete as the client expected. +#[derive(Debug, Clone, FromXml)] +#[xml(rename = "reason", ns(XMLNS))] +pub struct ReasonType { + #[xml(attribute)] + pub lang: Option, + #[xml(direct)] + pub description: String, +} + +/// Type for EPP XML `` extension response +#[derive(Debug, FromXml)] +#[xml(rename = "creData", ns(XMLNS))] +pub struct CreateData { + pub inner: TransformResultType, +} + +impl Deref for CreateData { + type Target = TransformResultType; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +/// Type for EPP XML `` extension response +#[derive(Debug, FromXml)] +#[xml(rename = "renData", ns(XMLNS))] +pub struct RenewData { + pub inner: TransformResultType, +} + +impl Deref for RenewData { + type Target = TransformResultType; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +/// Type for EPP XML `` extension response +#[derive(Debug, FromXml)] +#[xml(rename = "updData", ns(XMLNS))] +pub struct UpdateData { + pub inner: TransformResultType, +} + +impl Deref for UpdateData { + type Target = TransformResultType; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +/// Type for EPP XML `` extension response +#[derive(Debug, FromXml)] +#[xml(rename = "trnData", ns(XMLNS))] +pub struct TransferData { + pub inner: TransformResultType, +} + +impl Deref for TransferData { + type Target = TransformResultType; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +/// Type for EPP XML `` extension response +#[derive(Debug, FromXml)] +#[xml(rename = "delData", ns(XMLNS))] +pub struct DeleteData { + pub inner: TransformResultType, +} + +impl Deref for DeleteData { + type Target = TransformResultType; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +/// Inner type for transform responses fee:transformResultType +/// +/// general transform (create, renew, update) result +#[derive(Debug)] +pub struct TransformResultType { + pub currency: Option, + pub period: Option, + pub fees: Vec, + pub credit: Vec, + pub balance: Option, + pub credit_limit: Option, +} + +// We need to implement FromXml manually here because FromXml currently contains bugs. +// See https://github.com/djc/instant-xml/issues/113 +// Even when used with named fields this the derive macro results in +// `Xml(DuplicateValue("TransformResultType::currency"))`` +impl<'xml> FromXml<'xml> for TransformResultType { + #[inline] + fn matches(id: Id<'_>, _: Option>) -> bool { + as FromXml<'xml>>::matches(id, None) + || as FromXml<'xml>>::matches(id, None) + || as FromXml<'xml>>::matches( + id, + Some(Id { + ns: XMLNS, + name: "fee", + }), + ) + || as FromXml<'xml>>::matches( + id, + Some(Id { + ns: XMLNS, + name: "credit", + }), + ) + || as FromXml<'xml>>::matches(id, None) + || as FromXml<'xml>>::matches(id, None) + } + + fn deserialize<'cx>( + into: &mut Self::Accumulator, + _: &'static str, + deserializer: &mut instant_xml::Deserializer<'cx, 'xml>, + ) -> Result<(), instant_xml::Error> { + let current = deserializer.parent(); + // mode scalar will emit code that matches on the field, but the default transparent does not forward the field. + const CURRENCY_TAG: Id = Id { + ns: XMLNS, + name: "currency", + }; + if as FromXml<'xml>>::matches(current, Some(CURRENCY_TAG)) { + as FromXml>::deserialize( + &mut into.currency, + "Transform::currency", + deserializer, + )?; + deserializer.ignore()?; + } else if as FromXml<'xml>>::matches(current, None) { + as FromXml>::deserialize( + &mut into.period, + "Transform::period", + deserializer, + )?; + } else if as FromXml<'xml>>::matches( + current, + Some(Id { + ns: XMLNS, + name: "fee", + }), + ) { + as FromXml>::deserialize( + &mut into.fees, + "Transform::fees", + deserializer, + )?; + } else if as FromXml<'xml>>::matches(current, None) { + as FromXml>::deserialize( + &mut into.balance, + "Transform::balance", + deserializer, + )?; + } else if as FromXml<'xml>>::matches(current, None) { + as FromXml>::deserialize( + &mut into.credit, + "Transform::credit", + deserializer, + )?; + } else if as FromXml<'xml>>::matches(current, None) { + as FromXml>::deserialize( + &mut into.credit_limit, + "Transform::credit_limit", + deserializer, + )?; + } + Ok(()) + } + + type Accumulator = TransformAccumulator<'xml>; + const KIND: instant_xml::Kind = instant_xml::Kind::Element; +} + +#[derive(Default)] +pub struct TransformAccumulator<'xml> { + currency: as FromXml<'xml>>::Accumulator, + fees: as FromXml<'xml>>::Accumulator, + period: as FromXml<'xml>>::Accumulator, + credit: as FromXml<'xml>>::Accumulator, + balance: as FromXml<'xml>>::Accumulator, + credit_limit: as FromXml<'xml>>::Accumulator, +} + +impl<'xml> instant_xml::Accumulate for TransformAccumulator<'xml> { + fn try_done(self, _: &'static str) -> Result { + Ok(TransformResultType { + currency: self.currency.try_done("Transform::currency")?, + fees: self.fees.try_done("Transform::fees")?, + period: self.period.try_done("Transform::period")?, + credit: self.credit.try_done("Transform::credit")?, + balance: self.balance.try_done("Transform::balance")?, + credit_limit: self.credit_limit.try_done("Transform::credit_limit")?, + }) + } +} + +/// Type for EPP XML `` tag implements fee:commandDataType. +#[derive(Debug, Clone, FromXml)] +#[xml(rename = "command", ns(XMLNS))] +pub struct CommandDataType { + #[xml(attribute)] + pub phase: Option, + #[xml(attribute)] + pub subphase: Option, + #[xml(attribute, rename = "customName")] + pub custom_name: Option, + #[xml(attribute)] + pub name: CommandEnum, + /// This should default to false if not present + #[xml(attribute)] + pub standard: Option, + #[xml(rename = "period")] + pub period: Option, + #[xml(rename = "fee")] + pub fees: Vec, + #[xml(rename = "credit")] + pub credits: Vec, + pub reason: Option, +} + +/// Type for EPP XML `` tag +/// +/// Used in , , , and responses +#[derive(Debug, FromXml)] +#[xml(rename = "balance", ns(XMLNS))] +pub struct Balance { + #[xml(direct)] + pub amount: f64, +} + +/// Type for EPP XML `` tag +/// +/// Used in , , , and responses +#[derive(Debug, FromXml)] +#[xml(rename = "creditLimit", ns(XMLNS))] +pub struct CreditLimit { + #[xml(direct)] + pub amount: f64, +} + +/// Type for EPP XML `` tag implements fee:creditType +#[derive(Debug, Clone, FromXml, ToXml)] +#[xml(rename = "credit", ns(XMLNS))] +pub struct Credit { + #[xml(attribute)] + pub description: Option, + #[xml(direct)] + pub amount: f64, +} + +/// Type for EPP XML `` tag implementing type fee:feeType +#[derive(Debug, Clone, PartialEq, FromXml)] +#[xml(rename = "fee", ns(XMLNS))] +pub struct FeeType { + #[xml(attribute)] + pub description: Option, + #[xml(attribute)] + pub refundable: Option, + #[xml(attribute, rename = "grace-period")] + pub grace_period: Option, + #[xml(attribute)] + pub applied: Option, + #[xml(direct)] + pub amount: f64, +} + +// We need a custom ToXml to emit the decimal amount correctly +// There seems to be an incompatibility with `serialize_with` and `direct` +// We need direct for the FromXml derive to work correctly, but you cannot +// combine this with `serialize_with`. +impl ToXml for FeeType { + fn serialize( + &self, + _field: Option>, + serializer: &mut instant_xml::Serializer, + ) -> Result<(), instant_xml::Error> { + let prefix = serializer.write_start("fee", XMLNS)?; + let new = instant_xml::ser::Context::<0usize> { + default_ns: XMLNS, + ..Default::default() + }; + + let old = serializer.push(new)?; + if self.description.present() { + serializer.write_attr("description", XMLNS, &self.description)?; + } + if self.refundable.present() { + serializer.write_attr("refundable", XMLNS, &self.refundable)?; + } + if self.grace_period.present() { + serializer.write_attr("grace-period", XMLNS, &self.grace_period)?; + } + if self.applied.present() { + serializer.write_attr("applied", XMLNS, &self.applied)?; + } + serializer.end_start()?; + // decimal type requires at least one digit after the decimal point, we use two, as this is a currency. + // Todo, this should use a proper decimal type. + let amount_str = format!("{:.2}", self.amount); + serializer.write_str(&amount_str)?; + serializer.write_close(prefix, "fee")?; + serializer.pop(old); + Ok(()) + } +} + +/// Scalar enum for fee:applied +#[derive(Debug, Clone, Copy, Default, PartialEq, FromXml, ToXml)] +#[xml(scalar, rename_all = "lowercase")] +pub enum Applied { + #[default] + Immediate, + Delayed, +} + +/// Scalar enum for fee:command +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromXml, ToXml)] +#[xml(scalar, rename_all = "lowercase")] +pub enum CommandEnum { + Create, + Renew, + Transfer, + Restore, +} + +/// Scalar enum for fee:currency +#[derive(Debug, Clone, Copy, PartialEq, FromXml, ToXml)] +#[xml(scalar, rename = "currency", rename_all = "UPPERCASE", ns(XMLNS))] +pub enum Currency { + Usd, + Eur, + Gbp, +} + +/// Type for EPP XML `` tag +#[derive(Debug, Clone, FromXml, ToXml)] +#[xml(rename = "period", ns(XMLNS))] +pub struct PeriodType { + #[xml(attribute)] + unit: String, + #[xml(direct)] + value: u32, +} + +impl PeriodType { + /// Create a PeriodType in years + pub fn years(value: u32) -> Self { + Self { + unit: "y".to_string(), + value, + } + } +} + +impl From for PeriodType { + /// Convert u32 to PeriodType in years + fn from(value: u32) -> Self { + Self::years(value) + } +} + +impl Transaction> for DomainCheck<'_> {} +impl Transaction for DomainUpdate<'_> {} +impl Transaction for DomainCreate<'_> {} +impl Transaction for DomainRenew<'_> {} +impl Transaction for DomainDelete<'_> {} +impl Transaction for DomainTransfer<'_> {} + +impl Extension for Check<'_> { + type Response = CheckData; +} + +impl Extension for Transfer { + type Response = TransferData; +} + +impl Extension for Create { + type Response = CreateData; +} + +impl Extension for Update { + type Response = UpdateData; +} + +impl Extension for Renew { + type Response = RenewData; +} + +impl Extension for Delete { + type Response = DeleteData; +} + +pub const XMLNS: &str = "urn:ietf:params:xml:ns:epp:fee-1.0"; + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use chrono::NaiveDate; + + use crate::domain::transfer::DomainTransfer; + use crate::domain::update::DomainChangeInfo; + use crate::domain::{DomainContact, HostInfo, HostObj, Period, PeriodLength}; + use crate::tests::{assert_serialized, response_from_file_with_ext}; + + use super::*; + + #[test] + fn request_check() { + assert_serialized( + "request/extensions/fee/check.xml", + ( + &DomainCheck { + domains: &["example.com", "example.net", "example.xyz"], + }, + &Check::new() + .with_currency(Currency::Usd) + .push(Command::create().with_period(2)) + .push(Command::renew()) + .push(Command::transfer()) + .push(Command::restore()), + ), + ); + } + + #[test] + fn request_create() { + let ns = vec![ + HostInfo::Obj(HostObj { + name: "ns1.example.net".into(), + }), + HostInfo::Obj(HostObj { + name: "ns2.example.net".into(), + }), + ]; + + let contacts = vec![ + DomainContact { + contact_type: "admin".into(), + id: "sh8013".into(), + }, + DomainContact { + contact_type: "tech".into(), + id: "sh8013".into(), + }, + ]; + + let create = DomainCreate::new( + "example.com", + Period::Years(PeriodLength::new(2).unwrap()), + Some(&ns), + Some("jd1234"), + "2fooBAR", + Some(&contacts), + ); + + assert_serialized( + "request/extensions/fee/create.xml", + ( + &create, + &Create::new(FeeType { + description: None, + refundable: None, + grace_period: None, + applied: None, + amount: 5.00, + }) + .with_currency(Currency::Usd), + ), + ); + } + + #[test] + fn request_delete() { + assert_serialized( + "request/extensions/fee/delete.xml", + (&DomainDelete::new("example.com"), &Delete), + ); + } + + #[test] + fn request_renew() { + let renew = DomainRenew::new( + "example.com", + NaiveDate::from_ymd_opt(2000, 4, 3).unwrap(), + Period::Years(PeriodLength::new(5).unwrap()), + ); + + assert_serialized( + "request/extensions/fee/renew.xml", + ( + &renew, + &Renew { + inner: TransformType { + currency: Some(Currency::Usd), + fees: vec![FeeType { + description: None, + refundable: None, + grace_period: None, + applied: None, + amount: 5.00, + }], + credits: vec![], + }, + }, + ), + ); + } + + #[test] + fn request_transfer_request() { + let transfer = DomainTransfer::new( + "example.com", + Some(Period::Years(PeriodLength::new(1).unwrap())), + "2fooBAR", + ); + + assert_serialized( + "request/extensions/fee/transfer_request.xml", + ( + &transfer, + &Transfer::Request(TransferRequest { + inner: TransformType { + currency: Some(Currency::Usd), + fees: vec![FeeType { + description: None, + refundable: None, + grace_period: None, + applied: None, + amount: 5.00, + }], + credits: vec![], + }, + }), + ), + ); + } + + #[test] + fn request_update() { + let chg = DomainChangeInfo { + registrant: Some("sh8013"), + auth_info: None, + }; + + let mut update = DomainUpdate::new("example.com"); + update.info(chg); + + assert_serialized( + "request/extensions/fee/update.xml", + ( + &update, + &Update { + inner: TransformType { + currency: Some(Currency::Usd), + fees: vec![FeeType { + description: None, + refundable: None, + grace_period: None, + applied: None, + amount: 5.00, + }], + credits: vec![], + }, + }, + ), + ); + } + + #[test] + fn response_check() { + let object = + response_from_file_with_ext::("response/extensions/fee/check.xml"); + let ext = object.extension.unwrap().data; + + assert_eq!(ext.currency, Currency::Usd); + + let results = ext + .data + .into_iter() + .map(|entry| { + let command_map = entry + .command + .iter() + .cloned() + .map(|cmd| (cmd.name, cmd)) + .collect::>(); + + (entry.obj_id.clone(), (entry, command_map)) + }) + .collect::>(); + + let cd = results.get("example.com").unwrap(); + + assert!(cd.0.avail.unwrap()); + assert_eq!(cd.0.class.as_ref().unwrap(), "Premium"); + let command = cd.1.get(&CommandEnum::Create).unwrap(); + assert_eq!(command.period.as_ref().unwrap().value, 2); + assert_eq!(command.period.as_ref().unwrap().unit, "y"); + assert_eq!(command.fees.len(), 1); + assert_eq!( + command.fees, + vec![FeeType { + grace_period: Some(XsdDuration::new(0, (5 * 24 * 60 * 60) as f64).unwrap()), // 5 days + applied: None, + refundable: Some(true), + description: Some("Registration Fee".to_string()), + amount: 10.00 + },] + ); + let command = cd.1.get(&CommandEnum::Renew).unwrap(); + + assert_eq!(command.period.as_ref().unwrap().value, 1); + assert_eq!(command.period.as_ref().unwrap().unit, "y"); + assert_eq!(command.fees.len(), 1); + assert_eq!( + command.fees, + vec![FeeType { + grace_period: Some(XsdDuration::new(0, (5 * 24 * 60 * 60) as f64).unwrap()), // 5 days + applied: None, + refundable: Some(true), + description: Some("Renewal Fee".to_string()), + amount: 10.00 + },] + ); + + let cd = results.get("example.xyz").unwrap(); + assert!(!cd.0.avail.unwrap()); + let command = cd.1.get(&CommandEnum::Create).unwrap(); + assert_eq!(command.period.as_ref().unwrap().value, 2); + assert_eq!(command.period.as_ref().unwrap().unit, "y"); + assert_eq!( + command + .reason + .as_ref() + .expect("expected reason") + .description, + "Only 1 year registration periods are valid." + ); + } + + #[test] + fn response_create() { + let object = response_from_file_with_ext::( + "response/extensions/fee/create.xml", + ); + let ext = object.extension().unwrap(); + assert_eq!(ext.currency, Some(Currency::Usd)); + assert_eq!(ext.fees[0].amount, 5.00); + assert_eq!( + ext.fees[0].grace_period, + Some(XsdDuration::new(0, (5 * 24 * 60 * 60) as f64).unwrap()) // 5 days + ); + assert_eq!(ext.balance.as_ref().unwrap().amount, -5.00); + assert_eq!(ext.credit_limit.as_ref().unwrap().amount, 1000.00); + } + + #[test] + fn response_renew() { + let object = + response_from_file_with_ext::("response/extensions/fee/renew.xml"); + let ext = object.extension().unwrap(); + assert_eq!(ext.inner.currency, Some(Currency::Usd)); + assert_eq!(ext.inner.fees[0].amount, 5.00); + assert_eq!(ext.inner.balance.as_ref().unwrap().amount, 1000.00); + } + + #[test] + fn response_delete() { + let object = response_from_file_with_ext::( + "response/extensions/fee/delete.xml", + ); + + let ext = object.extension().unwrap(); + assert_eq!(ext.inner.currency, Some(Currency::Usd)); + assert_eq!(ext.inner.credit[0].amount, -5.00); + assert_eq!( + ext.inner.credit[0].description.as_ref().unwrap(), + "AGP Credit" + ); + assert_eq!(ext.inner.balance.as_ref().unwrap().amount, 1005.00); + } + + #[test] + fn response_transfer_query() { + let object = response_from_file_with_ext::( + "response/extensions/fee/transfer_query.xml", + ); + let ext = object.extension().unwrap(); + assert_eq!(ext.currency, Some(Currency::Usd)); + assert_eq!(ext.period.as_ref().unwrap().value, 1); + assert_eq!(ext.fees[0].amount, 5.00); + } + + #[test] + fn response_transfer_request() { + // Same structure as query but different values potentially + let object = response_from_file_with_ext::( + "response/extensions/fee/transfer_request.xml", + ); + let ext = object.extension().unwrap(); + assert_eq!(ext.currency, Some(Currency::Usd)); + assert!(ext.period.is_none()); + assert_eq!(ext.fees[0].amount, 5.00); + assert_eq!( + ext.fees[0].grace_period, + Some(XsdDuration::new(0, (5 * 24 * 60 * 60) as f64).unwrap()) //5 days + ); + } + + #[test] + fn response_update() { + let object = response_from_file_with_ext::( + "response/extensions/fee/update.xml", + ); + let ext = object.extension().unwrap(); + assert_eq!(ext.currency, Some(Currency::Usd)); + assert_eq!(ext.fees[0].amount, 5.00); + } +} diff --git a/src/lib.rs b/src/lib.rs index a638503..ee91c00 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,6 +53,7 @@ pub mod xml; pub mod extensions { pub mod change_poll; pub mod consolidate; + pub mod fee; pub mod frnic; pub mod low_balance; pub mod namestore; diff --git a/tests/resources/request/extensions/fee/check.xml b/tests/resources/request/extensions/fee/check.xml new file mode 100644 index 0000000..63c9737 --- /dev/null +++ b/tests/resources/request/extensions/fee/check.xml @@ -0,0 +1,24 @@ + + + + + + example.com + example.net + example.xyz + + + + + USD + + 2 + + + + + + + cltrid:1626454866 + + \ No newline at end of file diff --git a/tests/resources/request/extensions/fee/create.xml b/tests/resources/request/extensions/fee/create.xml new file mode 100644 index 0000000..326eb4b --- /dev/null +++ b/tests/resources/request/extensions/fee/create.xml @@ -0,0 +1,28 @@ + + + + + + example.com + 2 + + ns1.example.net + ns2.example.net + + jd1234 + sh8013 + sh8013 + + 2fooBAR + + + + + + USD + 5.00 + + + cltrid:1626454866 + + \ No newline at end of file diff --git a/tests/resources/request/extensions/fee/delete.xml b/tests/resources/request/extensions/fee/delete.xml new file mode 100644 index 0000000..b9701ad --- /dev/null +++ b/tests/resources/request/extensions/fee/delete.xml @@ -0,0 +1,12 @@ + + + + + + example.com + + + + cltrid:1626454866 + + \ No newline at end of file diff --git a/tests/resources/request/extensions/fee/renew.xml b/tests/resources/request/extensions/fee/renew.xml new file mode 100644 index 0000000..446f2b3 --- /dev/null +++ b/tests/resources/request/extensions/fee/renew.xml @@ -0,0 +1,19 @@ + + + + + + example.com + 2000-04-03 + 5 + + + + + USD + 5.00 + + + cltrid:1626454866 + + \ No newline at end of file diff --git a/tests/resources/request/extensions/fee/transfer_query.xml b/tests/resources/request/extensions/fee/transfer_query.xml new file mode 100644 index 0000000..abf0d7f --- /dev/null +++ b/tests/resources/request/extensions/fee/transfer_query.xml @@ -0,0 +1,15 @@ + + + + + + example.com + + 2fooBAR + + + + + cltrid:1626454866 + + \ No newline at end of file diff --git a/tests/resources/request/extensions/fee/transfer_request.xml b/tests/resources/request/extensions/fee/transfer_request.xml new file mode 100644 index 0000000..e8c9292 --- /dev/null +++ b/tests/resources/request/extensions/fee/transfer_request.xml @@ -0,0 +1,21 @@ + + + + + + example.com + 1 + + 2fooBAR + + + + + + USD + 5.00 + + + cltrid:1626454866 + + \ No newline at end of file diff --git a/tests/resources/request/extensions/fee/update.xml b/tests/resources/request/extensions/fee/update.xml new file mode 100644 index 0000000..9ae645b --- /dev/null +++ b/tests/resources/request/extensions/fee/update.xml @@ -0,0 +1,20 @@ + + + + + + example.com + + sh8013 + + + + + + USD + 5.00 + + + cltrid:1626454866 + + \ No newline at end of file diff --git a/tests/resources/response/extensions/fee/check.xml b/tests/resources/response/extensions/fee/check.xml new file mode 100644 index 0000000..bda0518 --- /dev/null +++ b/tests/resources/response/extensions/fee/check.xml @@ -0,0 +1,75 @@ + + + + + Command completed successfully + + + + + example.com + + + example.net + + + example.xyz + + + + + + USD + + example.com + Premium + + 2 + 10.00 + + + 1 + 10.00 + + + 1 + 10.00 + + + 15.00 + + + + example.net + standard + + 2 + 5.00 + + + 1 + 5.00 + + + 1 + 5.00 + + + 5.00 + + + + example.xyz + + 2 + Only 1 year registration periods are valid. + + + + + + ABC-12345 + 54322-XYZ + + + \ No newline at end of file diff --git a/tests/resources/response/extensions/fee/create.xml b/tests/resources/response/extensions/fee/create.xml new file mode 100644 index 0000000..a1d4b36 --- /dev/null +++ b/tests/resources/response/extensions/fee/create.xml @@ -0,0 +1,27 @@ + + + + + Command completed successfully + + + + example.com + 1999-04-03T22:00:00.0Z + 2001-04-03T22:00:00.0Z + + + + + USD + 5.00 + -5.00 + 1000.00 + + + + ABC-12345 + 54321-XYZ + + + diff --git a/tests/resources/response/extensions/fee/delete.xml b/tests/resources/response/extensions/fee/delete.xml new file mode 100644 index 0000000..f934167 --- /dev/null +++ b/tests/resources/response/extensions/fee/delete.xml @@ -0,0 +1,19 @@ + + + + + Command completed successfully + + + + USD + -5.00 + 1005.00 + + + + ABC-12345 + 54321-XYZ + + + diff --git a/tests/resources/response/extensions/fee/renew.xml b/tests/resources/response/extensions/fee/renew.xml new file mode 100644 index 0000000..ea42bfb --- /dev/null +++ b/tests/resources/response/extensions/fee/renew.xml @@ -0,0 +1,25 @@ + + + + + Command completed successfully + + + + example.com + 2005-04-03T22:00:00.0Z + + + + + USD + 5.00 + 1000.00 + + + + ABC-12345 + 54322-XYZ + + + \ No newline at end of file diff --git a/tests/resources/response/extensions/fee/transfer_query.xml b/tests/resources/response/extensions/fee/transfer_query.xml new file mode 100644 index 0000000..edd22fc --- /dev/null +++ b/tests/resources/response/extensions/fee/transfer_query.xml @@ -0,0 +1,30 @@ + + + + + Command completed successfully; action pending + + + + example.com + pending + ClientX + 2000-06-08T22:00:00.0Z + ClientY + 2000-06-13T22:00:00.0Z + 2002-09-08T22:00:00.0Z + + + + + USD + 1 + 5.00 + + + + ABC-12345 + 54322-XYZ + + + \ No newline at end of file diff --git a/tests/resources/response/extensions/fee/transfer_request.xml b/tests/resources/response/extensions/fee/transfer_request.xml new file mode 100644 index 0000000..919d345 --- /dev/null +++ b/tests/resources/response/extensions/fee/transfer_request.xml @@ -0,0 +1,29 @@ + + + + + Command completed successfully; action pending + + + + example.com + pending + ClientX + 2000-06-08T22:00:00.0Z + ClientY + 2000-06-13T22:00:00.0Z + 2002-09-08T22:00:00.0Z + + + + + USD + 5.00 + + + + ABC-12345 + 54322-XYZ + + + diff --git a/tests/resources/response/extensions/fee/update.xml b/tests/resources/response/extensions/fee/update.xml new file mode 100644 index 0000000..0540511 --- /dev/null +++ b/tests/resources/response/extensions/fee/update.xml @@ -0,0 +1,18 @@ + + + + + Command completed successfully + + + + USD + 5.00 + + + + ABC-12345 + 54321-XYZ + + + \ No newline at end of file From 80c75be37272c3ca429a3cfa17f14a48ed2c8fc9 Mon Sep 17 00:00:00 2001 From: Rudi Floren Date: Mon, 16 Feb 2026 18:52:35 +0100 Subject: [PATCH 2/5] add QoL getter for fee PeriodType --- src/extensions/fee/mod.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/extensions/fee/mod.rs b/src/extensions/fee/mod.rs index d6ae5f9..fd0c7f8 100644 --- a/src/extensions/fee/mod.rs +++ b/src/extensions/fee/mod.rs @@ -812,6 +812,24 @@ impl PeriodType { value, } } + + /// Create a PeriodType in months + pub fn months(value: u32) -> Self { + Self { + unit: "m".to_string(), + value, + } + } + + /// Get the unit of the period + pub fn unit(&self) -> &str { + &self.unit + } + + /// Get the value of the period + pub fn value(&self) -> u32 { + self.value + } } impl From for PeriodType { From 9f48b3153959d594417ed0376c9eba6fe5581079 Mon Sep 17 00:00:00 2001 From: Rudi Floren Date: Mon, 30 Mar 2026 15:15:28 +0200 Subject: [PATCH 3/5] add proper decimal type --- Cargo.toml | 2 + src/extensions/fee/mod.rs | 171 ++++++++++++++++++++++++-------------- 2 files changed, 112 insertions(+), 61 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3b4806d..a8b6c42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ async-trait = "0.1.52" celes = "2.1" chrono = { version = "0.4.23", features = ["serde"] } instant-xml = { version = "0.6", features = ["chrono"] } +rust_decimal = "1" rustls-platform-verifier = { version = "0.6", optional = true } serde = { version = "1.0", features = ["derive"] } tokio = { version = "1.0", features = ["io-util", "net", "time"] } @@ -25,6 +26,7 @@ tokio-rustls = { version = "0.26", optional = true, default-features = false, fe tracing = "0.1.29" [dev-dependencies] +rust_decimal_macros = "1" regex = "1.5" tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] } tokio-test = "0.4" diff --git a/src/extensions/fee/mod.rs b/src/extensions/fee/mod.rs index fd0c7f8..ab7ddd7 100644 --- a/src/extensions/fee/mod.rs +++ b/src/extensions/fee/mod.rs @@ -3,10 +3,13 @@ //! As described in [Registry Fee Extension for the Extensible Provisioning Protocol](https://datatracker.ietf.org/doc/rfc8748/) use std::borrow::Cow; +use std::fmt; use std::ops::Deref; +use std::str::FromStr; use instant_xml::Id; use instant_xml::{FromXml, ToXml}; +use rust_decimal::Decimal; use crate::domain::{ check::DomainCheck, transfer::DomainTransfer, DomainCreate, DomainDelete, DomainRenew, @@ -680,6 +683,81 @@ pub struct CommandDataType { pub reason: Option, } +/// Wrapper around [`rust_decimal::Decimal`] for monetary amounts in EPP fee +/// extension XML. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Amount(pub Decimal); + +impl Amount { + pub fn new(value: Decimal) -> Self { + Self(value) + } +} + +impl From for Amount { + fn from(value: Decimal) -> Self { + Self(value) + } +} + +impl Deref for Amount { + type Target = Decimal; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for Amount { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl<'xml> FromXml<'xml> for Amount { + fn matches(id: Id<'_>, field: Option>) -> bool { + match field { + Some(field) => id == field, + None => false, + } + } + + fn deserialize<'cx>( + into: &mut Self::Accumulator, + field: &'static str, + deserializer: &mut instant_xml::Deserializer<'cx, 'xml>, + ) -> Result<(), instant_xml::Error> { + if into.is_some() { + return Err(instant_xml::Error::DuplicateValue(field)); + } + + if let Some(value) = deserializer.take_str()? { + let decimal = Decimal::from_str(value.as_ref()).map_err(|_| { + instant_xml::Error::UnexpectedValue(format!( + "unable to parse decimal from `{value}` for {field}" + )) + })?; + *into = Some(Amount(decimal)); + } + + Ok(()) + } + + type Accumulator = Option; + const KIND: instant_xml::Kind = instant_xml::Kind::Scalar; +} + +impl ToXml for Amount { + fn serialize( + &self, + _field: Option>, + serializer: &mut instant_xml::Serializer, + ) -> Result<(), instant_xml::Error> { + serializer.write_str(&self.0.round_dp(2).to_string())?; + Ok(()) + } +} + /// Type for EPP XML `` tag /// /// Used in , , , and responses @@ -687,7 +765,7 @@ pub struct CommandDataType { #[xml(rename = "balance", ns(XMLNS))] pub struct Balance { #[xml(direct)] - pub amount: f64, + pub amount: Amount, } /// Type for EPP XML `` tag @@ -697,7 +775,7 @@ pub struct Balance { #[xml(rename = "creditLimit", ns(XMLNS))] pub struct CreditLimit { #[xml(direct)] - pub amount: f64, + pub amount: Amount, } /// Type for EPP XML `` tag implements fee:creditType @@ -707,11 +785,11 @@ pub struct Credit { #[xml(attribute)] pub description: Option, #[xml(direct)] - pub amount: f64, + pub amount: Amount, } /// Type for EPP XML `` tag implementing type fee:feeType -#[derive(Debug, Clone, PartialEq, FromXml)] +#[derive(Debug, Clone, PartialEq, FromXml, ToXml)] #[xml(rename = "fee", ns(XMLNS))] pub struct FeeType { #[xml(attribute)] @@ -723,47 +801,7 @@ pub struct FeeType { #[xml(attribute)] pub applied: Option, #[xml(direct)] - pub amount: f64, -} - -// We need a custom ToXml to emit the decimal amount correctly -// There seems to be an incompatibility with `serialize_with` and `direct` -// We need direct for the FromXml derive to work correctly, but you cannot -// combine this with `serialize_with`. -impl ToXml for FeeType { - fn serialize( - &self, - _field: Option>, - serializer: &mut instant_xml::Serializer, - ) -> Result<(), instant_xml::Error> { - let prefix = serializer.write_start("fee", XMLNS)?; - let new = instant_xml::ser::Context::<0usize> { - default_ns: XMLNS, - ..Default::default() - }; - - let old = serializer.push(new)?; - if self.description.present() { - serializer.write_attr("description", XMLNS, &self.description)?; - } - if self.refundable.present() { - serializer.write_attr("refundable", XMLNS, &self.refundable)?; - } - if self.grace_period.present() { - serializer.write_attr("grace-period", XMLNS, &self.grace_period)?; - } - if self.applied.present() { - serializer.write_attr("applied", XMLNS, &self.applied)?; - } - serializer.end_start()?; - // decimal type requires at least one digit after the decimal point, we use two, as this is a currency. - // Todo, this should use a proper decimal type. - let amount_str = format!("{:.2}", self.amount); - serializer.write_str(&amount_str)?; - serializer.write_close(prefix, "fee")?; - serializer.pop(old); - Ok(()) - } + pub amount: Amount, } /// Scalar enum for fee:applied @@ -881,6 +919,8 @@ mod tests { use crate::domain::transfer::DomainTransfer; use crate::domain::update::DomainChangeInfo; use crate::domain::{DomainContact, HostInfo, HostObj, Period, PeriodLength}; + use rust_decimal_macros::dec; + use crate::tests::{assert_serialized, response_from_file_with_ext}; use super::*; @@ -943,7 +983,7 @@ mod tests { refundable: None, grace_period: None, applied: None, - amount: 5.00, + amount: Amount(dec!(5.00)), }) .with_currency(Currency::Usd), ), @@ -978,7 +1018,7 @@ mod tests { refundable: None, grace_period: None, applied: None, - amount: 5.00, + amount: Amount(dec!(5.00)), }], credits: vec![], }, @@ -1007,7 +1047,7 @@ mod tests { refundable: None, grace_period: None, applied: None, - amount: 5.00, + amount: Amount(dec!(5.00)), }], credits: vec![], }, @@ -1038,7 +1078,7 @@ mod tests { refundable: None, grace_period: None, applied: None, - amount: 5.00, + amount: Amount(dec!(5.00)), }], credits: vec![], }, @@ -1085,7 +1125,7 @@ mod tests { applied: None, refundable: Some(true), description: Some("Registration Fee".to_string()), - amount: 10.00 + amount: Amount(dec!(10.00)) },] ); let command = cd.1.get(&CommandEnum::Renew).unwrap(); @@ -1100,7 +1140,7 @@ mod tests { applied: None, refundable: Some(true), description: Some("Renewal Fee".to_string()), - amount: 10.00 + amount: Amount(dec!(10.00)) },] ); @@ -1126,13 +1166,16 @@ mod tests { ); let ext = object.extension().unwrap(); assert_eq!(ext.currency, Some(Currency::Usd)); - assert_eq!(ext.fees[0].amount, 5.00); + assert_eq!(ext.fees[0].amount, Amount(dec!(5.00))); assert_eq!( ext.fees[0].grace_period, Some(XsdDuration::new(0, (5 * 24 * 60 * 60) as f64).unwrap()) // 5 days ); - assert_eq!(ext.balance.as_ref().unwrap().amount, -5.00); - assert_eq!(ext.credit_limit.as_ref().unwrap().amount, 1000.00); + assert_eq!(ext.balance.as_ref().unwrap().amount, Amount(dec!(-5.00))); + assert_eq!( + ext.credit_limit.as_ref().unwrap().amount, + Amount(dec!(1000.00)) + ); } #[test] @@ -1141,8 +1184,11 @@ mod tests { response_from_file_with_ext::("response/extensions/fee/renew.xml"); let ext = object.extension().unwrap(); assert_eq!(ext.inner.currency, Some(Currency::Usd)); - assert_eq!(ext.inner.fees[0].amount, 5.00); - assert_eq!(ext.inner.balance.as_ref().unwrap().amount, 1000.00); + assert_eq!(ext.inner.fees[0].amount, Amount(dec!(5.00))); + assert_eq!( + ext.inner.balance.as_ref().unwrap().amount, + Amount(dec!(1000.00)) + ); } #[test] @@ -1153,12 +1199,15 @@ mod tests { let ext = object.extension().unwrap(); assert_eq!(ext.inner.currency, Some(Currency::Usd)); - assert_eq!(ext.inner.credit[0].amount, -5.00); + assert_eq!(ext.inner.credit[0].amount, Amount(dec!(-5.00))); assert_eq!( ext.inner.credit[0].description.as_ref().unwrap(), "AGP Credit" ); - assert_eq!(ext.inner.balance.as_ref().unwrap().amount, 1005.00); + assert_eq!( + ext.inner.balance.as_ref().unwrap().amount, + Amount(dec!(1005.00)) + ); } #[test] @@ -1169,7 +1218,7 @@ mod tests { let ext = object.extension().unwrap(); assert_eq!(ext.currency, Some(Currency::Usd)); assert_eq!(ext.period.as_ref().unwrap().value, 1); - assert_eq!(ext.fees[0].amount, 5.00); + assert_eq!(ext.fees[0].amount, Amount(dec!(5.00))); } #[test] @@ -1181,7 +1230,7 @@ mod tests { let ext = object.extension().unwrap(); assert_eq!(ext.currency, Some(Currency::Usd)); assert!(ext.period.is_none()); - assert_eq!(ext.fees[0].amount, 5.00); + assert_eq!(ext.fees[0].amount, Amount(dec!(5.00))); assert_eq!( ext.fees[0].grace_period, Some(XsdDuration::new(0, (5 * 24 * 60 * 60) as f64).unwrap()) //5 days @@ -1195,6 +1244,6 @@ mod tests { ); let ext = object.extension().unwrap(); assert_eq!(ext.currency, Some(Currency::Usd)); - assert_eq!(ext.fees[0].amount, 5.00); + assert_eq!(ext.fees[0].amount, Amount(dec!(5.00))); } } From 38ebf15105525ec0590876f42d6efd8ec253a5cd Mon Sep 17 00:00:00 2001 From: Rudi Floren Date: Mon, 30 Mar 2026 15:16:34 +0200 Subject: [PATCH 4/5] fix XsdDuration --- src/extensions/fee/duration.rs | 98 +++++++++++++++++++++++----------- 1 file changed, 66 insertions(+), 32 deletions(-) diff --git a/src/extensions/fee/duration.rs b/src/extensions/fee/duration.rs index a501382..a3285f3 100644 --- a/src/extensions/fee/duration.rs +++ b/src/extensions/fee/duration.rs @@ -1,6 +1,5 @@ //! XSD Duration format -use std::ops::Div; use std::str::FromStr; use instant_xml::{FromXml, ToXml}; @@ -68,7 +67,7 @@ impl FromStr for XsdDuration { _ => s, }; - (s, 12 * (y as i64) + (m as i64) * sgn) + (s, (12 * (y as i64) + (m as i64)) * sgn) }; // duDayTimeFrag @@ -85,7 +84,7 @@ impl FromStr for XsdDuration { if !s.starts_with("T") { return Ok(Self { months, - seconds: (86400 * d) as f64, + seconds: (86400 * d) as f64 * sgn as f64, }); } let s = &s[1..]; @@ -117,7 +116,10 @@ impl FromStr for XsdDuration { (86400 * d) as f64 + t }; - Ok(Self { months, seconds }) + Ok(Self { + months, + seconds: seconds * sgn as f64, + }) } } @@ -210,8 +212,9 @@ fn format_duration_inner(duration: &XsdDuration) -> String { String::from("P") }; // https://www.w3.org/TR/xmlschema11-2/#f-duYMCan - let years = (duration.months / 12) as u64; - let months = (duration.months % 12) as u64; + let abs_months = duration.months.unsigned_abs(); + let years = abs_months / 12; + let months = abs_months % 12; if years > 0 { buf.push_str(&format!("{}Y", years)); @@ -224,34 +227,33 @@ fn format_duration_inner(duration: &XsdDuration) -> String { return buf; } - let days = (duration.seconds.div_euclid(86400.0)) as u64; - let hours = ((duration.seconds % 86400.0).div_euclid(3600.0)) as u64; - let minutes = ((duration.seconds % 3600.0).div(60.0)) as u64; - let seconds = duration.seconds % 60.0; + let abs_seconds = duration.seconds.abs(); + let days = (abs_seconds.div_euclid(86400.0)) as u64; + let hours = ((abs_seconds % 86400.0).div_euclid(3600.0)) as u64; + let minutes = ((abs_seconds % 3600.0) / 60.0) as u64; + let seconds = abs_seconds % 60.0; - if duration.seconds != 0.0 { - if days > 0 { - buf.push_str(&format!("{}D", days)); - } + if days > 0 { + buf.push_str(&format!("{}D", days)); + } - if hours == 0 && minutes == 0 && seconds == 0.0 { - return buf; - } + if hours == 0 && minutes == 0 && seconds == 0.0 { + return buf; + } - buf.push('T'); + buf.push('T'); - if hours > 0 { - buf.push_str(&format!("{}H", hours)); - } - if minutes > 0 { - buf.push_str(&format!("{}M", minutes)); - } - if seconds > 0.0 { - if seconds.fract() > 0.0 { - buf.push_str(&format!("{:.4}S", seconds)); - } else { - buf.push_str(&format!("{}S", seconds.trunc() as u64)); - } + if hours > 0 { + buf.push_str(&format!("{}H", hours)); + } + if minutes > 0 { + buf.push_str(&format!("{}M", minutes)); + } + if seconds > 0.0 { + if seconds.fract() > 0.0 { + buf.push_str(&format!("{:.4}S", seconds)); + } else { + buf.push_str(&format!("{}S", seconds.trunc() as u64)); } } @@ -305,14 +307,46 @@ mod tests { let dur: XsdDuration = s.parse().unwrap(); assert_eq!( dur, - XsdDuration::new(12 * 30, (30 * DAY + DAY + 3600 + 60 + 1) as f64).unwrap() + XsdDuration::new(13, (DAY + 3600 + 60 + 1) as f64).unwrap() ); let s = "P1Y1M5DT0H0M0S"; let dur: XsdDuration = s.parse().unwrap(); assert_eq!( dur, - XsdDuration::new(12 * 30, (30 * DAY + 5 * DAY) as f64).unwrap() + XsdDuration::new(13, (5 * DAY) as f64).unwrap() ); } + + #[test] + fn deser_negative() { + let s = "-P1Y2M"; + let dur: XsdDuration = s.parse().unwrap(); + assert_eq!(dur, XsdDuration::new(-14, 0.0).unwrap()); + + let s = "-P1DT1H"; + let dur: XsdDuration = s.parse().unwrap(); + assert_eq!( + dur, + XsdDuration::new(0, -((DAY + 3600) as f64)).unwrap() + ); + + let s = "-P1Y2M3DT4H5M6S"; + let dur: XsdDuration = s.parse().unwrap(); + assert_eq!( + dur, + XsdDuration::new(-14, -((3 * DAY + 4 * 3600 + 5 * 60 + 6) as f64)).unwrap() + ); + } + + #[test] + fn ser_negative() { + let dur = XsdDuration::new(-14, 0.0).unwrap(); + let s = format_duration(dur).unwrap(); + assert_eq!(s, "-P1Y2M"); + + let dur = XsdDuration::new(0, -((DAY + 3600) as f64)).unwrap(); + let s = format_duration(dur).unwrap(); + assert_eq!(s, "-P1DT1H"); + } } From 9320e3d9a6a1077b6f247d07d3e41041ce5eda0c Mon Sep 17 00:00:00 2001 From: Rudi Floren Date: Mon, 30 Mar 2026 15:22:17 +0200 Subject: [PATCH 5/5] rework currency This now is a Cow<'static, str> backed type instead of an enum. This allows parsing of all currencies, with 3 helpers for the most common currencies needed. --- src/extensions/fee/mod.rs | 63 ++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/src/extensions/fee/mod.rs b/src/extensions/fee/mod.rs index ab7ddd7..8add6cc 100644 --- a/src/extensions/fee/mod.rs +++ b/src/extensions/fee/mod.rs @@ -56,7 +56,7 @@ impl<'a> Check<'a> { /// use instant_epp::extensions::fee::{Check, Command, Currency}; /// let fee_check = Check::new() /// .push(Command::create()) - /// .push(Command::renew()).with_currency(Currency::Usd); + /// .push(Command::renew()).with_currency(Currency::USD); /// ``` pub fn new() -> Self { Self::default() @@ -591,7 +591,6 @@ impl<'xml> FromXml<'xml> for TransformResultType { "Transform::currency", deserializer, )?; - deserializer.ignore()?; } else if as FromXml<'xml>>::matches(current, None) { as FromXml>::deserialize( &mut into.period, @@ -823,13 +822,35 @@ pub enum CommandEnum { Restore, } -/// Scalar enum for fee:currency -#[derive(Debug, Clone, Copy, PartialEq, FromXml, ToXml)] -#[xml(scalar, rename = "currency", rename_all = "UPPERCASE", ns(XMLNS))] -pub enum Currency { - Usd, - Eur, - Gbp, +/// Newtype for EPP XML `` tag +/// +/// Wraps a currency code string (e.g. "USD", "EUR"). Common currencies are +/// provided as associated constants. +#[derive(Debug, Clone, PartialEq, Eq, FromXml, ToXml)] +#[xml(rename = "currency", ns(XMLNS))] +pub struct Currency { + #[xml(direct)] + code: Cow<'static, str>, +} + +impl Currency { + pub const USD: Self = Self { + code: Cow::Borrowed("USD"), + }; + pub const EUR: Self = Self { + code: Cow::Borrowed("EUR"), + }; + pub const GBP: Self = Self { + code: Cow::Borrowed("GBP"), + }; + + pub fn new(code: impl Into>) -> Self { + Self { code: code.into() } + } + + pub fn as_str(&self) -> &str { + &self.code + } } /// Type for EPP XML `` tag @@ -934,7 +955,7 @@ mod tests { domains: &["example.com", "example.net", "example.xyz"], }, &Check::new() - .with_currency(Currency::Usd) + .with_currency(Currency::USD) .push(Command::create().with_period(2)) .push(Command::renew()) .push(Command::transfer()) @@ -985,7 +1006,7 @@ mod tests { applied: None, amount: Amount(dec!(5.00)), }) - .with_currency(Currency::Usd), + .with_currency(Currency::USD), ), ); } @@ -1012,7 +1033,7 @@ mod tests { &renew, &Renew { inner: TransformType { - currency: Some(Currency::Usd), + currency: Some(Currency::USD), fees: vec![FeeType { description: None, refundable: None, @@ -1041,7 +1062,7 @@ mod tests { &transfer, &Transfer::Request(TransferRequest { inner: TransformType { - currency: Some(Currency::Usd), + currency: Some(Currency::USD), fees: vec![FeeType { description: None, refundable: None, @@ -1072,7 +1093,7 @@ mod tests { &update, &Update { inner: TransformType { - currency: Some(Currency::Usd), + currency: Some(Currency::USD), fees: vec![FeeType { description: None, refundable: None, @@ -1093,7 +1114,7 @@ mod tests { response_from_file_with_ext::("response/extensions/fee/check.xml"); let ext = object.extension.unwrap().data; - assert_eq!(ext.currency, Currency::Usd); + assert_eq!(ext.currency, Currency::USD); let results = ext .data @@ -1165,7 +1186,7 @@ mod tests { "response/extensions/fee/create.xml", ); let ext = object.extension().unwrap(); - assert_eq!(ext.currency, Some(Currency::Usd)); + assert_eq!(ext.currency, Some(Currency::USD)); assert_eq!(ext.fees[0].amount, Amount(dec!(5.00))); assert_eq!( ext.fees[0].grace_period, @@ -1183,7 +1204,7 @@ mod tests { let object = response_from_file_with_ext::("response/extensions/fee/renew.xml"); let ext = object.extension().unwrap(); - assert_eq!(ext.inner.currency, Some(Currency::Usd)); + assert_eq!(ext.inner.currency, Some(Currency::USD)); assert_eq!(ext.inner.fees[0].amount, Amount(dec!(5.00))); assert_eq!( ext.inner.balance.as_ref().unwrap().amount, @@ -1198,7 +1219,7 @@ mod tests { ); let ext = object.extension().unwrap(); - assert_eq!(ext.inner.currency, Some(Currency::Usd)); + assert_eq!(ext.inner.currency, Some(Currency::USD)); assert_eq!(ext.inner.credit[0].amount, Amount(dec!(-5.00))); assert_eq!( ext.inner.credit[0].description.as_ref().unwrap(), @@ -1216,7 +1237,7 @@ mod tests { "response/extensions/fee/transfer_query.xml", ); let ext = object.extension().unwrap(); - assert_eq!(ext.currency, Some(Currency::Usd)); + assert_eq!(ext.currency, Some(Currency::USD)); assert_eq!(ext.period.as_ref().unwrap().value, 1); assert_eq!(ext.fees[0].amount, Amount(dec!(5.00))); } @@ -1228,7 +1249,7 @@ mod tests { "response/extensions/fee/transfer_request.xml", ); let ext = object.extension().unwrap(); - assert_eq!(ext.currency, Some(Currency::Usd)); + assert_eq!(ext.currency, Some(Currency::USD)); assert!(ext.period.is_none()); assert_eq!(ext.fees[0].amount, Amount(dec!(5.00))); assert_eq!( @@ -1243,7 +1264,7 @@ mod tests { "response/extensions/fee/update.xml", ); let ext = object.extension().unwrap(); - assert_eq!(ext.currency, Some(Currency::Usd)); + assert_eq!(ext.currency, Some(Currency::USD)); assert_eq!(ext.fees[0].amount, Amount(dec!(5.00))); } }