diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71585f0..d86eef2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,8 +59,8 @@ jobs: - name: Set up Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@v1 with: - components: clippy, rustfmt + components: clippy, rustfmt, rust-src target: wasm32-unknown-unknown - name: Test - run: cargo test --locked --release + run: cargo test --all-features --locked --release diff --git a/Cargo.lock b/Cargo.lock index 2f82352..3e588a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,6 +69,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -148,6 +157,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "email_address" version = "0.2.9" @@ -185,6 +200,7 @@ dependencies = [ "email_address", "fortifier-macros", "indexmap", + "phonenumber", "pretty_assertions", "regex", "serde", @@ -217,7 +233,7 @@ dependencies = [ "axum", "fortifier", "serde", - "thiserror", + "thiserror 2.0.17", "tokio", "utoipa", "utoipa-axum", @@ -233,6 +249,7 @@ dependencies = [ "email_address", "fortifier", "indexmap", + "phonenumber", "proc-macro-crate", "proc-macro2", "quote", @@ -299,6 +316,12 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "http" version = "1.4.0" @@ -523,6 +546,12 @@ version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "litemap" version = "0.8.1" @@ -535,6 +564,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "matchit" version = "0.8.4" @@ -553,6 +591,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.1.1" @@ -564,12 +608,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "oncemutex" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d11de466f4a3006fe8a5e7ec84e93b79c70cb992ae0aa0eb631ad2df8abfe2" + [[package]] name = "paste" version = "1.0.15" @@ -582,6 +642,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phonenumber" +version = "0.3.7+8.13.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2247167dc3741816fdd4d3690e97f56a892a264b44f4c702078b72d1f8b6bd40" +dependencies = [ + "bincode", + "either", + "fnv", + "nom", + "once_cell", + "quick-xml", + "regex", + "regex-cache", + "serde", + "serde_derive", + "strum", + "thiserror 1.0.69", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -653,6 +733,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.42" @@ -677,7 +766,7 @@ dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax", + "regex-syntax 0.8.8", ] [[package]] @@ -688,9 +777,27 @@ checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.8", +] + +[[package]] +name = "regex-cache" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f7b62d69743b8b94f353b6b7c3deb4c5582828328bcb8d5fedf214373808793" +dependencies = [ + "lru-cache", + "oncemutex", + "regex", + "regex-syntax 0.6.29", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.8" @@ -812,6 +919,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "syn" version = "2.0.110" @@ -855,13 +984,33 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f9229b2..a90bd34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ email_address = { version = "0.2.9", default-features = false } fortifier = { path = "./packages/fortifier", version = "0.0.1" } fortifier-macros = { path = "./packages/fortifier-macros", version = "0.0.1" } indexmap = "2.12.0" +phonenumber = "0.3.7" regex = "1.12.2" serde = "1.0.228" serde_json = "1.0.145" diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 31203a2..f8bc154 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -9,6 +9,7 @@ - [Validations](./validations/README.md) - [Email](./validations/email.md) - [Length](./validations/length.md) + - [Phone Number](./validations/phone-number.md) - [Regex]() - [URL](./validations/url.md) - [Integrations]() diff --git a/book/src/introduction.md b/book/src/introduction.md index f8f045f..c3a811d 100644 --- a/book/src/introduction.md +++ b/book/src/introduction.md @@ -2,11 +2,16 @@ Schema validation. -- Synchronous and asynchronous validation -- Enums and structs +- Synchronous & asynchronous validation +- Enums & structs - Typed errors -- Email, regex, URL and more -- Support for Serde and Utoipa +- Built-in validations + - Email + - Length + - Phone number + - Regex + - URL +- Support for Serde & Utoipa ## Credits diff --git a/book/src/validations/README.md b/book/src/validations/README.md index c17969b..eb96cf6 100644 --- a/book/src/validations/README.md +++ b/book/src/validations/README.md @@ -2,4 +2,5 @@ - [Email](./email.md) - [Length](./length.md) +- [Phone Number](./phone-number.md) - [URL](./url.md) diff --git a/book/src/validations/phone-number.md b/book/src/validations/phone-number.md new file mode 100644 index 0000000..11a785c --- /dev/null +++ b/book/src/validations/phone-number.md @@ -0,0 +1,89 @@ +# Phone Number + +> [!NOTE] +> Requires the `phone-number` feature. + +Validate a string is a specification-compliant phone number using the [`phonenumber`](https://docs.rs/phonenumber/latest/phonenumber/) crate. + +```rust +# extern crate fortifier; +# use fortifier::Validate; +# +##[derive(Validate)] +struct User { + #[validate(phone_number)] + phone_number: String +} +``` + +## Types + +### String + +- [`str`](https://doc.rust-lang.org/std/primitive.str.html) +- [`String`](https://doc.rust-lang.org/std/string/struct.String.html) + +Validate the string is a speficiation-compliant phone number. + +### Phone number + +- [`PhoneNumber`](https://docs.rs/phonenumber/latest/phonenumber/struct.PhoneNumber.html) + +Validate the value is a specification-compliant phone number. + +A `PhoneNumber` can be constructed with different options passed to [`phonenumber::parse`](https://docs.rs/phonenumber/latest/phonenumber/fn.parse.html), so re-validation is required. + +## Options + +### `allowed_countries` + +A list of allowed country codes. + +See [`phonenumber::country::Id`](https://docs.rs/phonenumber/latest/phonenumber/country/enum.Id.html) for available country codes. This enum is re-exported as [`fortifier::PhoneNumberCountry`]. + +```rust +# extern crate fortifier; +use fortifier::{PhoneNumberCountry, Validate}; + +#[derive(Validate)] +struct User<'a> { + #[validate(phone_number(allowed_countries = vec![PhoneNumberCountry::GB]))] + phone_number: &'a str +} + +fn main() { + let user = User { + phone_number: "+44 20 7946 0000" + }; + assert!(user.validate_sync().is_ok()); + + let user = User { + phone_number: "+31 6 12345678" + }; + assert!(user.validate_sync().is_err()); +} +``` + +### `default_country` + +Default country code to use when no country code is provided. + +See [`phonenumber::country::Id`](https://docs.rs/phonenumber/latest/phonenumber/country/enum.Id.html) for available country codes. This enum is re-exported as [`fortifier::PhoneNumberCountry`]. + +```rust +# extern crate fortifier; +use fortifier::{PhoneNumberCountry, Validate}; + +#[derive(Validate)] +struct User<'a> { + #[validate(phone_number(default_country = PhoneNumberCountry::GB))] + phone_number: &'a str +} + +fn main() { + let user = User { + phone_number: "020 7946 0000" + }; + assert!(user.validate_sync().is_ok()); +} +``` diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index 9678b55..22f6806 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -9,7 +9,12 @@ repository.workspace = true version.workspace = true [dependencies] -fortifier = { workspace = true, features = ["email", "regex", "url"] } +fortifier = { workspace = true, features = [ + "email", + "phone-number", + "regex", + "url", +] } regex.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index c8bcb49..b857151 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -10,8 +10,9 @@ use crate::{email_address::ChangeEmailAddressRelation, user::CreateUser}; #[tokio::main] async fn main() -> Result<(), Box> { let data = CreateUser { - email: "john@doe.com".to_owned(), name: "John Doe".to_owned(), + email: "john@doe.com".to_owned(), + phone_number: "+44 20 7946 0000".to_owned(), url: "https://john.doe.com".to_owned(), country_code: "GB".to_owned(), locales: vec!["en_GB".to_owned()], diff --git a/examples/basic/src/user.rs b/examples/basic/src/user.rs index e26a730..6fc2e2b 100644 --- a/examples/basic/src/user.rs +++ b/examples/basic/src/user.rs @@ -8,11 +8,14 @@ static COUNTRY_CODE_REGEX: LazyLock = #[derive(Validate)] pub struct CreateUser { + #[validate(length(min = 1, max = 256))] + pub name: String, + #[validate(email)] pub email: String, - #[validate(length(min = 1, max = 256))] - pub name: String, + #[validate(phone_number)] + pub phone_number: String, #[validate(url)] pub url: String, diff --git a/packages/fortifier-macros/Cargo.toml b/packages/fortifier-macros/Cargo.toml index 6a99e51..00de314 100644 --- a/packages/fortifier-macros/Cargo.toml +++ b/packages/fortifier-macros/Cargo.toml @@ -30,6 +30,7 @@ syn = "2.0.110" email_address.workspace = true fortifier = { workspace = true, features = ["all-validations", "indexmap"] } indexmap.workspace = true +phonenumber.workspace = true regex.workspace = true trybuild = "1.0.114" url.workspace = true diff --git a/packages/fortifier-macros/src/validate/field.rs b/packages/fortifier-macros/src/validate/field.rs index 3015b10..b37ecf1 100644 --- a/packages/fortifier-macros/src/validate/field.rs +++ b/packages/fortifier-macros/src/validate/field.rs @@ -6,7 +6,7 @@ use syn::{Field, Ident, Result, Visibility}; use crate::{ validate::{attributes::enum_attributes, r#type::should_validate_type}, validation::{Execution, Validation}, - validations::{Custom, Email, Length, Nested, Regex, Url}, + validations::{Custom, Email, Length, Nested, PhoneNumber, Regex, Url}, }; pub enum LiteralOrIdent { @@ -74,6 +74,12 @@ impl<'a> ValidateField<'a> { } else if meta.path.is_ident("length") { result.validations.push(Box::new(Length::parse(&meta)?)); + Ok(()) + } else if meta.path.is_ident("phone_number") { + result + .validations + .push(Box::new(PhoneNumber::parse(&meta)?)); + Ok(()) } else if meta.path.is_ident("regex") { result.validations.push(Box::new(Regex::parse(&meta)?)); diff --git a/packages/fortifier-macros/src/validations.rs b/packages/fortifier-macros/src/validations.rs index 7264db6..7ac870e 100644 --- a/packages/fortifier-macros/src/validations.rs +++ b/packages/fortifier-macros/src/validations.rs @@ -2,6 +2,7 @@ mod custom; mod email; mod length; mod nested; +mod phone_number; mod regex; mod url; @@ -9,5 +10,6 @@ pub use custom::*; pub use email::*; pub use length::*; pub use nested::*; +pub use phone_number::*; pub use regex::*; pub use url::*; diff --git a/packages/fortifier-macros/src/validations/phone_number.rs b/packages/fortifier-macros/src/validations/phone_number.rs new file mode 100644 index 0000000..5aa7351 --- /dev/null +++ b/packages/fortifier-macros/src/validations/phone_number.rs @@ -0,0 +1,64 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Expr, Ident, Result, meta::ParseNestedMeta}; + +use crate::validation::{Execution, Validation}; + +#[derive(Default)] +pub struct PhoneNumber { + allowed_countries: Option, + default_country: Option, +} + +impl Validation for PhoneNumber { + fn parse(meta: &ParseNestedMeta<'_>) -> Result { + let mut result = PhoneNumber::default(); + + if !meta.input.is_empty() { + meta.parse_nested_meta(|meta| { + if meta.path.is_ident("allowed_countries") { + let expr: Expr = meta.value()?.parse()?; + result.allowed_countries = Some(expr); + + Ok(()) + } else if meta.path.is_ident("default_country") { + let expr: Expr = meta.value()?.parse()?; + result.default_country = Some(expr); + + Ok(()) + } else { + Err(meta.error("unknown parameter")) + } + })?; + } + + Ok(result) + } + + fn ident(&self) -> Ident { + format_ident!("PhoneNumber") + } + fn error_type(&self) -> TokenStream { + quote!(::fortifier::PhoneNumberError) + } + + fn expr(&self, execution: Execution, expr: &TokenStream) -> Option { + match execution { + Execution::Sync => { + let allowed_countries = match &self.allowed_countries { + Some(allowed_countries) => quote!(Some(#allowed_countries)), + None => quote!(None), + }; + let default_country = match &self.default_country { + Some(default_country) => quote!(Some(#default_country)), + None => quote!(None), + }; + + Some(quote! { + ::fortifier::ValidatePhoneNumber::validate_phone_number(&#expr, #default_country, #allowed_countries) + }) + } + Execution::Async => None, + } + } +} diff --git a/packages/fortifier-macros/tests/validations/phone-number/invalid_allowed_countries_fail.rs b/packages/fortifier-macros/tests/validations/phone-number/invalid_allowed_countries_fail.rs new file mode 100644 index 0000000..da59d01 --- /dev/null +++ b/packages/fortifier-macros/tests/validations/phone-number/invalid_allowed_countries_fail.rs @@ -0,0 +1,9 @@ +use fortifier::{PhoneNumberCountry, Validate}; + +#[derive(Validate)] +struct PhoneNumberData<'a> { + #[validate(phone_number(allowed_countries = PhoneNumberCountry::NL))] + value: &'a str, +} + +fn main() {} diff --git a/packages/fortifier-macros/tests/validations/phone-number/invalid_allowed_countries_fail.stderr b/packages/fortifier-macros/tests/validations/phone-number/invalid_allowed_countries_fail.stderr new file mode 100644 index 0000000..59e2c08 --- /dev/null +++ b/packages/fortifier-macros/tests/validations/phone-number/invalid_allowed_countries_fail.stderr @@ -0,0 +1,25 @@ +error[E0308]: mismatched types + --> tests/validations/phone-number/invalid_allowed_countries_fail.rs:5:49 + | +3 | #[derive(Validate)] + | -------- arguments to this enum variant are incorrect +4 | struct PhoneNumberData<'a> { +5 | #[validate(phone_number(allowed_countries = PhoneNumberCountry::NL))] + | ^^^^^^^^^^^^^^^^^^^^^^ expected `Vec`, found `PhoneNumberCountry` + | + = note: expected struct `Vec` + found enum `PhoneNumberCountry` +help: the type constructed contains `PhoneNumberCountry` due to the type of the argument passed + --> tests/validations/phone-number/invalid_allowed_countries_fail.rs:3:10 + | +3 | #[derive(Validate)] + | ^^^^^^^^ +4 | struct PhoneNumberData<'a> { +5 | #[validate(phone_number(allowed_countries = PhoneNumberCountry::NL))] + | ---------------------- this argument influences the type of `Some` +note: tuple variant defined here + --> $RUST/core/src/option.rs + | + | Some(#[stable(feature = "rust1", since = "1.0.0")] T), + | ^^^^ + = note: this error originates in the derive macro `Validate` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/packages/fortifier-macros/tests/validations/phone-number/invalid_default_country_fail.rs b/packages/fortifier-macros/tests/validations/phone-number/invalid_default_country_fail.rs new file mode 100644 index 0000000..1885be0 --- /dev/null +++ b/packages/fortifier-macros/tests/validations/phone-number/invalid_default_country_fail.rs @@ -0,0 +1,9 @@ +use fortifier::Validate; + +#[derive(Validate)] +struct PhoneNumberData<'a> { + #[validate(phone_number(default_country = ZZ))] + value: &'a str, +} + +fn main() {} diff --git a/packages/fortifier-macros/tests/validations/phone-number/invalid_default_country_fail.stderr b/packages/fortifier-macros/tests/validations/phone-number/invalid_default_country_fail.stderr new file mode 100644 index 0000000..3927d4c --- /dev/null +++ b/packages/fortifier-macros/tests/validations/phone-number/invalid_default_country_fail.stderr @@ -0,0 +1,5 @@ +error[E0425]: cannot find value `ZZ` in this scope + --> tests/validations/phone-number/invalid_default_country_fail.rs:5:47 + | +5 | #[validate(phone_number(default_country = ZZ))] + | ^^ not found in this scope diff --git a/packages/fortifier-macros/tests/validations/phone-number/options_pass.rs b/packages/fortifier-macros/tests/validations/phone-number/options_pass.rs new file mode 100644 index 0000000..dfd103e --- /dev/null +++ b/packages/fortifier-macros/tests/validations/phone-number/options_pass.rs @@ -0,0 +1,38 @@ +use fortifier::{PhoneNumberCountry, PhoneNumberError, Validate, ValidationErrors}; +use phonenumber::ParseError; + +#[derive(Validate)] +struct PhoneNumberData<'a> { + #[validate(phone_number)] + international: &'a str, + #[validate(phone_number(default_country = PhoneNumberCountry::GB))] + default_country: &'a str, + #[validate(phone_number(allowed_countries = vec![PhoneNumberCountry::GB]))] + allowed_countries: &'a str, +} + +fn main() { + let data = PhoneNumberData { + international: "+31 6 123456789123456789", + default_country: "1", + allowed_countries: "+31 6 12345678", + }; + + assert_eq!( + data.validate_sync(), + Err(ValidationErrors::from_iter([ + PhoneNumberDataValidationError::International(PhoneNumberError::from( + ParseError::TooLong + )), + PhoneNumberDataValidationError::DefaultCountry(PhoneNumberError::from( + ParseError::TooShortNsn + )), + PhoneNumberDataValidationError::AllowedCountries( + PhoneNumberError::DisallowedCountryCode { + allowed: vec![PhoneNumberCountry::GB], + value: Some(PhoneNumberCountry::NL) + } + ), + ])) + ); +} diff --git a/packages/fortifier-macros/tests/validations/phone-number/types_pass.rs b/packages/fortifier-macros/tests/validations/phone-number/types_pass.rs new file mode 100644 index 0000000..9c23fda --- /dev/null +++ b/packages/fortifier-macros/tests/validations/phone-number/types_pass.rs @@ -0,0 +1,36 @@ +use std::str::FromStr; + +use fortifier::{PhoneNumberCountry, PhoneNumberError, Validate, ValidationErrors}; +use phonenumber::{ParseError, PhoneNumber}; + +#[derive(Validate)] +struct PhoneNumberData<'a> { + #[validate(phone_number)] + r#str: &'a str, + #[validate(phone_number)] + string: String, + #[validate(phone_number(allowed_countries = vec![PhoneNumberCountry::NL]))] + phone_number: PhoneNumber, +} + +fn main() { + let data = PhoneNumberData { + r#str: "abc", + string: "+44".to_owned(), + phone_number: PhoneNumber::from_str("+44 20 7946 0000").expect("valid phone number"), + }; + + assert_eq!( + data.validate_sync(), + Err(ValidationErrors::from_iter([ + PhoneNumberDataValidationError::Str(PhoneNumberError::from( + ParseError::InvalidCountryCode + )), + PhoneNumberDataValidationError::String(PhoneNumberError::from(ParseError::TooShortNsn)), + PhoneNumberDataValidationError::PhoneNumber(PhoneNumberError::DisallowedCountryCode { + allowed: vec![PhoneNumberCountry::NL], + value: Some(PhoneNumberCountry::GB), + }) + ])) + ); +} diff --git a/packages/fortifier-macros/tests/validations/phone-number/unknown_fail.rs b/packages/fortifier-macros/tests/validations/phone-number/unknown_fail.rs new file mode 100644 index 0000000..8b27bde --- /dev/null +++ b/packages/fortifier-macros/tests/validations/phone-number/unknown_fail.rs @@ -0,0 +1,9 @@ +use fortifier::Validate; + +#[derive(Validate)] +struct PhoneNumberData<'a> { + #[validate(phone_number(unknown = true))] + value: &'a str, +} + +fn main() {} diff --git a/packages/fortifier-macros/tests/validations/phone-number/unknown_fail.stderr b/packages/fortifier-macros/tests/validations/phone-number/unknown_fail.stderr new file mode 100644 index 0000000..e3e8d59 --- /dev/null +++ b/packages/fortifier-macros/tests/validations/phone-number/unknown_fail.stderr @@ -0,0 +1,5 @@ +error: unknown parameter + --> tests/validations/phone-number/unknown_fail.rs:5:29 + | +5 | #[validate(phone_number(unknown = true))] + | ^^^^^^^ diff --git a/packages/fortifier/Cargo.toml b/packages/fortifier/Cargo.toml index fc2295e..f69cfc4 100644 --- a/packages/fortifier/Cargo.toml +++ b/packages/fortifier/Cargo.toml @@ -13,11 +13,12 @@ all-features = true [features] default = ["macros"] -all-validations = ["email", "regex", "url"] +all-validations = ["email", "phone-number", "regex", "url"] email = ["dep:email_address"] indexmap = ["dep:indexmap"] macros = ["dep:fortifier-macros"] message = [] +phone-number = ["dep:phonenumber"] regex = ["dep:regex"] serde = ["dep:serde", "email_address?/serde_support", "fortifier-macros?/serde"] url = ["dep:url"] @@ -27,6 +28,7 @@ utoipa = ["dep:utoipa", "fortifier-macros?/utoipa"] email_address = { workspace = true, default-features = false, optional = true } fortifier-macros = { workspace = true, optional = true } indexmap = { workspace = true, optional = true } +phonenumber = { workspace = true, optional = true } regex = { workspace = true, optional = true } serde = { workspace = true, features = ["derive"], optional = true } url = { workspace = true, optional = true } diff --git a/packages/fortifier/src/validations.rs b/packages/fortifier/src/validations.rs index 3fef0e0..f1f9509 100644 --- a/packages/fortifier/src/validations.rs +++ b/packages/fortifier/src/validations.rs @@ -1,6 +1,8 @@ #[cfg(feature = "email")] mod email; mod length; +#[cfg(feature = "phone-number")] +mod phone_number; #[cfg(feature = "regex")] mod regex; #[cfg(feature = "url")] @@ -9,6 +11,8 @@ mod url; #[cfg(feature = "email")] pub use email::*; pub use length::*; +#[cfg(feature = "phone-number")] +pub use phone_number::*; #[cfg(feature = "regex")] pub use regex::*; #[cfg(feature = "url")] diff --git a/packages/fortifier/src/validations/phone_number.rs b/packages/fortifier/src/validations/phone_number.rs new file mode 100644 index 0000000..603751b --- /dev/null +++ b/packages/fortifier/src/validations/phone_number.rs @@ -0,0 +1,376 @@ +use std::{ + borrow::Cow, + cell::{Ref, RefMut}, + rc::Rc, + sync::Arc, +}; + +pub use phonenumber::country::Id as PhoneNumberCountry; +use phonenumber::{ParseError, PhoneNumber}; + +/// Phone number 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" + ) +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub enum PhoneNumberError { + /// No number error. + NoNumber { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Invalid country error. + InvalidCountryCode { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Too short after IDD error. + TooShortAfterIdd { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Too short NSN error. + TooShortNsn { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Too long error. + TooLong { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Malformed integer error. + MalformedInteger { + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, + /// Disallowed country code error. + DisallowedCountryCode { + /// Allowed country codes. + #[cfg_attr(feature = "utoipa", schema(value_type = Vec))] + allowed: Vec, + + /// The actual country code. + /// + /// `None` if the country calling code did not match a country. + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + value: Option, + + /// A human-readable error message. + #[cfg(feature = "message")] + message: String, + }, +} + +impl From for PhoneNumberError { + fn from(value: ParseError) -> Self { + match value { + ParseError::NoNumber => Self::NoNumber { + #[cfg(feature = "message")] + message: "no number".to_owned(), + }, + ParseError::InvalidCountryCode => Self::InvalidCountryCode { + #[cfg(feature = "message")] + message: "invalid country code".to_owned(), + }, + ParseError::TooShortAfterIdd => Self::TooShortAfterIdd { + #[cfg(feature = "message")] + message: "too short after IDD".to_owned(), + }, + ParseError::TooShortNsn => Self::TooShortNsn { + #[cfg(feature = "message")] + message: "too short NSN".to_owned(), + }, + ParseError::TooLong => Self::TooLong { + #[cfg(feature = "message")] + message: "too long".to_owned(), + }, + ParseError::MalformedInteger(_) => Self::MalformedInteger { + #[cfg(feature = "message")] + message: "malformed integer".to_owned(), + }, + } + } +} + +/// Validate a phone number. +pub trait ValidatePhoneNumber { + /// The phone number. + fn phone_number(&self) -> Option>; + + /// Validate phone number. + fn validate_phone_number( + &self, + default_country: Option, + allowed_countries: Option>, + ) -> Result<(), PhoneNumberError> { + let Some(phone_number) = self.phone_number() else { + return Ok(()); + }; + + let phone_number = + phonenumber::parse(default_country, &phone_number).map_err(PhoneNumberError::from)?; + + if let Some(allowed_countries) = allowed_countries { + match phone_number.country().id() { + Some(country) => { + if !allowed_countries.contains(&country) { + #[cfg(feature = "message")] + let message = format!( + "country code `{}` is not allowed, must be one of `{}`", + country.as_ref(), + allowed_countries + .iter() + .map(AsRef::as_ref) + .collect::>() + .join(", ") + ); + + return Err(PhoneNumberError::DisallowedCountryCode { + allowed: allowed_countries, + value: Some(country), + #[cfg(feature = "message")] + message, + }); + } + } + None => { + #[cfg(feature = "message")] + let message = format!( + "unknown country code, must be one of `{}`", + allowed_countries + .iter() + .map(AsRef::as_ref) + .collect::>() + .join(", ") + ); + + return Err(PhoneNumberError::DisallowedCountryCode { + allowed: allowed_countries, + value: None, + #[cfg(feature = "message")] + message, + }); + } + } + } + + Ok(()) + } +} + +impl ValidatePhoneNumber for str { + fn phone_number(&self) -> Option> { + Some(self.into()) + } +} + +impl ValidatePhoneNumber for &str { + fn phone_number(&self) -> Option> { + Some((*self).into()) + } +} + +impl ValidatePhoneNumber for String { + fn phone_number(&self) -> Option> { + Some(self.into()) + } +} + +impl ValidatePhoneNumber for Cow<'_, str> { + fn phone_number(&self) -> Option> { + Some(self.clone()) + } +} + +impl ValidatePhoneNumber for PhoneNumber { + fn phone_number(&self) -> Option> { + Some(self.to_string().into()) + } +} + +impl ValidatePhoneNumber for Option +where + T: ValidatePhoneNumber, +{ + fn phone_number(&self) -> Option> { + if let Some(s) = self { + T::phone_number(s) + } else { + None + } + } +} + +macro_rules! validate_with_deref { + ($type:ty) => { + impl ValidatePhoneNumber for $type + where + T: ValidatePhoneNumber, + { + fn phone_number(&self) -> Option> { + T::phone_number(self) + } + } + }; +} + +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>); + +#[cfg(test)] +mod tests { + use std::{borrow::Cow, cell::RefCell, rc::Rc, str::FromStr, sync::Arc}; + + use phonenumber::{ParseError, PhoneNumber}; + + use super::{PhoneNumberCountry, PhoneNumberError, ValidatePhoneNumber}; + + #[test] + fn ok() { + assert_eq!( + (*"+44 20 7946 0000").validate_phone_number(None, None), + Ok(()) + ); + assert_eq!("+44 20 7946 0000".validate_phone_number(None, None), Ok(())); + assert_eq!( + "+44 20 7946 0000" + .to_owned() + .validate_phone_number(None, None), + Ok(()) + ); + assert_eq!( + Cow::::Borrowed("+44 20 7946 0000").validate_phone_number(None, None), + Ok(()) + ); + assert_eq!( + Cow::::Owned("+44 20 7946 0000".to_owned()).validate_phone_number(None, None), + Ok(()) + ); + assert_eq!( + PhoneNumber::from_str("+44 20 7946 0000") + .expect("valid phone number") + .validate_phone_number(None, None), + Ok(()) + ); + + assert_eq!(None::<&str>.validate_phone_number(None, None), Ok(())); + assert_eq!( + Some("+44 20 7946 0000").validate_phone_number(None, None), + Ok(()) + ); + + assert_eq!( + (&"+44 20 7946 0000").validate_phone_number(None, None), + Ok(()) + ); + #[expect(unused_allocation)] + { + assert_eq!( + Box::new("+44 20 7946 0000").validate_phone_number(None, None), + Ok(()) + ); + } + assert_eq!( + Arc::new("+44 20 7946 0000").validate_phone_number(None, None), + Ok(()) + ); + assert_eq!( + Rc::new("+44 20 7946 0000").validate_phone_number(None, None), + Ok(()) + ); + + let cell = RefCell::new("+44 20 7946 0000"); + assert_eq!(cell.borrow().validate_phone_number(None, None), Ok(())); + assert_eq!(cell.borrow_mut().validate_phone_number(None, None), Ok(())); + } + + #[test] + fn invalid_error() { + assert_eq!( + (*"+44").validate_phone_number(None, None), + Err(PhoneNumberError::from(ParseError::TooShortNsn)) + ); + assert_eq!( + "+44".validate_phone_number(None, None), + Err(PhoneNumberError::from(ParseError::TooShortNsn)) + ); + assert_eq!( + "+44".to_owned().validate_phone_number(None, None), + Err(PhoneNumberError::from(ParseError::TooShortNsn)) + ); + assert_eq!( + Cow::::Borrowed("+44").validate_phone_number(None, None), + Err(PhoneNumberError::from(ParseError::TooShortNsn)) + ); + assert_eq!( + Cow::::Owned("+44".to_owned()).validate_phone_number(None, None), + Err(PhoneNumberError::from(ParseError::TooShortNsn)) + ); + + assert_eq!( + Some("+44").validate_phone_number(None, None), + Err(PhoneNumberError::from(ParseError::TooShortNsn)) + ); + + assert_eq!( + (&"+44").validate_phone_number(None, None), + Err(PhoneNumberError::from(ParseError::TooShortNsn)) + ); + #[expect(unused_allocation)] + { + assert_eq!( + Box::new("+44").validate_phone_number(None, None), + Err(PhoneNumberError::from(ParseError::TooShortNsn)) + ); + } + assert_eq!( + Arc::new("+44").validate_phone_number(None, None), + Err(PhoneNumberError::from(ParseError::TooShortNsn)) + ); + assert_eq!( + Rc::new("+44").validate_phone_number(None, None), + Err(PhoneNumberError::from(ParseError::TooShortNsn)) + ); + + let cell = RefCell::new("+44"); + assert_eq!( + cell.borrow().validate_phone_number(None, None), + Err(PhoneNumberError::from(ParseError::TooShortNsn)) + ); + assert_eq!( + cell.borrow_mut().validate_phone_number(None, None), + Err(PhoneNumberError::from(ParseError::TooShortNsn)) + ); + } + + #[test] + fn disallowed_country_code_error() { + assert_eq!( + "+44 20 7946 0000".validate_phone_number(None, Some(vec![PhoneNumberCountry::NL])), + Err(PhoneNumberError::DisallowedCountryCode { + allowed: vec![PhoneNumberCountry::NL], + value: Some(PhoneNumberCountry::GB), + #[cfg(feature = "message")] + message: "country code `GB` is not allowed, must be one of `NL`".to_owned() + }) + ); + } +}