From 25a9ba0dfd121213722351b4ea6517419e535d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABlle=20Huisman?= Date: Mon, 8 Dec 2025 11:13:19 +0100 Subject: [PATCH] feat: use `email_address` crate for email validation --- Cargo.lock | 35 ++ Cargo.toml | 2 + example/Cargo.toml | 2 +- packages/fortifier-macros/Cargo.toml | 2 +- .../fortifier-macros/src/validations/email.rs | 62 ++- .../src/validations/length.rs | 6 +- .../fortifier-macros/src/validations/regex.rs | 2 +- packages/fortifier/Cargo.toml | 11 +- packages/fortifier/src/validate.rs | 17 +- packages/fortifier/src/validations.rs | 2 + packages/fortifier/src/validations/email.rs | 304 ++++++++++-- packages/fortifier/src/validations/length.rs | 456 +++++++++++++++--- packages/fortifier/src/validations/regex.rs | 58 ++- packages/fortifier/src/validations/url.rs | 179 ++++++- packages/fortifier/tests/serde.rs | 92 ++++ 15 files changed, 1068 insertions(+), 162 deletions(-) create mode 100644 packages/fortifier/tests/serde.rs diff --git a/Cargo.lock b/Cargo.lock index bbc413b..c0976e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "displaydoc" version = "0.2.5" @@ -31,6 +37,15 @@ dependencies = [ "syn", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -50,9 +65,13 @@ dependencies = [ name = "fortifier" version = "0.0.1" dependencies = [ + "email_address", "fortifier-macros", "indexmap", + "pretty_assertions", "regex", + "serde", + "serde_json", "url", ] @@ -240,6 +259,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -545,6 +574,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 5b77ce6..a2a1e60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,8 @@ version = "0.0.1" fortifier = { path = "./packages/fortifier", version = "0.0.1" } fortifier-macros = { path = "./packages/fortifier-macros", version = "0.0.1" } regex = "1.12.2" +serde = "1.0.228" +serde_json = "1.0.145" tokio = "1.48.0" [workspace.lints.rust] diff --git a/example/Cargo.toml b/example/Cargo.toml index 2f3d3ea..f513946 100644 --- a/example/Cargo.toml +++ b/example/Cargo.toml @@ -9,7 +9,7 @@ repository.workspace = true version.workspace = true [dependencies] -fortifier.workspace = true +fortifier = { workspace = true, features = ["email", "regex", "url"] } regex.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/packages/fortifier-macros/Cargo.toml b/packages/fortifier-macros/Cargo.toml index 560c46a..2c3a850 100644 --- a/packages/fortifier-macros/Cargo.toml +++ b/packages/fortifier-macros/Cargo.toml @@ -18,7 +18,7 @@ quote = "1.0.42" syn = "2.0.110" [dev-dependencies] -fortifier.workspace = true +fortifier = { workspace = true, features = ["email"] } trybuild = "1.0.114" [lints] diff --git a/packages/fortifier-macros/src/validations/email.rs b/packages/fortifier-macros/src/validations/email.rs index ee63a01..f2f1f74 100644 --- a/packages/fortifier-macros/src/validations/email.rs +++ b/packages/fortifier-macros/src/validations/email.rs @@ -1,15 +1,53 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use syn::{Ident, Result, meta::ParseNestedMeta}; +use syn::{Ident, LitBool, LitInt, Result, meta::ParseNestedMeta}; use crate::validation::Validation; -#[derive(Default)] -pub struct Email {} +pub struct Email { + allow_display_text: bool, + allow_domain_literal: bool, + minimum_sub_domains: usize, +} + +impl Default for Email { + fn default() -> Self { + Self { + allow_display_text: false, + allow_domain_literal: true, + minimum_sub_domains: 0, + } + } +} impl Validation for Email { - fn parse(_meta: &ParseNestedMeta<'_>) -> Result { - Ok(Email::default()) + fn parse(meta: &ParseNestedMeta<'_>) -> Result { + let mut result = Email::default(); + + if !meta.input.is_empty() { + meta.parse_nested_meta(|meta| { + if meta.path.is_ident("allow_display_text") { + let lit: LitBool = meta.value()?.parse()?; + result.allow_display_text = lit.value; + + Ok(()) + } else if meta.path.is_ident("allow_domain_literal") { + let lit: LitBool = meta.value()?.parse()?; + result.allow_domain_literal = lit.value; + + Ok(()) + } else if meta.path.is_ident("minimum_sub_domains") { + let lit: LitInt = meta.value()?.parse()?; + result.minimum_sub_domains = lit.base10_parse()?; + + Ok(()) + } else { + Err(meta.error("unknown parameter")) + } + })?; + } + + Ok(result) } fn is_async(&self) -> bool { @@ -25,8 +63,20 @@ impl Validation for Email { } fn tokens(&self, expr: &TokenStream) -> TokenStream { + let allow_display_text = self.allow_display_text; + let allow_domain_literal = self.allow_domain_literal; + let minimum_sub_domains = self.minimum_sub_domains; + quote! { - #expr.validate_email() + { + const EMAIL_ADDRESS_OPTIONS: EmailOptions = EmailOptions { + allow_display_text: #allow_display_text, + allow_domain_literal: #allow_domain_literal, + minimum_sub_domains: #minimum_sub_domains, + }; + + #expr.validate_email(EMAIL_ADDRESS_OPTIONS) + } } } } diff --git a/packages/fortifier-macros/src/validations/length.rs b/packages/fortifier-macros/src/validations/length.rs index cbdc446..2e03c88 100644 --- a/packages/fortifier-macros/src/validations/length.rs +++ b/packages/fortifier-macros/src/validations/length.rs @@ -6,9 +6,9 @@ use crate::validation::Validation; #[derive(Default)] pub struct Length { - pub equal: Option, - pub min: Option, - pub max: Option, + equal: Option, + min: Option, + max: Option, } impl Validation for Length { diff --git a/packages/fortifier-macros/src/validations/regex.rs b/packages/fortifier-macros/src/validations/regex.rs index c6117fa..da9d642 100644 --- a/packages/fortifier-macros/src/validations/regex.rs +++ b/packages/fortifier-macros/src/validations/regex.rs @@ -5,7 +5,7 @@ use syn::{Expr, Ident, Result, meta::ParseNestedMeta}; use crate::validation::Validation; pub struct Regex { - pub expression: Expr, + expression: Expr, } impl Validation for Regex { diff --git a/packages/fortifier/Cargo.toml b/packages/fortifier/Cargo.toml index 1491282..58bf4bc 100644 --- a/packages/fortifier/Cargo.toml +++ b/packages/fortifier/Cargo.toml @@ -9,17 +9,26 @@ repository.workspace = true version.workspace = true [features] -default = ["macros", "regex", "url"] +default = ["macros"] +email = ["dep:email_address"] indexmap = ["dep:indexmap"] macros = ["dep:fortifier-macros"] +message = [] regex = ["dep:regex"] +serde = ["dep:serde", "email_address/serde_support"] url = ["dep:url"] [dependencies] +email_address = { version = "0.2.9", default-features = false, optional = true } fortifier-macros = { workspace = true, optional = true } indexmap = { version = "2.12.0", optional = true } regex = { workspace = true, optional = true } +serde = { workspace = true, features = ["derive"], optional = true } url = { version = "2.5.7", optional = true } +[dev-dependencies] +pretty_assertions = "1.4.1" +serde_json.workspace = true + [lints] workspace = true diff --git a/packages/fortifier/src/validate.rs b/packages/fortifier/src/validate.rs index 1ebaa41..f3a51e6 100644 --- a/packages/fortifier/src/validate.rs +++ b/packages/fortifier/src/validate.rs @@ -1,14 +1,15 @@ use std::{ error::Error, - fmt::{self, Display}, + fmt::{self, Debug, Display}, pin::Pin, }; /// Validation errors. -#[derive(Debug)] -pub struct ValidationErrors(Vec); +#[derive(Debug, Eq, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct ValidationErrors(Vec); -impl Display for ValidationErrors { +impl Display for ValidationErrors { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:?}", self.0) } @@ -16,7 +17,13 @@ impl Display for ValidationErrors { impl Error for ValidationErrors {} -impl From> for ValidationErrors { +impl FromIterator for ValidationErrors { + fn from_iter>(iter: T) -> Self { + Self(Vec::from_iter(iter)) + } +} + +impl From> for ValidationErrors { fn from(value: Vec) -> Self { Self(value) } diff --git a/packages/fortifier/src/validations.rs b/packages/fortifier/src/validations.rs index 92172e9..3fef0e0 100644 --- a/packages/fortifier/src/validations.rs +++ b/packages/fortifier/src/validations.rs @@ -1,3 +1,4 @@ +#[cfg(feature = "email")] mod email; mod length; #[cfg(feature = "regex")] @@ -5,6 +6,7 @@ mod regex; #[cfg(feature = "url")] mod url; +#[cfg(feature = "email")] pub use email::*; pub use length::*; #[cfg(feature = "regex")] diff --git a/packages/fortifier/src/validations/email.rs b/packages/fortifier/src/validations/email.rs index 3c14203..dd28ebe 100644 --- a/packages/fortifier/src/validations/email.rs +++ b/packages/fortifier/src/validations/email.rs @@ -5,11 +5,198 @@ use std::{ sync::Arc, }; +use email_address::EmailAddress; +pub use email_address::Options as EmailOptions; + /// Email validation error. #[derive(Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serde", + derive(serde::Deserialize, serde::Serialize), + serde( + tag = "subcode", + rename_all = "camelCase", + rename_all_fields = "camelCase" + ) +)] pub enum EmailError { - /// Invalid email address. - Invalid, + /// Invalid character error. + InvalidCharacter { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Missing separator error. + MissingSeparator { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Locale part empty error. + LocalPartEmpty { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Local part too long error. + LocalPartTooLong { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Domain empty error. + DomainEmpty { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Domain too long error. + DomainTooLong { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Subdomain empty error. + SubDomainEmpty { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Subdomain too long error. + SubDomainTooLong { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Domain too few error. + DomainTooFew { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Domain invalid separator error. + DomainInvalidSeparator { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Unbalanced quotes error. + UnbalancedQuotes { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Invalid comment error. + InvalidComment { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Invalid IP Address error. + InvalidIPAddress { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Unsupported domain literal error. + UnsupportedDomainLiteral { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Unsupported display name error. + UnsupportedDisplayName { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Missing display name error. + MissingDisplayName { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Missing end bracket error. + MissingEndBracket { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, +} + +impl From for EmailError { + fn from(value: email_address::Error) -> Self { + match value { + email_address::Error::InvalidCharacter => Self::InvalidCharacter { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + email_address::Error::MissingSeparator => Self::MissingSeparator { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + email_address::Error::LocalPartEmpty => Self::LocalPartEmpty { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + email_address::Error::LocalPartTooLong => Self::LocalPartTooLong { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + email_address::Error::DomainEmpty => Self::DomainEmpty { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + email_address::Error::DomainTooLong => Self::DomainTooLong { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + email_address::Error::SubDomainEmpty => Self::SubDomainEmpty { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + email_address::Error::SubDomainTooLong => Self::SubDomainTooLong { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + email_address::Error::DomainTooFew => Self::DomainTooFew { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + email_address::Error::DomainInvalidSeparator => Self::DomainInvalidSeparator { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + email_address::Error::UnbalancedQuotes => Self::UnbalancedQuotes { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + email_address::Error::InvalidComment => Self::InvalidComment { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + email_address::Error::InvalidIPAddress => Self::InvalidIPAddress { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + email_address::Error::UnsupportedDomainLiteral => Self::UnsupportedDomainLiteral { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + email_address::Error::UnsupportedDisplayName => Self::UnsupportedDisplayName { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + email_address::Error::MissingDisplayName => Self::MissingDisplayName { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + email_address::Error::MissingEndBracket => Self::MissingEndBracket { + #[cfg(feature = "message")] + message: "".to_owned(), + }, + } + } } /// Validate an email address. @@ -18,16 +205,12 @@ pub trait ValidateEmail { fn email(&self) -> Option>; /// Validate email address. - fn validate_email(&self) -> Result<(), EmailError> { + fn validate_email(&self, options: EmailOptions) -> Result<(), EmailError> { let Some(email) = self.email() else { return Ok(()); }; - if email.is_empty() || !email.contains("@") { - return Err(EmailError::Invalid); - } - - // TODO + EmailAddress::parse_with_options(&email, options).map_err(EmailError::from)?; Ok(()) } @@ -57,6 +240,12 @@ impl ValidateEmail for Cow<'_, str> { } } +impl ValidateEmail for EmailAddress { + fn email(&self) -> Option> { + Some(self.as_str().into()) + } +} + impl ValidateEmail for Option where T: ValidateEmail, @@ -94,67 +283,108 @@ validate_with_deref!(RefMut<'_, T>); mod tests { use std::{borrow::Cow, cell::RefCell, rc::Rc, sync::Arc}; - use super::{EmailError, ValidateEmail}; + use email_address::EmailAddress; + + use super::{EmailError, EmailOptions, ValidateEmail}; #[test] fn ok() { - assert_eq!((*"admin@localhost").validate_email(), Ok(())); - assert_eq!("admin@localhost".validate_email(), Ok(())); - assert_eq!("admin@localhost".to_owned().validate_email(), Ok(())); + let options = EmailOptions::default().without_display_text(); + + assert_eq!((*"admin@localhost").validate_email(options), Ok(())); + assert_eq!("admin@localhost".validate_email(options), Ok(())); + assert_eq!("admin@localhost".to_owned().validate_email(options), Ok(())); assert_eq!( - Cow::::Borrowed("admin@localhost").validate_email(), + Cow::::Borrowed("admin@localhost").validate_email(options), Ok(()) ); assert_eq!( - Cow::::Owned("admin@localhost".to_owned()).validate_email(), + Cow::::Owned("admin@localhost".to_owned()).validate_email(options), + Ok(()) + ); + assert_eq!( + EmailAddress::new_unchecked("admin@localhost").validate_email(options), Ok(()) ); - assert_eq!(None::<&str>.validate_email(), Ok(())); - assert_eq!(Some("admin@localhost").validate_email(), Ok(())); + assert_eq!(None::<&str>.validate_email(options), Ok(())); + assert_eq!(Some("admin@localhost").validate_email(options), Ok(())); - assert_eq!((&"admin@localhost").validate_email(), Ok(())); + assert_eq!((&"admin@localhost").validate_email(options), Ok(())); #[expect(unused_allocation)] { - assert_eq!(Box::new("admin@localhost").validate_email(), Ok(())); + assert_eq!(Box::new("admin@localhost").validate_email(options), Ok(())); } - assert_eq!(Arc::new("admin@localhost").validate_email(), Ok(())); - assert_eq!(Rc::new("admin@localhost").validate_email(), Ok(())); + assert_eq!(Arc::new("admin@localhost").validate_email(options), Ok(())); + assert_eq!(Rc::new("admin@localhost").validate_email(options), Ok(())); let cell = RefCell::new("admin@localhost"); - assert_eq!(cell.borrow().validate_email(), Ok(())); - assert_eq!(cell.borrow_mut().validate_email(), Ok(())); + assert_eq!(cell.borrow().validate_email(options), Ok(())); + assert_eq!(cell.borrow_mut().validate_email(options), Ok(())); } #[test] fn invalid_error() { - assert_eq!((*"admin").validate_email(), Err(EmailError::Invalid)); - assert_eq!("admin".validate_email(), Err(EmailError::Invalid)); + let options = EmailOptions::default().without_display_text(); + assert_eq!( - "admin".to_owned().validate_email(), - Err(EmailError::Invalid) + (*"admin").validate_email(options), + Err(EmailError::from(email_address::Error::MissingSeparator)) ); assert_eq!( - Cow::::Borrowed("admin").validate_email(), - Err(EmailError::Invalid) + "admin".validate_email(options), + Err(EmailError::from(email_address::Error::MissingSeparator)) ); assert_eq!( - Cow::::Owned("admin".to_owned()).validate_email(), - Err(EmailError::Invalid) + "admin".to_owned().validate_email(options), + Err(EmailError::from(email_address::Error::MissingSeparator)) + ); + assert_eq!( + Cow::::Borrowed("admin").validate_email(options), + Err(EmailError::from(email_address::Error::MissingSeparator)) + ); + assert_eq!( + Cow::::Owned("admin".to_owned()).validate_email(options), + Err(EmailError::from(email_address::Error::MissingSeparator)) + ); + assert_eq!( + EmailAddress::new_unchecked("admin").validate_email(options), + Err(EmailError::from(email_address::Error::MissingSeparator)) ); - assert_eq!(Some("admin").validate_email(), Err(EmailError::Invalid)); + assert_eq!( + Some("admin").validate_email(options), + Err(EmailError::from(email_address::Error::MissingSeparator)) + ); - assert_eq!((&"admin").validate_email(), Err(EmailError::Invalid)); + assert_eq!( + (&"admin").validate_email(options), + Err(EmailError::from(email_address::Error::MissingSeparator)) + ); #[expect(unused_allocation)] { - assert_eq!(Box::new("admin").validate_email(), Err(EmailError::Invalid)); + assert_eq!( + Box::new("admin").validate_email(options), + Err(EmailError::from(email_address::Error::MissingSeparator)) + ); } - assert_eq!(Arc::new("admin").validate_email(), Err(EmailError::Invalid)); - assert_eq!(Rc::new("admin").validate_email(), Err(EmailError::Invalid)); + assert_eq!( + Arc::new("admin").validate_email(options), + Err(EmailError::from(email_address::Error::MissingSeparator)) + ); + assert_eq!( + Rc::new("admin").validate_email(options), + Err(EmailError::from(email_address::Error::MissingSeparator)) + ); let cell = RefCell::new("admin"); - assert_eq!(cell.borrow().validate_email(), Err(EmailError::Invalid)); - assert_eq!(cell.borrow_mut().validate_email(), Err(EmailError::Invalid)); + assert_eq!( + cell.borrow().validate_email(options), + Err(EmailError::from(email_address::Error::MissingSeparator)) + ); + assert_eq!( + cell.borrow_mut().validate_email(options), + Err(EmailError::from(email_address::Error::MissingSeparator)) + ); } } diff --git a/packages/fortifier/src/validations/length.rs b/packages/fortifier/src/validations/length.rs index 88e5bf4..82adfd5 100644 --- a/packages/fortifier/src/validations/length.rs +++ b/packages/fortifier/src/validations/length.rs @@ -2,6 +2,7 @@ use std::{ borrow::Cow, cell::{Ref, RefMut}, collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}, + fmt::Display, rc::Rc, sync::Arc, }; @@ -11,6 +12,15 @@ use indexmap::{IndexMap, IndexSet}; /// Length validation error. #[derive(Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serde", + derive(serde::Deserialize, serde::Serialize), + serde( + tag = "subcode", + rename_all = "camelCase", + rename_all_fields = "camelCase" + ) +)] pub enum LengthError { /// Length is not equal to the required length. Equal { @@ -19,6 +29,10 @@ pub enum LengthError { /// The actual length. length: T, + + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, }, /// Length is less than the minimum length. Min { @@ -27,6 +41,10 @@ pub enum LengthError { /// The actual length. length: T, + + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, }, /// Length is more than the maximum length. Max { @@ -35,13 +53,17 @@ pub enum LengthError { /// The length. length: T, + + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, }, } /// Validate a length. pub trait ValidateLength where - T: PartialEq + PartialOrd, + T: Display + PartialEq + PartialOrd, { /// The length. fn length(&self) -> Option; @@ -59,19 +81,43 @@ where if let Some(equal) = equal { if length != equal { - return Err(LengthError::Equal { equal, length }); + #[cfg(feature = "message")] + let message = format!("length {length} is not equal to required length {equal}"); + + return Err(LengthError::Equal { + equal, + length, + #[cfg(feature = "message")] + message, + }); } } else { if let Some(min) = min && length < min { - return Err(LengthError::Min { min, length }); + #[cfg(feature = "message")] + let message = format!("length {length} is less than minimum length {min}"); + + return Err(LengthError::Min { + min, + length, + #[cfg(feature = "message")] + message, + }); } if let Some(max) = max && length > max { - return Err(LengthError::Max { max, length }); + #[cfg(feature = "message")] + let message = format!("length {length} is greater than maximum length {max}"); + + return Err(LengthError::Max { + max, + length, + #[cfg(feature = "message")] + message, + }); } } @@ -126,7 +172,7 @@ validate_with_len!(IndexMap, K, V); impl ValidateLength for Option where - L: PartialEq + PartialOrd, + L: Display + PartialEq + PartialOrd, T: ValidateLength, { fn length(&self) -> Option { @@ -142,7 +188,7 @@ macro_rules! validate_with_deref { ($type:ty) => { impl ValidateLength for $type where - L: PartialEq + PartialOrd, + L: Display + PartialEq + PartialOrd, T: ValidateLength, { fn length(&self) -> Option { @@ -161,7 +207,7 @@ validate_with_deref!(RefMut<'_, T>); impl ValidateLength for Cow<'_, T> where - L: PartialEq + PartialOrd, + L: Display + PartialEq + PartialOrd, T: ToOwned + ?Sized, for<'a> &'a T: ValidateLength, { @@ -260,35 +306,45 @@ mod tests { (*"a").validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); assert_eq!( "a".validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); assert_eq!( "a".to_owned().validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); assert_eq!( Cow::::Borrowed("a").validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); assert_eq!( Cow::::Owned("a".to_owned()).validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); @@ -296,7 +352,9 @@ mod tests { Some("a").validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); @@ -304,56 +362,72 @@ mod tests { [""; 1].validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); assert_eq!( [""].validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); assert_eq!( BTreeSet::from([""]).validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); assert_eq!( BTreeMap::from([("", "")]).validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); assert_eq!( HashSet::from([""]).validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); assert_eq!( HashMap::from([("", "")]).validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); assert_eq!( vec![""].validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); assert_eq!( VecDeque::from([""]).validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); @@ -363,14 +437,18 @@ mod tests { IndexSet::from([""]).validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); assert_eq!( IndexMap::from([("", "")]).validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); } @@ -379,7 +457,9 @@ mod tests { (&"a").validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); #[expect(unused_allocation)] @@ -388,7 +468,9 @@ mod tests { Box::new("a").validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); } @@ -396,14 +478,18 @@ mod tests { Arc::new("a").validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); assert_eq!( Rc::new("a").validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); @@ -412,14 +498,18 @@ mod tests { cell.borrow().validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); assert_eq!( cell.borrow_mut().validate_length(Some(2), None, None), Err(LengthError::Equal { equal: 2, - length: 1 + length: 1, + #[cfg(feature = "message")] + message: "length 1 is not equal to required length 2".to_owned(), }) ); } @@ -428,103 +518,213 @@ mod tests { fn min_error() { assert_eq!( (*"a").validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); assert_eq!( "a".validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); assert_eq!( "a".to_owned().validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); assert_eq!( Cow::::Borrowed("a").validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); assert_eq!( Cow::::Owned("a".to_owned()).validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); assert_eq!( Some("a").validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); assert_eq!( [""; 1].validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); assert_eq!( [""].validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); assert_eq!( BTreeSet::from([""]).validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); assert_eq!( BTreeMap::from([("", "")]).validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); assert_eq!( HashSet::from([""]).validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); assert_eq!( HashMap::from([("", "")]).validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); assert_eq!( vec![""].validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); assert_eq!( VecDeque::from([""]).validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); #[cfg(feature = "indexmap")] { assert_eq!( IndexSet::from([""]).validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); assert_eq!( IndexMap::from([("", "")]).validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); } assert_eq!( (&"a").validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); #[expect(unused_allocation)] { assert_eq!( Box::new("a").validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); } assert_eq!( Arc::new("a").validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); assert_eq!( Rc::new("a").validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); let cell = RefCell::new("a"); assert_eq!( cell.borrow().validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); assert_eq!( cell.borrow_mut().validate_length(None, Some(3), None), - Err(LengthError::Min { min: 3, length: 1 }) + Err(LengthError::Min { + min: 3, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is less than minimum length 3".to_owned(), + }) ); } @@ -532,103 +732,213 @@ mod tests { fn max_error() { assert_eq!( (*"a").validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); assert_eq!( "a".validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); assert_eq!( "a".to_owned().validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); assert_eq!( Cow::::Borrowed("a").validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); assert_eq!( Cow::::Owned("a".to_owned()).validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); assert_eq!( Some("a").validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); assert_eq!( [""; 1].validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); assert_eq!( [""].validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); assert_eq!( BTreeSet::from([""]).validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); assert_eq!( BTreeMap::from([("", "")]).validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); assert_eq!( HashSet::from([""]).validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); assert_eq!( HashMap::from([("", "")]).validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); assert_eq!( vec![""].validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); assert_eq!( VecDeque::from([""]).validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); #[cfg(feature = "indexmap")] { assert_eq!( IndexSet::from([""]).validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); assert_eq!( IndexMap::from([("", "")]).validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); } assert_eq!( (&"a").validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); #[expect(unused_allocation)] { assert_eq!( Box::new("a").validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); } assert_eq!( Arc::new("a").validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); assert_eq!( Rc::new("a").validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); let cell = RefCell::new("a"); assert_eq!( cell.borrow().validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); assert_eq!( cell.borrow_mut().validate_length(None, None, Some(0)), - Err(LengthError::Max { max: 0, length: 1 }) + Err(LengthError::Max { + max: 0, + length: 1, + #[cfg(feature = "message")] + message: "length 1 is greater than maximum length 0".to_owned(), + }) ); } } diff --git a/packages/fortifier/src/validations/regex.rs b/packages/fortifier/src/validations/regex.rs index 230a70e..434b720 100644 --- a/packages/fortifier/src/validations/regex.rs +++ b/packages/fortifier/src/validations/regex.rs @@ -36,9 +36,25 @@ where /// Regular expression validation error. #[derive(Debug, Eq, PartialEq)] -pub enum RegexError { - /// Regular expression does not match. - NoMatch, +#[cfg_attr( + feature = "serde", + derive(serde::Deserialize, serde::Serialize), + serde(rename_all = "camelCase") +)] +pub struct RegexError { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, +} + +#[cfg_attr(not(feature = "message"), expect(clippy::derivable_impls))] +impl Default for RegexError { + fn default() -> Self { + Self { + #[cfg(feature = "message")] + message: "value does not match regular expression".to_owned(), + } + } } /// Validate a regular expression. @@ -52,7 +68,7 @@ impl ValidateRegex for str { if regex.as_regex().is_match(self) { Ok(()) } else { - Err(RegexError::NoMatch) + Err(RegexError::default()) } } } @@ -62,7 +78,7 @@ impl ValidateRegex for &str { if regex.as_regex().is_match(self) { Ok(()) } else { - Err(RegexError::NoMatch) + Err(RegexError::default()) } } } @@ -72,7 +88,7 @@ impl ValidateRegex for String { if regex.as_regex().is_match(self) { Ok(()) } else { - Err(RegexError::NoMatch) + Err(RegexError::default()) } } } @@ -165,48 +181,54 @@ mod tests { #[test] fn no_match_error() { - assert_eq!((*"123").validate_regex(®EX), Err(RegexError::NoMatch)); - assert_eq!("123".validate_regex(®EX), Err(RegexError::NoMatch)); + assert_eq!((*"123").validate_regex(®EX), Err(RegexError::default())); + assert_eq!("123".validate_regex(®EX), Err(RegexError::default())); assert_eq!( "123".to_owned().validate_regex(®EX), - Err(RegexError::NoMatch) + Err(RegexError::default()) ); assert_eq!( Cow::::Borrowed("123").validate_regex(®EX), - Err(RegexError::NoMatch) + Err(RegexError::default()) ); assert_eq!( Cow::::Owned("123".to_owned()).validate_regex(®EX), - Err(RegexError::NoMatch) + Err(RegexError::default()) ); - assert_eq!(Some("123").validate_regex(®EX), Err(RegexError::NoMatch)); + assert_eq!( + Some("123").validate_regex(®EX), + Err(RegexError::default()) + ); - assert_eq!((&"123").validate_regex(®EX), Err(RegexError::NoMatch)); + assert_eq!((&"123").validate_regex(®EX), Err(RegexError::default())); #[expect(unused_allocation)] { assert_eq!( Box::new("123").validate_regex(®EX), - Err(RegexError::NoMatch) + Err(RegexError { + #[cfg(feature = "message")] + message: "value does not match regular expression".to_owned(), + }) ); } assert_eq!( Arc::new("123").validate_regex(®EX), - Err(RegexError::NoMatch) + Err(RegexError::default()) ); assert_eq!( Rc::new("123").validate_regex(®EX), - Err(RegexError::NoMatch) + Err(RegexError::default()) ); let cell = RefCell::new("123"); assert_eq!( cell.borrow().validate_regex(®EX), - Err(RegexError::NoMatch) + Err(RegexError::default()) ); assert_eq!( cell.borrow_mut().validate_regex(®EX), - Err(RegexError::NoMatch) + Err(RegexError::default()) ); } } diff --git a/packages/fortifier/src/validations/url.rs b/packages/fortifier/src/validations/url.rs index 3b4e081..a7dc615 100644 --- a/packages/fortifier/src/validations/url.rs +++ b/packages/fortifier/src/validations/url.rs @@ -9,9 +9,136 @@ use url::{ParseError, Url}; /// URL validation error. #[derive(Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serde", + derive(serde::Deserialize, serde::Serialize), + serde( + tag = "subcode", + rename_all = "camelCase", + rename_all_fields = "camelCase" + ) +)] pub enum UrlError { - /// Parse error. - Parse(ParseError), + /// Empty host error. + EmptyHost { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Invalid international domain name error. + IdnaError { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Invalid port error. + InvalidPort { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Invalid IPv4 address error. + InvalidIpv4Address { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Invalid IPv6 address error. + InvalidIpv6Address { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Invalid domain character error. + InvalidDomainCharacter { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Relative URL without base error. + RelativeUrlWithoutBase { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Relative URL with cannot-be-a-base base error. + RelativeUrlWithCannotBeABaseBase { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Set host on cannot-be-a-base URL error. + SetHostOnCannotBeABaseUrl { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Overflow error. + Overflow { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Unknown error. + Unknown { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, +} + +impl From for UrlError { + fn from(value: ParseError) -> Self { + match value { + ParseError::EmptyHost => UrlError::EmptyHost { + #[cfg(feature = "message")] + message: value.to_string(), + }, + ParseError::IdnaError => UrlError::IdnaError { + #[cfg(feature = "message")] + message: value.to_string(), + }, + ParseError::InvalidPort => UrlError::InvalidPort { + #[cfg(feature = "message")] + message: value.to_string(), + }, + ParseError::InvalidIpv4Address => UrlError::InvalidIpv4Address { + #[cfg(feature = "message")] + message: value.to_string(), + }, + ParseError::InvalidIpv6Address => UrlError::InvalidIpv6Address { + #[cfg(feature = "message")] + message: value.to_string(), + }, + ParseError::InvalidDomainCharacter => UrlError::InvalidDomainCharacter { + #[cfg(feature = "message")] + message: value.to_string(), + }, + ParseError::RelativeUrlWithoutBase => UrlError::RelativeUrlWithoutBase { + #[cfg(feature = "message")] + message: value.to_string(), + }, + ParseError::RelativeUrlWithCannotBeABaseBase => { + UrlError::RelativeUrlWithCannotBeABaseBase { + #[cfg(feature = "message")] + message: value.to_string(), + } + } + ParseError::SetHostOnCannotBeABaseUrl => UrlError::SetHostOnCannotBeABaseUrl { + #[cfg(feature = "message")] + message: value.to_string(), + }, + ParseError::Overflow => UrlError::Overflow { + #[cfg(feature = "message")] + message: value.to_string(), + }, + #[cfg_attr(not(feature = "message"), allow(unused_variables))] + value => UrlError::Overflow { + #[cfg(feature = "message")] + message: value.to_string(), + }, + } + } } /// Validate a URL. @@ -25,7 +152,7 @@ pub trait ValidateUrl { return Ok(()); }; - Url::parse(&url).map_err(UrlError::Parse)?; + Url::parse(&url).map_err(UrlError::from)?; Ok(()) } @@ -55,6 +182,17 @@ impl ValidateUrl for Cow<'_, str> { } } +impl ValidateUrl for Url { + fn url(&self) -> Option> { + Some(self.as_str().into()) + } + + fn validate_url(&self) -> Result<(), UrlError> { + // URL has already been parsed, so it must be valid. + Ok(()) + } +} + impl ValidateUrl for Option where T: ValidateUrl, @@ -92,7 +230,7 @@ validate_with_deref!(RefMut<'_, T>); mod tests { use std::{borrow::Cow, cell::RefCell, rc::Rc, sync::Arc}; - use url::ParseError; + use url::{ParseError, Url}; use super::{UrlError, ValidateUrl}; @@ -109,6 +247,12 @@ mod tests { Cow::::Owned("http://localhost".to_owned()).validate_url(), Ok(()) ); + assert_eq!( + Url::parse("http://localhost") + .expect("URL should be valid.") + .validate_url(), + Ok(()) + ); assert_eq!(None::<&str>.validate_url(), Ok(())); assert_eq!(Some("http://localhost").validate_url(), Ok(())); @@ -130,58 +274,61 @@ mod tests { fn parse_error() { assert_eq!( (*"http://").validate_url(), - Err(UrlError::Parse(ParseError::EmptyHost)) + Err(UrlError::from(ParseError::EmptyHost)) ); assert_eq!( "http://".validate_url(), - Err(UrlError::Parse(ParseError::EmptyHost)) + Err(UrlError::from(ParseError::EmptyHost)) ); assert_eq!( "http://".to_owned().validate_url(), - Err(UrlError::Parse(ParseError::EmptyHost)) + Err(UrlError::from(ParseError::EmptyHost)) ); assert_eq!( Cow::::Borrowed("http://").validate_url(), - Err(UrlError::Parse(ParseError::EmptyHost)) + Err(UrlError::from(ParseError::EmptyHost)) ); assert_eq!( Cow::::Owned("http://".to_owned()).validate_url(), - Err(UrlError::Parse(ParseError::EmptyHost)) + Err(UrlError::from(ParseError::EmptyHost)) ); assert_eq!( Some("http://").validate_url(), - Err(UrlError::Parse(ParseError::EmptyHost)) + Err(UrlError::from(ParseError::EmptyHost)) ); assert_eq!( (&"http://").validate_url(), - Err(UrlError::Parse(ParseError::EmptyHost)) + Err(UrlError::from(ParseError::EmptyHost)) ); #[expect(unused_allocation)] { assert_eq!( Box::new("http://").validate_url(), - Err(UrlError::Parse(ParseError::EmptyHost)) + Err(UrlError::EmptyHost { + #[cfg(feature = "message")] + message: "empty host".to_owned(), + }) ); } assert_eq!( Arc::new("http://").validate_url(), - Err(UrlError::Parse(ParseError::EmptyHost)) + Err(UrlError::from(ParseError::EmptyHost)) ); assert_eq!( Rc::new("http://").validate_url(), - Err(UrlError::Parse(ParseError::EmptyHost)) + Err(UrlError::from(ParseError::EmptyHost)) ); let cell = RefCell::new("http://"); assert_eq!( cell.borrow().validate_url(), - Err(UrlError::Parse(ParseError::EmptyHost)) + Err(UrlError::from(ParseError::EmptyHost)) ); assert_eq!( cell.borrow_mut().validate_url(), - Err(UrlError::Parse(ParseError::EmptyHost)) + Err(UrlError::from(ParseError::EmptyHost)) ); } } diff --git a/packages/fortifier/tests/serde.rs b/packages/fortifier/tests/serde.rs new file mode 100644 index 0000000..44dd50a --- /dev/null +++ b/packages/fortifier/tests/serde.rs @@ -0,0 +1,92 @@ +#![cfg(feature = "serde")] + +use fortifier::{EmailError, LengthError, RegexError, UrlError, ValidationErrors}; +use pretty_assertions::assert_eq; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use url::ParseError; + +#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "code", rename_all = "camelCase")] +enum TestError { + Email(EmailError), + Length(LengthError), + Regex(RegexError), + Url(UrlError), +} + +fn setup() -> (ValidationErrors, Value) { + ( + ValidationErrors::from_iter([ + TestError::Email(EmailError::from(email_address::Error::MissingSeparator)), + TestError::Length(LengthError::Equal { + equal: 1, + length: 2, + message: "length 2 is not equal to required length 1".to_owned(), + }), + TestError::Regex(RegexError::default()), + TestError::Url(UrlError::from(ParseError::EmptyHost)), + ]), + #[cfg(not(feature = "message"))] + json!([ + { + "code": "email", + "subcode": "missingSeparator", + }, + { + "code": "length", + "subcode": "equal", + "equal": 1, + "length": 2, + }, + { + "code": "regex", + }, + { + "code": "url", + "subcode": "emptyHost", + } + ]), + #[cfg(feature = "message")] + json!([ + { + "code": "email", + "subcode": "missingSeparator", + "message": "", + }, + { + "code": "length", + "subcode": "equal", + "equal": 1, + "length": 2, + "message": "length 2 is not equal to required length 1", + }, + { + "code": "regex", + "message": "value does not match regular expression", + }, + { + "code": "url", + "subcode": "emptyHost", + "message": "empty host", + } + ]), + ) +} + +#[test] +fn serialize() { + let (deserialized, serialized) = setup(); + + assert_eq!(serde_json::to_value(&deserialized).unwrap(), serialized); +} + +#[test] +fn deserialize() { + let (deserialized, serialized) = setup(); + + assert_eq!( + serde_json::from_value::>(serialized).unwrap(), + deserialized, + ); +}