diff --git a/packages/fortifier-macros/src/validate/enum.rs b/packages/fortifier-macros/src/validate/enum.rs index 833bba4..48cdbd3 100644 --- a/packages/fortifier-macros/src/validate/enum.rs +++ b/packages/fortifier-macros/src/validate/enum.rs @@ -5,7 +5,7 @@ use syn::{DataEnum, DeriveInput, Ident, Result, Variant, Visibility}; use crate::{ validate::{ attributes::enum_attributes, - field::{LiteralOrIdent, ValidateFieldPrefix}, + field::{LiteralOrIdent, ValidateFieldPrefix, format_error_ident}, fields::ValidateFields, }, validation::Execution, @@ -23,7 +23,7 @@ impl<'a> ValidateEnum<'a> { let mut result = ValidateEnum { visibility: &input.vis, ident: &input.ident, - error_ident: format_ident!("{}ValidationError", input.ident), + error_ident: format_error_ident(&input.ident), variants: Vec::with_capacity(data.variants.len()), }; diff --git a/packages/fortifier-macros/src/validate/field.rs b/packages/fortifier-macros/src/validate/field.rs index 8aa3f97..962b17a 100644 --- a/packages/fortifier-macros/src/validate/field.rs +++ b/packages/fortifier-macros/src/validate/field.rs @@ -1,10 +1,13 @@ use convert_case::{Case, Casing}; use proc_macro2::{Literal, TokenStream}; use quote::{ToTokens, format_ident, quote}; -use syn::{Field, Ident, Result, Visibility}; +use syn::{Error, Field, Ident, Result, Visibility}; use crate::{ - validate::{attributes::enum_attributes, r#type::should_validate_type}, + validate::{ + attributes::enum_attributes, + r#type::{KnownOrUnknown, should_validate_type}, + }, validation::{Execution, Validation}, validations::{Custom, EmailAddress, Length, Nested, PhoneNumber, Regex, Url}, }; @@ -49,7 +52,7 @@ impl<'a> ValidateField<'a> { LiteralOrIdent::Literal(literal) => format_ident!("F{literal}"), LiteralOrIdent::Ident(ident) => upper_camel_ident(ident), }; - let error_type_ident = format_ident!("{type_prefix}{error_ident}ValidationError"); + let error_type_ident = format_error_ident_with_prefix(type_prefix, &error_ident); let mut result = Self { visibility, @@ -58,7 +61,7 @@ impl<'a> ValidateField<'a> { error_type_ident, validations: vec![], }; - let mut skip = false; + let mut skip_nested = false; for attr in &field.attrs { if attr.path().is_ident("validate") { @@ -76,6 +79,11 @@ 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("nested") { + result.validations.push(Box::new(Nested::parse(&meta)?)); + skip_nested = true; + Ok(()) } else if meta.path.is_ident("phone_number") { result @@ -92,7 +100,7 @@ impl<'a> ValidateField<'a> { Ok(()) } else if meta.path.is_ident("skip") { - skip = true; + skip_nested = true; Ok(()) } else { @@ -104,9 +112,18 @@ impl<'a> ValidateField<'a> { // TODO: Use enum/struct generics to determine if a generic field type supports nested validation. // TODO: Remove the validations empty check after resolving the issue above. - if !skip && result.validations.is_empty() && should_validate_type(&field.ty) { - // TODO: Nested validation - result.validations.push(Box::new(Nested::new())); + if !skip_nested + && result.validations.is_empty() + && let Some(nested_type) = should_validate_type(&field.ty) + { + if let KnownOrUnknown::Known(nested_type) = nested_type { + result.validations.push(Box::new(Nested::new(nested_type))); + } else { + return Err(Error::new_spanned( + field, + "error type must be specified using `#[validate(nested(error_type = MyErrorType))]`", + )); + } } Ok(result) @@ -124,7 +141,7 @@ impl<'a> ValidateField<'a> { if self.validations.len() > 1 { let attributes = enum_attributes(); let visibility = &self.visibility; - let ident = format_ident!("{}{}ValidationError", ident, self.error_ident); + let ident = format_error_ident_with_prefix(ident, &self.error_ident); let variant_ident = self.validations.iter().map(|validation| validation.ident()); let variant_type = self .validations @@ -194,3 +211,11 @@ fn upper_camel_ident(ident: &Ident) -> Ident { format_ident!("{}", s.to_case(Case::UpperCamel)) } } + +pub fn format_error_ident(ident: &Ident) -> Ident { + format_ident!("{}ValidationError", ident) +} + +pub fn format_error_ident_with_prefix(prefix: &Ident, ident: &Ident) -> Ident { + format_ident!("{}{}ValidationError", prefix, ident) +} diff --git a/packages/fortifier-macros/src/validate/fields.rs b/packages/fortifier-macros/src/validate/fields.rs index 9c3c743..2c0a00f 100644 --- a/packages/fortifier-macros/src/validate/fields.rs +++ b/packages/fortifier-macros/src/validate/fields.rs @@ -1,11 +1,11 @@ use proc_macro2::{Literal, TokenStream}; -use quote::{ToTokens, format_ident, quote}; +use quote::{ToTokens, quote}; use syn::{Fields, FieldsNamed, FieldsUnnamed, Ident, Result, Visibility}; use crate::{ validate::{ attributes::enum_attributes, - field::{LiteralOrIdent, ValidateField, ValidateFieldPrefix}, + field::{LiteralOrIdent, ValidateField, ValidateFieldPrefix, format_error_ident}, }, validation::Execution, }; @@ -64,7 +64,7 @@ pub struct ValidateNamedFields<'a> { impl<'a> ValidateNamedFields<'a> { fn parse(visibility: &'a Visibility, ident: Ident, fields: &'a FieldsNamed) -> Result { - let error_ident = format_ident!("{}ValidationError", ident); + let error_ident = format_error_ident(&ident); let mut result = Self { visibility, @@ -127,7 +127,7 @@ pub struct ValidateUnnamedFields<'a> { impl<'a> ValidateUnnamedFields<'a> { fn parse(visibility: &'a Visibility, ident: Ident, fields: &'a FieldsUnnamed) -> Result { - let error_ident = format_ident!("{}ValidationError", ident); + let error_ident = format_error_ident(&ident); let mut result = Self { visibility, diff --git a/packages/fortifier-macros/src/validate/type.rs b/packages/fortifier-macros/src/validate/type.rs index eb04fa0..f611baf 100644 --- a/packages/fortifier-macros/src/validate/type.rs +++ b/packages/fortifier-macros/src/validate/type.rs @@ -1,24 +1,24 @@ +use proc_macro2::TokenStream; +use quote::{ToTokens, quote}; use syn::{GenericArgument, Path, PathArguments, Type, TypeParamBound}; +use crate::validate::field::format_error_ident; + const PRIMITIVE_AND_BUILT_IN_TYPES: [&str; 18] = [ "bool", "i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", "u32", "u64", "u128", "usize", "f32", "f64", "char", "str", "String", ]; -const CONTAINER_TYPES: [&str; 20] = [ +const INDEXED_CONTAINER_TYPES: [&str; 16] = [ "Arc", - "BTreeMap", "BTreeSet", - "HashMap", "HashSet", "LinkedList", "Option", "Rc", "Vec", "VecDeque", - "std::collections::BTreeMap", "std::collections::BTreeSet", - "std::collections::HashMap", "std::collections::HashSet", "std::collections::LinkedList", "std::collections::VecDeque", @@ -28,6 +28,13 @@ const CONTAINER_TYPES: [&str; 20] = [ "std::vec::Vec", ]; +const KEYED_CONTAINER_TYPES: [&str; 4] = [ + "BTreeMap", + "HashMap", + "std::collections::BTreeMap", + "std::collections::HashMap", +]; + fn path_to_string(path: &Path) -> String { // TODO: This is probably slow, replace with comparisons. path.segments @@ -42,46 +49,75 @@ fn is_validate_path(path: &Path) -> bool { path_string == "Validate" || path_string == "fortifier::Validate" } -fn should_validate_generic_argument(arg: &GenericArgument) -> bool { +fn should_validate_generic_argument(arg: &GenericArgument) -> Option> { match arg { - GenericArgument::Lifetime(_) => true, + GenericArgument::Lifetime(_) => Some(KnownOrUnknown::Unknown), GenericArgument::Type(r#type) => should_validate_type(r#type), GenericArgument::Const(_expr) => todo!(), GenericArgument::AssocType(_assoc_type) => todo!(), GenericArgument::AssocConst(_assoc_const) => todo!(), GenericArgument::Constraint(_constraint) => todo!(), - _ => true, + _ => Some(KnownOrUnknown::Unknown), } } -fn should_validate_path(path: &Path) -> bool { +fn should_validate_path(path: &Path) -> Option> { if let Some(ident) = path.get_ident() { - return !PRIMITIVE_AND_BUILT_IN_TYPES.contains(&ident.to_string().as_str()); + return if PRIMITIVE_AND_BUILT_IN_TYPES.contains(&ident.to_string().as_str()) { + None + } else { + Some(KnownOrUnknown::Known( + format_error_ident(ident).to_token_stream(), + )) + }; } let path_string = path_to_string(path); + let path_string = path_string.as_str(); - if CONTAINER_TYPES.contains(&path_string.as_str()) + if INDEXED_CONTAINER_TYPES.contains(&path_string) && let Some(segment) = path.segments.last() && let PathArguments::AngleBracketed(arguments) = &segment.arguments - && !arguments.args.iter().all(should_validate_generic_argument) + && let Some(argument) = arguments.args.first() { - return false; + return should_validate_generic_argument(argument).map(|error_type| match error_type { + KnownOrUnknown::Known(error_type) => KnownOrUnknown::Known( + quote!(::fortifier::ValidationErrors<::fortifier::IndexedValidationError<#error_type>>) + ), + KnownOrUnknown::Unknown => KnownOrUnknown::Unknown + }); } - true + // TODO: Determine error type. + if KEYED_CONTAINER_TYPES.contains(&path_string) + && let Some(segment) = path.segments.last() + && let PathArguments::AngleBracketed(arguments) = &segment.arguments + && !arguments + .args + .iter() + .all(|arg| should_validate_generic_argument(arg).is_some()) + { + return None; + } + + Some(KnownOrUnknown::Unknown) +} + +pub enum KnownOrUnknown { + Known(T), + Unknown, } -pub fn should_validate_type(r#type: &Type) -> bool { +pub fn should_validate_type(r#type: &Type) -> Option> { match r#type { Type::Array(r#type) => should_validate_type(&r#type.elem), - Type::BareFn(_) => false, + Type::BareFn(_) => None, Type::Group(r#type) => should_validate_type(&r#type.elem), Type::ImplTrait(r#type) => r#type.bounds.iter().any( |bound| matches!(bound, TypeParamBound::Trait(bound) if is_validate_path(&bound.path)), - ), - Type::Infer(_) => true, - Type::Macro(_) => true, - Type::Never(_) => false, + ).then_some(KnownOrUnknown::Unknown), + Type::Infer(_) => Some(KnownOrUnknown::Unknown), + Type::Macro(_) => Some(KnownOrUnknown::Unknown), + Type::Never(_) => None, Type::Paren(r#type) => should_validate_type(&r#type.elem), Type::Path(r#type) => should_validate_path(&r#type.path), Type::Ptr(r#type) => should_validate_type(&r#type.elem), @@ -89,12 +125,12 @@ pub fn should_validate_type(r#type: &Type) -> bool { Type::Slice(r#type) => should_validate_type(&r#type.elem), Type::TraitObject(r#type) => r#type.bounds.iter().any( |bound| matches!(bound, TypeParamBound::Trait(bound) if is_validate_path(&bound.path)), - ), + ).then_some(KnownOrUnknown::Unknown), Type::Tuple(r#type) => { - !r#type.elems.is_empty() && r#type.elems.iter().all(should_validate_type) + (!r#type.elems.is_empty() && r#type.elems.iter().all(|r#type| should_validate_type(r#type).is_some())).then_some(KnownOrUnknown::Unknown) } - Type::Verbatim(_) => false, - _ => false, + Type::Verbatim(_) => None, + _ => None, } } @@ -106,7 +142,7 @@ mod tests { use super::should_validate_type; fn validate(tokens: TokenStream) -> bool { - should_validate_type(&syn::parse2(tokens).expect("valid type")) + should_validate_type(&syn::parse2(tokens).expect("valid type")).is_some() } #[test] diff --git a/packages/fortifier-macros/src/validations/nested.rs b/packages/fortifier-macros/src/validations/nested.rs index 987ff1c..42779cd 100644 --- a/packages/fortifier-macros/src/validations/nested.rs +++ b/packages/fortifier-macros/src/validations/nested.rs @@ -1,21 +1,41 @@ use proc_macro2::TokenStream; -use quote::{format_ident, quote}; -use syn::{Ident, Result, meta::ParseNestedMeta}; +use quote::{ToTokens, format_ident, quote}; +use syn::{Ident, Path, Result, meta::ParseNestedMeta}; use crate::validation::{Execution, Validation}; #[derive(Default)] -pub struct Nested {} +pub struct Nested { + error_type: TokenStream, +} impl Nested { - pub fn new() -> Self { - Self {} + pub fn new(error_type: TokenStream) -> Self { + Self { error_type } } } impl Validation for Nested { - fn parse(_meta: &ParseNestedMeta<'_>) -> Result { - unimplemented!() + fn parse(meta: &ParseNestedMeta<'_>) -> Result { + let mut error_type: Option = None; + + meta.parse_nested_meta(|meta| { + if meta.path.is_ident("error_type") { + error_type = Some(meta.value()?.parse()?); + + Ok(()) + } else { + Err(meta.error("unknown parameter")) + } + })?; + + let Some(error_type) = error_type else { + return Err(meta.error("missing `error_type` parameter")); + }; + + Ok(Nested { + error_type: error_type.to_token_stream(), + }) } fn ident(&self) -> Ident { @@ -23,8 +43,7 @@ impl Validation for Nested { } fn error_type(&self) -> TokenStream { - // TODO - quote!(::fortifier::NestedError) + self.error_type.clone() } fn expr(&self, execution: Execution, expr: &TokenStream) -> Option { diff --git a/packages/fortifier-macros/tests/validate/nested_pass.rs b/packages/fortifier-macros/tests/validate/nested_pass.rs new file mode 100644 index 0000000..76f1426 --- /dev/null +++ b/packages/fortifier-macros/tests/validate/nested_pass.rs @@ -0,0 +1,36 @@ +use fortifier::{EmailAddressError, IndexedValidationError, Validate, ValidationErrors}; + +#[derive(Validate)] +struct CreateUser { + name: String, + email_addresses: Vec, +} + +#[derive(Validate)] +struct CreateEmailAddress { + #[validate(email_address)] + email_address: String, +} + +fn main() { + let data = CreateUser { + name: "John Doe".to_owned(), + email_addresses: vec![CreateEmailAddress { + email_address: "invalid".to_owned(), + }], + }; + + assert_eq!( + data.validate_sync(), + Err(ValidationErrors::from_iter([ + CreateUserValidationError::EmailAddresses(ValidationErrors::from_iter([ + IndexedValidationError::new( + 0, + CreateEmailAddressValidationError::EmailAddress(EmailAddressError::from( + email_address::Error::MissingSeparator + )) + ) + ])) + ])) + ); +} diff --git a/packages/fortifier/src/error.rs b/packages/fortifier/src/error.rs new file mode 100644 index 0000000..c6a278d --- /dev/null +++ b/packages/fortifier/src/error.rs @@ -0,0 +1,96 @@ +use std::{ + error::Error, + fmt::{self, Debug, Display}, + ops::Deref, +}; + +/// Validation errors. +#[derive(Debug, Eq, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct ValidationErrors(Vec); + +impl Deref for ValidationErrors { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Display for ValidationErrors { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self.0) + } +} + +impl Error 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) + } +} + +impl IntoIterator for ValidationErrors { + type Item = E; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +/// Validation error with index. +#[derive(Debug, Eq, PartialEq)] +pub struct IndexedValidationError { + /// The index. + pub index: usize, + /// The error. + pub error: E, +} + +impl IndexedValidationError { + /// Constructs a new [`IndexedValidationError`]. + pub fn new(index: usize, error: E) -> Self { + Self { index, error } + } +} + +impl Display for IndexedValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:#?}", self) + } +} + +impl Error for IndexedValidationError {} + +/// Validation error with key. +#[derive(Debug, Eq, PartialEq)] +pub struct KeyedValidationError { + /// The key. + pub key: K, + /// The error. + pub error: E, +} + +impl KeyedValidationError { + /// Constructs a new [`KeyedValidationError`]. + pub fn new(key: K, error: E) -> Self { + Self { key, error } + } +} + +impl Display for KeyedValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:#?}", self) + } +} + +impl Error for KeyedValidationError {} diff --git a/packages/fortifier/src/lib.rs b/packages/fortifier/src/lib.rs index d9a6264..72f06b4 100644 --- a/packages/fortifier/src/lib.rs +++ b/packages/fortifier/src/lib.rs @@ -2,9 +2,11 @@ //! Fortifier. +mod error; mod validate; mod validations; +pub use error::*; pub use validate::*; pub use validations::*; diff --git a/packages/fortifier/src/validate.rs b/packages/fortifier/src/validate.rs index 7968683..d2654d2 100644 --- a/packages/fortifier/src/validate.rs +++ b/packages/fortifier/src/validate.rs @@ -1,34 +1,6 @@ -use std::{ - error::Error, - fmt::{self, Debug, Display}, - pin::Pin, -}; - -/// Validation errors. -#[derive(Debug, Eq, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] -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 {} +use std::{collections::HashMap, error::Error, fmt::Debug, pin::Pin}; -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) - } -} +use crate::error::{IndexedValidationError, KeyedValidationError, ValidationErrors}; /// Validate a schema with context. pub trait ValidateWithContext { @@ -89,3 +61,117 @@ pub trait Validate: ValidateWithContext { self.validate_async_with_context(&()) } } + +impl ValidateWithContext for Vec +where + T: ValidateWithContext + Send + Sync, + T::Error: Error + Send + Sync, +{ + type Context = T::Context; + type Error = IndexedValidationError; + + fn validate_sync_with_context( + &self, + context: &Self::Context, + ) -> Result<(), ValidationErrors> { + let mut errors = vec![]; + + for (index, value) in self.iter().enumerate() { + if let Err(error) = value.validate_sync_with_context(context) { + for error in error { + errors.push(IndexedValidationError { index, error }); + } + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.into()) + } + } + + fn validate_async_with_context( + &self, + context: &Self::Context, + ) -> Pin>> + Send>> { + Box::pin(async move { + let mut errors = vec![]; + + for (index, value) in self.iter().enumerate() { + if let Err(error) = value.validate_async_with_context(context).await { + for error in error { + errors.push(IndexedValidationError { index, error }); + } + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.into()) + } + }) + } +} + +// TODO: Should this validate both keys and values? +impl ValidateWithContext for HashMap +where + K: Clone + Debug + Send + Sync, + V: ValidateWithContext + Send + Sync, + V::Error: Error + Send + Sync, +{ + type Context = V::Context; + type Error = KeyedValidationError; + + fn validate_sync_with_context( + &self, + context: &Self::Context, + ) -> Result<(), ValidationErrors> { + let mut errors = vec![]; + + for (key, value) in self.iter() { + if let Err(error) = value.validate_sync_with_context(context) { + for error in error { + errors.push(KeyedValidationError { + key: key.clone(), + error, + }); + } + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.into()) + } + } + + fn validate_async_with_context( + &self, + context: &Self::Context, + ) -> Pin>> + Send>> { + Box::pin(async move { + let mut errors = vec![]; + + for (key, value) in self.iter() { + if let Err(error) = value.validate_async_with_context(context).await { + for error in error { + errors.push(KeyedValidationError { + key: key.clone(), + error, + }); + } + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.into()) + } + }) + } +}