From a4cdec9eb7f51a567ce738cbd8b04e81faa5704d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABlle=20Huisman?= Date: Sun, 7 Dec 2025 11:41:46 +0100 Subject: [PATCH] feat: add regex validation --- Cargo.lock | 40 ++++ Cargo.toml | 1 + example/Cargo.toml | 1 + example/src/main.rs | 10 +- .../fortifier-macros/src/validate/field.rs | 8 +- packages/fortifier-macros/src/validations.rs | 2 + .../src/validations/length.rs | 2 +- .../fortifier-macros/src/validations/regex.rs | 51 +++++ packages/fortifier/Cargo.toml | 4 +- packages/fortifier/src/validations.rs | 4 + packages/fortifier/src/validations/regex.rs | 212 ++++++++++++++++++ 11 files changed, 330 insertions(+), 5 deletions(-) create mode 100644 packages/fortifier-macros/src/validations/regex.rs create mode 100644 packages/fortifier/src/validations/regex.rs diff --git a/Cargo.lock b/Cargo.lock index 3c4d2fc..bbc413b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "convert_case" version = "0.9.0" @@ -43,6 +52,7 @@ version = "0.0.1" dependencies = [ "fortifier-macros", "indexmap", + "regex", "url", ] @@ -51,6 +61,7 @@ name = "fortifier-example" version = "0.0.1" dependencies = [ "fortifier", + "regex", "tokio", ] @@ -247,6 +258,35 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "ryu" version = "1.0.20" diff --git a/Cargo.toml b/Cargo.toml index 3ebb78f..5b77ce6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ version = "0.0.1" [workspace.dependencies] fortifier = { path = "./packages/fortifier", version = "0.0.1" } fortifier-macros = { path = "./packages/fortifier-macros", version = "0.0.1" } +regex = "1.12.2" tokio = "1.48.0" [workspace.lints.rust] diff --git a/example/Cargo.toml b/example/Cargo.toml index 9c66441..2f3d3ea 100644 --- a/example/Cargo.toml +++ b/example/Cargo.toml @@ -10,6 +10,7 @@ version.workspace = true [dependencies] fortifier.workspace = true +regex.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [lints] diff --git a/example/src/main.rs b/example/src/main.rs index f70fdb1..fc69620 100644 --- a/example/src/main.rs +++ b/example/src/main.rs @@ -1,6 +1,10 @@ -use std::error::Error; +use std::{error::Error, sync::LazyLock}; use fortifier::Validate; +use regex::Regex; + +static COUNTRY_CODE_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"[A-Z]{2}").expect("Regex should be valid.")); #[derive(Validate)] struct CreateUser { @@ -13,6 +17,9 @@ struct CreateUser { #[validate(url)] url: String, + #[validate(regex(expr = &COUNTRY_CODE_REGEX))] + country_code: String, + #[validate(custom(function = validate_one_locale_required, error = OneLocaleRequiredError))] #[validate(length(min = 1))] locales: Vec, @@ -35,6 +42,7 @@ async fn main() -> Result<(), Box> { email: "john@doe.com".to_owned(), name: "John Doe".to_owned(), url: "https://john.doe.com".to_owned(), + country_code: "GB".to_owned(), locales: vec!["en_GB".to_owned()], }; diff --git a/packages/fortifier-macros/src/validate/field.rs b/packages/fortifier-macros/src/validate/field.rs index e7c4ffd..7f91731 100644 --- a/packages/fortifier-macros/src/validate/field.rs +++ b/packages/fortifier-macros/src/validate/field.rs @@ -5,7 +5,7 @@ use syn::{Field, Ident, Result}; use crate::{ validation::Validation, - validations::{Custom, Email, Length, Url}, + validations::{Custom, Email, Length, Regex, Url}, }; pub struct ValidateField { @@ -44,13 +44,17 @@ impl ValidateField { } else if meta.path.is_ident("length") { result.validations.push(Box::new(Length::parse(&meta)?)); + Ok(()) + } else if meta.path.is_ident("regex") { + result.validations.push(Box::new(Regex::parse(&meta)?)); + Ok(()) } else if meta.path.is_ident("url") { result.validations.push(Box::new(Url::parse(&meta)?)); Ok(()) } else { - Err(meta.error("unknown validate parameter")) + Err(meta.error("unknown parameter")) } })?; } diff --git a/packages/fortifier-macros/src/validations.rs b/packages/fortifier-macros/src/validations.rs index dbaa948..af4f9e0 100644 --- a/packages/fortifier-macros/src/validations.rs +++ b/packages/fortifier-macros/src/validations.rs @@ -1,9 +1,11 @@ mod custom; mod email; mod length; +mod regex; mod url; pub use custom::*; pub use email::*; pub use length::*; +pub use regex::*; pub use url::*; diff --git a/packages/fortifier-macros/src/validations/length.rs b/packages/fortifier-macros/src/validations/length.rs index d9e6b0b..cbdc446 100644 --- a/packages/fortifier-macros/src/validations/length.rs +++ b/packages/fortifier-macros/src/validations/length.rs @@ -32,7 +32,7 @@ impl Validation for Length { Ok(()) } else { - Err(meta.error("unknown length parameter")) + Err(meta.error("unknown parameter")) } })?; diff --git a/packages/fortifier-macros/src/validations/regex.rs b/packages/fortifier-macros/src/validations/regex.rs new file mode 100644 index 0000000..70a3177 --- /dev/null +++ b/packages/fortifier-macros/src/validations/regex.rs @@ -0,0 +1,51 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Expr, Ident, Result, meta::ParseNestedMeta}; + +use crate::validation::Validation; + +pub struct Regex { + pub expr: Expr, +} + +impl Validation for Regex { + fn parse(meta: &ParseNestedMeta<'_>) -> Result { + let mut expr: Option = None; + + meta.parse_nested_meta(|meta| { + if meta.path.is_ident("expr") { + expr = Some(meta.value()?.parse()?); + + Ok(()) + } else { + Err(meta.error("unknown parameter")) + } + })?; + + let Some(expr) = expr else { + return Err(meta.error("missing expr parameter")); + }; + + Ok(Regex { expr }) + } + + fn is_async(&self) -> bool { + false + } + + fn ident(&self) -> Ident { + format_ident!("Regex") + } + + fn error_type(&self) -> TokenStream { + quote!(RegexError) + } + + fn tokens(&self, expr: &TokenStream) -> TokenStream { + let regex_expr = &self.expr; + + quote! { + #expr.validate_regex(#regex_expr) + } + } +} diff --git a/packages/fortifier/Cargo.toml b/packages/fortifier/Cargo.toml index 3b0372d..1491282 100644 --- a/packages/fortifier/Cargo.toml +++ b/packages/fortifier/Cargo.toml @@ -9,14 +9,16 @@ repository.workspace = true version.workspace = true [features] -default = ["macros", "url"] +default = ["macros", "regex", "url"] indexmap = ["dep:indexmap"] macros = ["dep:fortifier-macros"] +regex = ["dep:regex"] url = ["dep:url"] [dependencies] fortifier-macros = { workspace = true, optional = true } indexmap = { version = "2.12.0", optional = true } +regex = { workspace = true, optional = true } url = { version = "2.5.7", optional = true } [lints] diff --git a/packages/fortifier/src/validations.rs b/packages/fortifier/src/validations.rs index 4ff3dcb..92172e9 100644 --- a/packages/fortifier/src/validations.rs +++ b/packages/fortifier/src/validations.rs @@ -1,9 +1,13 @@ mod email; mod length; +#[cfg(feature = "regex")] +mod regex; #[cfg(feature = "url")] mod url; pub use email::*; pub use length::*; +#[cfg(feature = "regex")] +pub use regex::*; #[cfg(feature = "url")] pub use url::*; diff --git a/packages/fortifier/src/validations/regex.rs b/packages/fortifier/src/validations/regex.rs new file mode 100644 index 0000000..230a70e --- /dev/null +++ b/packages/fortifier/src/validations/regex.rs @@ -0,0 +1,212 @@ +use std::{ + borrow::Cow, + cell::{Ref, RefMut}, + rc::Rc, + sync::{Arc, LazyLock}, +}; + +use regex::Regex; + +/// Convert to a regular expression. +pub trait AsRegex { + /// Convert to a regular expression. + fn as_regex(&self) -> &Regex; +} + +impl AsRegex for Regex { + fn as_regex(&self) -> &Regex { + self + } +} + +impl AsRegex for LazyLock { + fn as_regex(&self) -> &Regex { + self + } +} + +impl AsRegex for &T +where + T: AsRegex, +{ + fn as_regex(&self) -> &Regex { + T::as_regex(self) + } +} + +/// Regular expression validation error. +#[derive(Debug, Eq, PartialEq)] +pub enum RegexError { + /// Regular expression does not match. + NoMatch, +} + +/// Validate a regular expression. +pub trait ValidateRegex { + /// Validate regular expression. + fn validate_regex(&self, regex: impl AsRegex) -> Result<(), RegexError>; +} + +impl ValidateRegex for str { + fn validate_regex(&self, regex: impl AsRegex) -> Result<(), RegexError> { + if regex.as_regex().is_match(self) { + Ok(()) + } else { + Err(RegexError::NoMatch) + } + } +} + +impl ValidateRegex for &str { + fn validate_regex(&self, regex: impl AsRegex) -> Result<(), RegexError> { + if regex.as_regex().is_match(self) { + Ok(()) + } else { + Err(RegexError::NoMatch) + } + } +} + +impl ValidateRegex for String { + fn validate_regex(&self, regex: impl AsRegex) -> Result<(), RegexError> { + if regex.as_regex().is_match(self) { + Ok(()) + } else { + Err(RegexError::NoMatch) + } + } +} + +impl ValidateRegex for Option +where + T: ValidateRegex, +{ + fn validate_regex(&self, regex: impl AsRegex) -> Result<(), RegexError> { + if let Some(value) = self { + T::validate_regex(value, regex) + } else { + Ok(()) + } + } +} + +macro_rules! validate_with_deref { + ($type:ty) => { + impl ValidateRegex for $type + where + T: ValidateRegex, + { + fn validate_regex(&self, regex: impl AsRegex) -> Result<(), RegexError> { + T::validate_regex(self, regex) + } + } + }; +} + +validate_with_deref!(&T); +validate_with_deref!(Arc); +validate_with_deref!(Box); +validate_with_deref!(Rc); +validate_with_deref!(Ref<'_, T>); +validate_with_deref!(RefMut<'_, T>); + +impl ValidateRegex for Cow<'_, T> +where + T: ToOwned + ?Sized, + for<'a> &'a T: ValidateRegex, +{ + fn validate_regex(&self, regex: impl AsRegex) -> Result<(), RegexError> { + self.as_ref().validate_regex(regex) + } +} + +#[cfg(test)] +mod tests { + use std::{ + borrow::Cow, + cell::RefCell, + rc::Rc, + sync::{Arc, LazyLock}, + }; + + use regex::Regex; + + use super::{RegexError, ValidateRegex}; + + static REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"[0-9]{4}").expect("Regex should be valid.")); + + #[test] + fn ok() { + assert_eq!((*"1234").validate_regex(®EX), Ok(())); + assert_eq!("1234".validate_regex(®EX), Ok(())); + assert_eq!("1234".to_owned().validate_regex(®EX), Ok(())); + assert_eq!(Cow::::Borrowed("1234").validate_regex(®EX), Ok(())); + assert_eq!( + Cow::::Owned("1234".to_owned()).validate_regex(®EX), + Ok(()) + ); + + assert_eq!(None::<&str>.validate_regex(®EX), Ok(())); + assert_eq!(Some("1234").validate_regex(®EX), Ok(())); + + assert_eq!((&"1234").validate_regex(®EX), Ok(())); + #[expect(unused_allocation)] + { + assert_eq!(Box::new("1234").validate_regex(®EX), Ok(())); + } + assert_eq!(Arc::new("1234").validate_regex(®EX), Ok(())); + assert_eq!(Rc::new("1234").validate_regex(®EX), Ok(())); + + let cell = RefCell::new("1234"); + assert_eq!(cell.borrow().validate_regex(®EX), Ok(())); + assert_eq!(cell.borrow_mut().validate_regex(®EX), Ok(())); + } + + #[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".to_owned().validate_regex(®EX), + Err(RegexError::NoMatch) + ); + assert_eq!( + Cow::::Borrowed("123").validate_regex(®EX), + Err(RegexError::NoMatch) + ); + assert_eq!( + Cow::::Owned("123".to_owned()).validate_regex(®EX), + Err(RegexError::NoMatch) + ); + + assert_eq!(Some("123").validate_regex(®EX), Err(RegexError::NoMatch)); + + assert_eq!((&"123").validate_regex(®EX), Err(RegexError::NoMatch)); + #[expect(unused_allocation)] + { + assert_eq!( + Box::new("123").validate_regex(®EX), + Err(RegexError::NoMatch) + ); + } + assert_eq!( + Arc::new("123").validate_regex(®EX), + Err(RegexError::NoMatch) + ); + assert_eq!( + Rc::new("123").validate_regex(®EX), + Err(RegexError::NoMatch) + ); + + let cell = RefCell::new("123"); + assert_eq!( + cell.borrow().validate_regex(®EX), + Err(RegexError::NoMatch) + ); + assert_eq!( + cell.borrow_mut().validate_regex(®EX), + Err(RegexError::NoMatch) + ); + } +}