diff --git a/Cargo.lock b/Cargo.lock index 18489ea..35698a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "convert_case" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db05ffb6856bf0ecdf6367558a76a0e8a77b1713044eb92845c692100ed50190" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -13,12 +22,21 @@ name = "fortifier" version = "0.0.1" dependencies = [ "fortifier-macros", + "indexmap", +] + +[[package]] +name = "fortifier-example" +version = "0.0.1" +dependencies = [ + "fortifier", ] [[package]] name = "fortifier-macros" version = "0.0.1" dependencies = [ + "convert_case", "fortifier", "proc-macro2", "quote", @@ -221,6 +239,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "winapi-util" version = "0.1.11" diff --git a/Cargo.toml b/Cargo.toml index 1c70cfe..de53bea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["packages/*"] +members = ["example", "packages/*"] resolver = "2" [workspace.package] diff --git a/README.md b/README.md index 208f91d..68400b4 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ Schema validation. See [the Fortifier book](https://fortifier.rustforweb.org/) for documentation. +## Credits + +Inspired by [`validator`](https://github.com/Keats/validator). + ## License This project is available under the [MIT license](LICENSE.md). diff --git a/book/book.toml b/book/book.toml index 09667c1..968849e 100644 --- a/book/book.toml +++ b/book/book.toml @@ -1,7 +1,6 @@ [book] authors = ["Daniƫlle Huisman"] language = "en" -multilingual = false src = "src" title = "Fortifier" diff --git a/book/src/introduction.md b/book/src/introduction.md index ffec188..a0e6c18 100644 --- a/book/src/introduction.md +++ b/book/src/introduction.md @@ -2,6 +2,10 @@ Schema validation. +## Credits + +Inspired by [`validator`](https://github.com/Keats/validator). + ## License This project is available under the [MIT license](https://github.com/RustForWeb/fortifier/blob/main/LICENSE.md). diff --git a/example/Cargo.toml b/example/Cargo.toml new file mode 100644 index 0000000..3a804b1 --- /dev/null +++ b/example/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fortifier-example" +description = "Fortifier example." + +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +fortifier.workspace = true diff --git a/example/src/main.rs b/example/src/main.rs new file mode 100644 index 0000000..abe2f6d --- /dev/null +++ b/example/src/main.rs @@ -0,0 +1,23 @@ +use std::error::Error; + +use fortifier::Validate; + +#[derive(Validate)] +struct CreateUser { + #[validate(email)] + email: String, + + #[validate(length(min = 1, max = 256))] + name: String, +} + +fn main() -> Result<(), Box> { + let data = CreateUser { + email: "john@doe.com".to_owned(), + name: "John Doe".to_owned(), + }; + + data.validate_sync()?; + + Ok(()) +} diff --git a/packages/fortifier-macros/Cargo.toml b/packages/fortifier-macros/Cargo.toml index 00b0eb8..e90d686 100644 --- a/packages/fortifier-macros/Cargo.toml +++ b/packages/fortifier-macros/Cargo.toml @@ -12,6 +12,7 @@ version.workspace = true proc-macro = true [dependencies] +convert_case = "0.9.0" proc-macro2 = "1.0.103" quote = "1.0.42" syn = "2.0.110" diff --git a/packages/fortifier-macros/src/derive.rs b/packages/fortifier-macros/src/derive.rs deleted file mode 100644 index 645afdb..0000000 --- a/packages/fortifier-macros/src/derive.rs +++ /dev/null @@ -1,47 +0,0 @@ -use proc_macro2::TokenStream; -use quote::{format_ident, quote}; -use syn::{Data, DeriveInput, Fields}; - -pub fn validate_tokens(input: DeriveInput) -> TokenStream { - match input.data { - Data::Struct(data_struct) => match data_struct.fields { - Fields::Named(_fields_named) => { - // TODO - } - Fields::Unnamed(_fields_unnamed) => todo!("fields unamed"), - Fields::Unit => todo!("fields unit"), - }, - Data::Enum(_data_enum) => todo!("data enum"), - Data::Union(_data_union) => todo!("data union"), - } - - let ident = input.ident; - let error_ident = format_ident!("{ident}ValidationError"); - - quote! { - #[derive(Debug)] - struct #error_ident {} - - impl ::std::fmt::Display for #error_ident { - fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { - write!(f, "") - } - } - - impl ::std::error::Error for #error_ident {} - - impl Validate for #ident { - type Error = #error_ident; - - fn validate_sync(&self) -> Result<(), Self::Error> { - Ok(()) - } - - fn validate_async(&self) -> ::std::pin::Pin>>> { - Box::pin(async { - Ok(()) - }) - } - } - } -} diff --git a/packages/fortifier-macros/src/lib.rs b/packages/fortifier-macros/src/lib.rs index c80b31a..200ca3e 100644 --- a/packages/fortifier-macros/src/lib.rs +++ b/packages/fortifier-macros/src/lib.rs @@ -1,13 +1,16 @@ -mod derive; +mod validate; +mod validations; use proc_macro::TokenStream; -use syn::{DeriveInput, parse_macro_input}; +use syn::{DeriveInput, Error, parse_macro_input}; -use crate::derive::validate_tokens; +use crate::validate::validate_tokens; #[proc_macro_derive(Validate, attributes(validate))] pub fn derive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); - validate_tokens(input).into() + validate_tokens(input) + .unwrap_or_else(Error::into_compile_error) + .into() } diff --git a/packages/fortifier-macros/src/validate.rs b/packages/fortifier-macros/src/validate.rs new file mode 100644 index 0000000..9012709 --- /dev/null +++ b/packages/fortifier-macros/src/validate.rs @@ -0,0 +1,22 @@ +mod r#enum; +mod r#struct; +mod r#union; + +use proc_macro2::TokenStream; +use quote::format_ident; +use syn::{Data, DeriveInput, Result}; + +use crate::validate::{ + r#enum::validate_enum, r#struct::validate_struct_tokens, union::validate_union, +}; + +pub fn validate_tokens(input: DeriveInput) -> Result { + let ident = input.ident; + let error_ident = format_ident!("{ident}ValidationError"); + + match input.data { + Data::Struct(data) => validate_struct_tokens(ident, error_ident, data), + Data::Enum(data) => validate_enum(ident, error_ident, data), + Data::Union(data) => validate_union(ident, error_ident, data), + } +} diff --git a/packages/fortifier-macros/src/validate/enum.rs b/packages/fortifier-macros/src/validate/enum.rs new file mode 100644 index 0000000..908c36a --- /dev/null +++ b/packages/fortifier-macros/src/validate/enum.rs @@ -0,0 +1,6 @@ +use proc_macro2::TokenStream; +use syn::{DataEnum, Ident, Result}; + +pub fn validate_enum(_ident: Ident, _error_ident: Ident, _data: DataEnum) -> Result { + todo!("enum") +} diff --git a/packages/fortifier-macros/src/validate/struct.rs b/packages/fortifier-macros/src/validate/struct.rs new file mode 100644 index 0000000..b3c2e57 --- /dev/null +++ b/packages/fortifier-macros/src/validate/struct.rs @@ -0,0 +1,130 @@ +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{DataStruct, Field, Fields, Ident, Result}; + +use crate::validations::{email_tokens, length_tokens, parse_email, parse_length}; + +pub fn validate_struct_tokens( + ident: Ident, + error_ident: Ident, + data: DataStruct, +) -> Result { + match data.fields { + Fields::Named(fields_named) => { + validate_named_struct_tokens(ident, error_ident, fields_named.named.into_iter()) + } + Fields::Unnamed(_fields_unnamed) => todo!("fields unamed"), + Fields::Unit => todo!("fields unit"), + } +} + +fn validate_named_struct_tokens( + ident: Ident, + error_ident: Ident, + fields: impl Iterator, +) -> Result { + let mut field_names = vec![]; + let mut field_types = vec![]; + let mut sync_validations = vec![]; + // let async_validations = vec![]; + + for field in fields { + let Some(field_ident) = field.ident else { + continue; + }; + + let field_error_ident = + format_ident!("{}", &field_ident.to_string().to_case(Case::UpperCamel)); + + for attr in field.attrs { + if attr.path().is_ident("validate") { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("email") { + let email = parse_email(&meta)?; + + field_names.push(field_error_ident.clone()); + field_types.push(quote!(::fortifier::EmailError)); + + sync_validations.push(email_tokens( + email, + &error_ident, + &field_ident, + &field_error_ident, + )); + + Ok(()) + } else if meta.path.is_ident("length") { + let length = parse_length(&meta)?; + + field_names.push(field_error_ident.clone()); + field_types.push(quote!(::fortifier::LengthError)); + + sync_validations.push(length_tokens( + length, + &error_ident, + &field_ident, + &field_error_ident, + )); + + Ok(()) + } else { + Err(meta.error("unknown validate parameter")) + } + })?; + } + } + } + + Ok(quote! { + use fortifier::*; + + #[derive(Debug)] + enum #error_ident { + #( #field_names(#field_types) ),* + } + + impl ::std::fmt::Display for #error_ident { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + write!(f, "{self:#?}") + } + } + + impl ::std::error::Error for #error_ident {} + + impl Validate for #ident { + type Error = #error_ident; + + fn validate_sync(&self) -> Result<(), ValidationErrors> { + use ::fortifier::*; + + let mut errors = vec![]; + + #(#sync_validations)* + + if !errors.is_empty() { + Err(errors.into()) + } else { + Ok(()) + } + } + + fn validate_async(&self) -> ::std::pin::Pin>>>> { + use ::fortifier::*; + + Box::pin(async { + + let mut errors = vec![]; + + // #(#async_validations)* + + if !errors.is_empty() { + Err(errors.into()) + } else { + Ok(()) + } + }) + } + } + }) +} diff --git a/packages/fortifier-macros/src/validate/union.rs b/packages/fortifier-macros/src/validate/union.rs new file mode 100644 index 0000000..53ef7c1 --- /dev/null +++ b/packages/fortifier-macros/src/validate/union.rs @@ -0,0 +1,6 @@ +use proc_macro2::TokenStream; +use syn::{DataUnion, Ident, Result}; + +pub fn validate_union(_ident: Ident, _error_ident: Ident, _data: DataUnion) -> Result { + todo!("union") +} diff --git a/packages/fortifier-macros/src/validations.rs b/packages/fortifier-macros/src/validations.rs new file mode 100644 index 0000000..0b1b292 --- /dev/null +++ b/packages/fortifier-macros/src/validations.rs @@ -0,0 +1,5 @@ +mod email; +mod length; + +pub use email::*; +pub use length::*; diff --git a/packages/fortifier-macros/src/validations/email.rs b/packages/fortifier-macros/src/validations/email.rs new file mode 100644 index 0000000..09369f9 --- /dev/null +++ b/packages/fortifier-macros/src/validations/email.rs @@ -0,0 +1,23 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Ident, Result, meta::ParseNestedMeta}; + +#[derive(Default)] +pub struct Email {} + +pub fn parse_email(_meta: &ParseNestedMeta<'_>) -> Result { + Ok(Email::default()) +} + +pub fn email_tokens( + _email: Email, + error_ident: &Ident, + field_ident: &Ident, + field_error_ident: &Ident, +) -> TokenStream { + quote! { + if let Err(err) = self.#field_ident.validate_email() { + errors.push(#error_ident::#field_error_ident(err)); + } + } +} diff --git a/packages/fortifier-macros/src/validations/length.rs b/packages/fortifier-macros/src/validations/length.rs new file mode 100644 index 0000000..9632224 --- /dev/null +++ b/packages/fortifier-macros/src/validations/length.rs @@ -0,0 +1,66 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Expr, Ident, Result, meta::ParseNestedMeta}; + +#[derive(Default)] +pub struct Length { + pub equal: Option, + pub min: Option, + pub max: Option, +} + +pub fn parse_length(meta: &ParseNestedMeta<'_>) -> Result { + let mut length = Length::default(); + + meta.parse_nested_meta(|meta| { + if meta.path.is_ident("equal") { + let expr: Expr = meta.value()?.parse()?; + length.equal = Some(expr); + + Ok(()) + } else if meta.path.is_ident("min") { + let expr: Expr = meta.value()?.parse()?; + length.min = Some(expr); + + Ok(()) + } else if meta.path.is_ident("max") { + let expr: Expr = meta.value()?.parse()?; + length.max = Some(expr); + + Ok(()) + } else { + Err(meta.error("unknown length parameter")) + } + })?; + + Ok(length) +} + +pub fn length_tokens( + length: Length, + error_ident: &Ident, + field_ident: &Ident, + field_error_ident: &Ident, +) -> TokenStream { + let equal = if let Some(equal) = length.equal { + quote!(Some(#equal)) + } else { + quote!(None) + }; + let min = if let Some(min) = length.min { + quote!(Some(#min)) + } else { + quote!(None) + }; + let max = if let Some(max) = length.max { + quote!(Some(#max)) + } else { + quote!(None) + }; + + quote! { + if let Err(err) = self.#field_ident.validate_length(#equal, #min, #max) { + errors.push(#error_ident::#field_error_ident(err)); + } + } +} diff --git a/packages/fortifier/Cargo.toml b/packages/fortifier/Cargo.toml index 7ad814c..c6d8e72 100644 --- a/packages/fortifier/Cargo.toml +++ b/packages/fortifier/Cargo.toml @@ -10,7 +10,9 @@ version.workspace = true [features] default = ["macros"] +indexmap = ["dep:indexmap"] macros = ["dep:fortifier-macros"] [dependencies] fortifier-macros = { workspace = true, optional = true } +indexmap = { version = "2.12.0", optional = true } diff --git a/packages/fortifier/src/lib.rs b/packages/fortifier/src/lib.rs index f1b88ef..f0b25f9 100644 --- a/packages/fortifier/src/lib.rs +++ b/packages/fortifier/src/lib.rs @@ -1,6 +1,8 @@ mod validate; +mod validations; pub use validate::*; +pub use validations::*; #[cfg(feature = "macros")] pub use fortifier_macros::*; diff --git a/packages/fortifier/src/validate.rs b/packages/fortifier/src/validate.rs index c6e528b..aafe8ba 100644 --- a/packages/fortifier/src/validate.rs +++ b/packages/fortifier/src/validate.rs @@ -1,9 +1,32 @@ -use std::{error::Error, pin::Pin}; +use std::{ + error::Error, + fmt::{self, Display}, + pin::Pin, +}; + +#[derive(Debug)] +pub struct ValidationErrors(Vec); + +impl Display for ValidationErrors { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self.0) + } +} + +impl Error for ValidationErrors {} + +impl From> for ValidationErrors { + fn from(value: Vec) -> Self { + Self(value) + } +} pub trait Validate { type Error: Error; - fn validate(&self) -> Pin> + Send>> + fn validate( + &self, + ) -> Pin>> + Send>> where Self: Sync, { @@ -13,7 +36,9 @@ pub trait Validate { }) } - fn validate_sync(&self) -> Result<(), Self::Error>; + fn validate_sync(&self) -> Result<(), ValidationErrors>; - fn validate_async(&self) -> Pin> + Send>>; + fn validate_async( + &self, + ) -> Pin>> + Send>>; } diff --git a/packages/fortifier/src/validations.rs b/packages/fortifier/src/validations.rs new file mode 100644 index 0000000..0b1b292 --- /dev/null +++ b/packages/fortifier/src/validations.rs @@ -0,0 +1,5 @@ +mod email; +mod length; + +pub use email::*; +pub use length::*; diff --git a/packages/fortifier/src/validations/email.rs b/packages/fortifier/src/validations/email.rs new file mode 100644 index 0000000..a1c0d1d --- /dev/null +++ b/packages/fortifier/src/validations/email.rs @@ -0,0 +1,80 @@ +use std::{ + borrow::Cow, + cell::{Ref, RefMut}, + rc::Rc, + sync::Arc, +}; + +#[derive(Debug)] +pub enum EmailError { + Invalid, +} + +pub trait ValidateEmail { + fn email(&self) -> Option>; + + fn validate_email(&self) -> Result<(), EmailError> { + let Some(email) = self.email() else { + return Ok(()); + }; + + if email.is_empty() || !email.contains("@") { + return Err(EmailError::Invalid); + } + + // TODO + + Ok(()) + } +} + +macro_rules! validate_type_with_deref { + ($type:ty) => { + impl ValidateEmail for $type + where + T: ValidateEmail, + { + fn email(&self) -> Option> { + T::email(self) + } + } + }; +} + +validate_type_with_deref!(&T); +validate_type_with_deref!(Arc); +validate_type_with_deref!(Box); +validate_type_with_deref!(Rc); +validate_type_with_deref!(Ref<'_, T>); +validate_type_with_deref!(RefMut<'_, T>); + +impl ValidateEmail for &str { + fn email(&self) -> Option> { + Some((*self).into()) + } +} + +impl ValidateEmail for String { + fn email(&self) -> Option> { + Some(self.into()) + } +} + +impl ValidateEmail for Cow<'_, str> { + fn email(&self) -> Option> { + Some(self.clone()) + } +} + +impl ValidateEmail for Option +where + T: ValidateEmail, +{ + fn email(&self) -> Option> { + if let Some(s) = self { + T::email(s) + } else { + None + } + } +} diff --git a/packages/fortifier/src/validations/length.rs b/packages/fortifier/src/validations/length.rs new file mode 100644 index 0000000..eb75251 --- /dev/null +++ b/packages/fortifier/src/validations/length.rs @@ -0,0 +1,143 @@ +use std::{ + borrow::Cow, + cell::{Ref, RefMut}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}, + rc::Rc, + sync::Arc, +}; + +#[cfg(feature = "indexmap")] +use indexmap::{IndexMap, IndexSet}; + +#[derive(Debug)] +pub enum LengthError { + Equal { equal: T, length: T }, + Min { min: T, length: T }, + Max { max: T, length: T }, +} + +pub trait ValidateLength +where + T: PartialEq + PartialOrd, +{ + fn length(&self) -> Option; + + fn validate_length( + &self, + equal: Option, + min: Option, + max: Option, + ) -> Result<(), LengthError> { + let Some(length) = self.length() else { + return Ok(()); + }; + + if let Some(equal) = equal { + if length != equal { + return Err(LengthError::Equal { equal, length }); + } + } else { + if let Some(min) = min + && length < min + { + return Err(LengthError::Min { min, length }); + } + + if let Some(max) = max + && length > max + { + return Err(LengthError::Max { max, length }); + } + } + + Ok(()) + } +} + +macro_rules! validate_type_with_deref { + ($type:ty) => { + impl ValidateLength for $type + where + T: ValidateLength, + { + fn length(&self) -> Option { + T::length(self) + } + } + }; +} + +validate_type_with_deref!(&T); +validate_type_with_deref!(Arc); +validate_type_with_deref!(Box); +validate_type_with_deref!(Rc); +validate_type_with_deref!(Ref<'_, T>); +validate_type_with_deref!(RefMut<'_, T>); + +macro_rules! validate_type_with_chars { + ($type:ty) => { + impl ValidateLength for $type { + fn length(&self) -> Option { + Some(self.chars().count()) + } + } + }; +} + +validate_type_with_chars!(str); +validate_type_with_chars!(&str); +validate_type_with_chars!(String); + +macro_rules! validate_type_with_len { + ($type:ty) => { + validate_type_with_len!($type,); + }; + ($type:ty, $( $generic:ident ),*$( , )*) => { + impl<$( $generic ),*> ValidateLength for $type { + fn length(&self) -> Option { + Some(self.len()) + } + } + }; +} + +validate_type_with_len!([T], T); +validate_type_with_len!(BTreeSet, T); +validate_type_with_len!(BTreeMap, K, V); +validate_type_with_len!(HashSet, T, S); +validate_type_with_len!(HashMap, K, V, S); +validate_type_with_len!(Vec, T); +validate_type_with_len!(VecDeque, T); +#[cfg(feature = "indexmap")] +validate_type_with_len!(IndexSet, T); +#[cfg(feature = "indexmap")] +validate_type_with_len!(IndexMap, K, V); + +impl ValidateLength for Cow<'_, T> +where + T: ToOwned + ?Sized, + for<'a> &'a T: ValidateLength, +{ + fn length(&self) -> Option { + self.as_ref().length() + } +} + +impl ValidateLength for Option +where + T: ValidateLength, +{ + fn length(&self) -> Option { + if let Some(s) = self { + T::length(s) + } else { + None + } + } +} + +impl ValidateLength for [T; N] { + fn length(&self) -> Option { + Some(N) + } +}