diff options
34 files changed, 1480 insertions, 0 deletions
| diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..505b806 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,5 @@ +[*.rs] +end_of_line = lf +charset = utf-8 +indent_style = space +indent_size = 4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30ec920 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/*.iml +/.idea +target/ +Cargo.lock diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e1320c9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +language: rust +cache: cargo + +rust: +  - nightly + +script: +  - (cd validator && cargo test) +  - (cd validator_derive && cargo test) +notifications: +  email: false @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2016 Vincent Prouillet + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..05d74be --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# validator + +[](https://travis-ci.org/Keats/validator) + +Status: Experimental - do not use in production + +Macros 1.1 custom derive to simplify struct validation inspired by [marshmallow](http://marshmallow.readthedocs.io/en/latest/) and +[Django validators](https://docs.djangoproject.com/en/1.10/ref/validators/). +It currently only works on nightly but should work on stable in Rust 1.15. + +A short example: +```rust +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +#[macro_use] extern crate serde_derive; +extern crate serde_json; + +// A trait that the Validate derive will impl +use validator::Validate; + +#[derive(Debug, Validate, Deserialize)] +struct SignupData { +    #[validate(email)] +    mail: String, +    #[validate(url)] +    site: String, +    #[validate(length(min = 1), custom = "validate_unique_username")] +    #[serde(rename = "firstName")] +    first_name: String, +    #[validate(range(min = 18, max = 20))] +    age: u32, +} + +fn validate_unique_username(username: &str) -> Option<String> { +    if username == "xXxShad0wxXx" { +        return Some("terrible_username".to_string()); +    } + +    None +} + +// load the struct from some json... +// `validate` returns `Result<(), HashMap<String, Vec<String>>>` +signup_data.validate()?; +``` + +This crate only sends back error codes for each field, it's up to you to write a message +for each error code. + +Note that `validator` works in conjunction with serde: in the example we can see that the `first_name` +field is renamed from/to `firstName`. Any error on that field will be in the `firstName` key of the hashmap, +not `first_name`. + +## Usage +You will need to add the `proc_macro` and `attr_literals` as seen in the example, as +well as importing the `Validate` trait. + +The `validator` crate can also be used without the custom derive as it exposes all the +validation functions. + +## Validators +The crate comes with some built-in validators and you can have several validators for a given field. + +### email +Tests whether the String is a valid email according to the HTML5 regex, which means it will mark +some esoteric emails as invalid that won't be valid in a `email` input as well. +This validator doesn't take any arguments: `#[validate(email)]`. + +### url +Tests whether the String is a valid URL. +This validator doesn't take any arguments: `#[validate(url)]`; + +### length +Tests whether a String or a Vec match the length requirement given. `length` has 3 integer arguments: + +- min +- max +- equal + +Using `equal` excludes the `min` or `max` and will result in a compilation error if they are found. + +At least one argument is required with a maximum of 2 (having `min` and `max` at the same time). + +Examples: + +```rust +#[validate(length(min = 1, max = 10))] +#[validate(length(min = 1))] +#[validate(length(max = 10))] +#[validate(length(equal = 10))] +``` + +### range +Tests whether a number is in the given range. `range` takes 2 number arguments: `min` and `max`. + +Examples: + +```rust +#[validate(range(min = 1, max = 10))] +#[validate(range(min = 1, max = 10.8))] +#[validate(range(min = 1.1, max = 10.8))] +``` + +### custom +Calls one of your function to do a custom validation.  +The field will be given as parameter and it should return a `Option<String>` representing the error code, +if there was an error. + +Examples: + +```rust +#[validate(custom = "validate_something")] +#[validate(custom = "::utils::validate_something"] +``` + +TODO: have it return a bool and pass a `code` to the `custom` validator instead? diff --git a/validator/Cargo.toml b/validator/Cargo.toml new file mode 100644 index 0000000..618c114 --- /dev/null +++ b/validator/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "validator" +version = "0.1.0" +authors = ["Vincent Prouillet <vincent@wearewizards.io>"] +license = "MIT" +description = "Common validation functions (email, url, length, ...) and trait" +homepage = "https://github.com/Keats/validator" +repository = "https://github.com/Keats/validator" +keywords = ["validation", "api", "validator"] + +[dependencies] +url = "1.2" +regex = "0.1" +lazy_static = "0.2" +idna = "0.1" diff --git a/validator/src/email.rs b/validator/src/email.rs new file mode 100644 index 0000000..d7cd084 --- /dev/null +++ b/validator/src/email.rs @@ -0,0 +1,121 @@ +use regex::Regex; + +use ip::{validate_ip}; +use idna::{domain_to_ascii}; + + +lazy_static! { +    // Regex from the specs +    // https://html.spec.whatwg.org/multipage/forms.html#valid-e-mail-address +    // It will mark esoteric email addresses like quoted string as invalid +    static ref EMAIL_USER_RE: Regex = Regex::new(r"^(?i)[a-z0-9.!#$%&'*+/=?^_`{|}~-]+\z").unwrap(); +    static ref EMAIL_DOMAIN_RE: Regex = Regex::new( +        r"(?i)^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$" +    ).unwrap(); +    // literal form, ipv4 or ipv6 address (SMTP 4.1.3) +    static ref EMAIL_LITERAL_RE: Regex = Regex::new(r"(?i)\[([A-f0-9:\.]+)\]\z").unwrap(); +} + +/// Validates whether the given string is an email based on Django `EmailValidator` and HTML5 specs +pub fn validate_email(val: &str) -> bool { +    if val.is_empty() || !val.contains('@') { +        return false; +    } +    let parts: Vec<&str> = val.rsplitn(2, '@').collect(); +    let user_part = parts[1]; +    let domain_part = parts[0]; + +    if !EMAIL_USER_RE.is_match(user_part) { +        return false; +    } + +    if !validate_domain_part(domain_part) { +        // Still the possibility of an [IDN](https://en.wikipedia.org/wiki/Internationalized_domain_name) +        return match domain_to_ascii(domain_part) { +            Ok(d) => validate_domain_part(&d), +            Err(_) => false, +        }; +    } + +    true +} + +/// Checks if the domain is a valid domain and if not, check whether it's an IP +fn validate_domain_part(domain_part: &str) -> bool { +    if EMAIL_DOMAIN_RE.is_match(domain_part) { +        return true; +    } + +    // maybe we have an ip as a domain? +    match EMAIL_LITERAL_RE.captures(domain_part) { +        Some(caps) => { +            match caps.at(1) { +                Some(c) => validate_ip(c), +                None => false, +            } +        } +        None => false +    } +} + + +#[cfg(test)] +mod tests { +    use super::validate_email; + +    #[test] +    fn test_validate_email() { +        // Test cases taken from Django +        // https://github.com/django/django/blob/master/tests/validators/tests.py#L48 +        let tests = vec![ +            ("email@here.com", true), +            ("weirder-email@here.and.there.com", true), +            (r#"!def!xyz%abc@example.com"#, true), +            ("email@[127.0.0.1]", true), +            ("email@[2001:dB8::1]", true), +            ("email@[2001:dB8:0:0:0:0:0:1]", true), +            ("email@[::fffF:127.0.0.1]", true), +            ("example@valid-----hyphens.com", true), +            ("example@valid-with-hyphens.com", true), +            ("test@domain.with.idn.tld.उदाहरण.परीक्षा", true), +            (r#""test@test"@example.com"#, false), +            // max length for domain name labels is 63 characters per RFC 1034 +            ("a@atm.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true), +            ("a@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.atm", true), +            ("a@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbb.atm", true), +            ("a@atm.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true), +            ("", false), +            ("abc", false), +            ("abc@", false), +            ("abc@bar", true), +            ("a @x.cz", false), +            // TODO: make that one below fail +            // ("abc@.com", false), +            ("something@@somewhere.com", false), +            ("email@127.0.0.1", true), +            ("email@[127.0.0.256]", false), +            ("email@[2001:db8::12345]", false), +            ("email@[2001:db8:0:0:0:0:1]", false), +            ("email@[::ffff:127.0.0.256]", false), +            ("example@invalid-.com", false), +            ("example@-invalid.com", false), +            ("example@invalid.com-", false), +            ("example@inv-.alid-.com", false), +            ("example@inv-.-alid.com", false), +            (r#"test@example.com\n\n<script src="x.js">"#, false), +            (r#""\\\011"@here.com"#, false), +            (r#""\\\012"@here.com"#, false), +            ("trailingdot@shouldfail.com.", false), +            // Trailing newlines in username or domain not allowed +            ("a@b.com\n", false), +            ("a\n@b.com", false), +            (r#""test@test"\n@example.com"#, false), +            ("a@[127.0.0.1]\n", false), +        ]; + +        for (input, expected) in tests { +            println!("{} - {}", input, expected); +            assert_eq!(validate_email(input), expected); +        } +    } +} diff --git a/validator/src/ip.rs b/validator/src/ip.rs new file mode 100644 index 0000000..9a94522 --- /dev/null +++ b/validator/src/ip.rs @@ -0,0 +1,107 @@ +use std::str::FromStr; +use std::net::IpAddr; + + +/// Validates whether the given string is an IP V4 +pub fn validate_ip_v4(val: &str) -> bool { +    match IpAddr::from_str(val) { +        Ok(i) => match i { +            IpAddr::V4(_) => true, +            IpAddr::V6(_) => false, +        }, +        Err(_) => false, +    } +} + +/// Validates whether the given string is an IP V6 +pub fn validate_ip_v6(val: &str) -> bool { +    match IpAddr::from_str(val) { +        Ok(i) => match i { +            IpAddr::V4(_) => false, +            IpAddr::V6(_) => true, +        }, +        Err(_) => false, +    } +} + +/// Validates whether the given string is an IP +pub fn validate_ip(val: &str) -> bool { +    match IpAddr::from_str(val) { +        Ok(_) => true, +        Err(_) => false, +    } +} + + +#[cfg(test)] +mod tests { +    use super::{validate_ip_v4, validate_ip_v6, validate_ip}; + +    #[test] +    fn test_validate_ip() { +        let tests = vec![ +            ("1.1.1.1", true), +            ("255.0.0.0", true), +            ("0.0.0.0", true), +            ("256.1.1.1", false), +            ("25.1.1.", false), +            ("25,1,1,1", false), +            ("fe80::223:6cff:fe8a:2e8a", true), +            ("::ffff:254.42.16.14", true), +            ("2a02::223:6cff :fe8a:2e8a", false), +        ]; + +        for (input, expected) in tests { +            assert_eq!(validate_ip(input), expected); +        } +    } + +    #[test] +    fn test_validate_ip_v4() { +        let tests = vec![ +            ("1.1.1.1", true), +            ("255.0.0.0", true), +            ("0.0.0.0", true), +            ("256.1.1.1", false), +            ("25.1.1.", false), +            ("25,1,1,1", false), +            ("25.1 .1.1", false), +            ("1.1.1.1\n", false), +            ("٧.2٥.3٣.243", false), +        ]; + +        for (input, expected) in tests { +            assert_eq!(validate_ip_v4(input), expected); +        } +    } + +    #[test] +    fn test_validate_ip_v6() { +        let tests = vec![ +            ("fe80::223:6cff:fe8a:2e8a", true), +            ("2a02::223:6cff:fe8a:2e8a", true), +            ("1::2:3:4:5:6:7", true), +            ("::", true), +            ("::a", true), +            ("2::", true), +            ("::ffff:254.42.16.14", true), +            ("::ffff:0a0a:0a0a", true), +            ("::254.42.16.14", true), +            ("::0a0a:0a0a", true), +            ("foo", false), +            ("127.0.0.1", false), +            ("12345::", false), +            ("1::2::3::4", false), +            ("1::zzz", false), +            ("1:2", false), +            ("fe80::223: 6cff:fe8a:2e8a", false), +            ("2a02::223:6cff :fe8a:2e8a", false), +            ("::ffff:999.42.16.14", false), +            ("::ffff:zzzz:0a0a", false), +        ]; + +        for (input, expected) in tests { +            assert_eq!(validate_ip_v6(input), expected); +        } +    } +} diff --git a/validator/src/length.rs b/validator/src/length.rs new file mode 100644 index 0000000..358317f --- /dev/null +++ b/validator/src/length.rs @@ -0,0 +1,94 @@ +use types::Validator; + +// a bit sad but we can generically refer to a struct that has a len() method +// so we impl our own trait for it +pub trait HasLen { +    fn length(&self) -> u64; +} + +impl<'a> HasLen for &'a String { +    fn length(&self) -> u64 { +        self.len() as u64 +    } +} + +impl<'a> HasLen for &'a str { +    fn length(&self) -> u64 { +        self.len() as u64 +    } +} + +impl<T> HasLen for Vec<T> { +    fn length(&self) -> u64 { +        self.len() as u64 +    } +} +impl<'a, T> HasLen for &'a Vec<T> { +    fn length(&self) -> u64 { +        self.len() as u64 +    } +} + +/// Validates the length of the value given. +/// If the validator has `equal` set, it will ignore any `min` and `max` value. +/// +/// If you apply it on String, don't forget that the length can be different +/// from the number of visual characters for Unicode +pub fn validate_length<T: HasLen>(length: Validator, val: T) -> bool { +    match length { +        Validator::Length { min, max, equal } => { +            let val_length = val.length(); +            if let Some(eq) = equal { +                return val_length == eq; +            } +            if let Some(m) = min { +                if val_length < m { +                    return false; +                } +            } +            if let Some(m) = max { +                if val_length > m { +                    return false; +                } +            } +        }, +        _ => unreachable!() +    } + +    true +} + +#[cfg(test)] +mod tests { +    use super::{validate_length, Validator}; + +    #[test] +    fn test_validate_length_equal_overrides_min_max() { +        let validator = Validator::Length { min: Some(1), max: Some(2), equal: Some(5) }; +        assert_eq!(validate_length(validator, "hello"), true); +    } + +    #[test] +    fn test_validate_length_string_min_max() { +        let validator = Validator::Length { min: Some(1), max: Some(10), equal: None }; +        assert_eq!(validate_length(validator, "hello"), true); +    } + +    #[test] +    fn test_validate_length_string_min_only() { +        let validator = Validator::Length { min: Some(10), max: None, equal: None }; +        assert_eq!(validate_length(validator, "hello"), false); +    } + +    #[test] +    fn test_validate_length_string_max_only() { +        let validator = Validator::Length { min: None, max: Some(1), equal: None }; +        assert_eq!(validate_length(validator, "hello"), false); +    } + +    #[test] +    fn test_validate_length_vec() { +        let validator = Validator::Length { min: None, max: None, equal: Some(3) }; +        assert_eq!(validate_length(validator, vec![1, 2, 3]), true); +    } +} diff --git a/validator/src/lib.rs b/validator/src/lib.rs new file mode 100644 index 0000000..181fc54 --- /dev/null +++ b/validator/src/lib.rs @@ -0,0 +1,20 @@ +extern crate url; +extern crate regex; +#[macro_use] extern crate lazy_static; +extern crate idna; + + +mod types; +mod ip; +mod email; +mod length; +mod range; +mod urls; + + +pub use types::{Errors, Validate, Validator}; +pub use ip::{validate_ip, validate_ip_v4, validate_ip_v6}; +pub use email::{validate_email}; +pub use length::{HasLen, validate_length}; +pub use range::{validate_range}; +pub use urls::{validate_url}; diff --git a/validator/src/range.rs b/validator/src/range.rs new file mode 100644 index 0000000..8db9a86 --- /dev/null +++ b/validator/src/range.rs @@ -0,0 +1,30 @@ +use types::Validator; + +/// Validates that a number is in the given range +/// +/// TODO: see if can be generic over the number type +pub fn validate_range(range: Validator, val: f64) -> bool { +    match range { +        Validator::Range { min, max } => { +            val >= min && val <= max +        }, +        _ => unreachable!() +    } +} + +#[cfg(test)] +mod tests { +    use super::{validate_range, Validator}; + +    #[test] +    fn test_validate_range_ok() { +        let validator = Validator::Range { min: 0.0, max: 10.0 }; +        assert_eq!(validate_range(validator, 1 as f64), true); +    } + +    #[test] +    fn test_validate_range_fail() { +        let validator = Validator::Range { min: 0.0, max: 10.0 }; +        assert_eq!(validate_range(validator, 20 as f64), false); +    } +} diff --git a/validator/src/types.rs b/validator/src/types.rs new file mode 100644 index 0000000..8265c63 --- /dev/null +++ b/validator/src/types.rs @@ -0,0 +1,30 @@ +use std::collections::HashMap; + + +pub type Errors = HashMap<String, Vec<String>>; + +pub trait Validate { +    //fn load_and_validate<T>(data: &str) -> Result<T, Errors>; +    fn validate(&self) -> Result<(), Errors>; +} + +#[derive(Debug, Clone)] +pub enum Validator { +    // String is the path to the function +    Custom(String), +    // value is a &str +    Email, +    // value is a &str +    Url, +    // value is a number +    Range { +        min: f64, +        max: f64, +    }, +    // value is anything that impl HasLen +    Length { +        min: Option<u64>, +        max: Option<u64>, +        equal: Option<u64>, +    }, +} diff --git a/validator/src/urls.rs b/validator/src/urls.rs new file mode 100644 index 0000000..d948050 --- /dev/null +++ b/validator/src/urls.rs @@ -0,0 +1,30 @@ +use url::Url; + + +/// Validates whether the string given is a url +pub fn validate_url(val: &str) -> bool { +    match Url::parse(val) { +        Ok(_) => true, +        Err(_) => false, +    } +} + +#[cfg(test)] +mod tests { +    use super::{validate_url}; + + +    #[test] +    fn test_validate_url() { +        let tests = vec![ +            ("http", false), +            ("https://google.com", true), +            ("http://localhost:80", true), +            ("ftp://localhost:80", true), +        ]; + +        for (url, expected) in tests { +            assert_eq!(validate_url(url), expected); +        } +    } +} diff --git a/validator_derive/Cargo.toml b/validator_derive/Cargo.toml new file mode 100644 index 0000000..76c5e7e --- /dev/null +++ b/validator_derive/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "validator_derive" +version = "0.1.0" +authors = ["Vincent Prouillet <vincent@wearewizards.io>"] +license = "MIT" +description = "Macros 1.1 implementation of #[derive(Validate)]" +homepage = "https://github.com/Keats/validator" +repository = "https://github.com/Keats/validator" +keywords = ["validation", "api", "validator"] + +[lib] +proc-macro = true + +[dependencies] +syn = "0.10" +quote = "0.3" + +[dev-dependencies] +serde = "0.8" +serde_derive = "0.8" +serde_json = "0.8" +compiletest_rs = "*" + +[dependencies.validator] +path = "../validator" diff --git a/validator_derive/src/lib.rs b/validator_derive/src/lib.rs new file mode 100644 index 0000000..0e580b0 --- /dev/null +++ b/validator_derive/src/lib.rs @@ -0,0 +1,410 @@ +#![feature(proc_macro, proc_macro_lib)] +#![recursion_limit = "128"] + +#[macro_use] extern crate quote; +extern crate proc_macro; +extern crate syn; +extern crate validator; + + +use proc_macro::TokenStream; +use quote::ToTokens; +use validator::{Validator}; + + +static LENGTH_TYPES: [&'static str; 2] = ["String", "Vec"]; +static RANGE_TYPES: [&'static str; 12] = [ +    "usize", "u8", "u16", "u32", "u64", "isize", "i8", "i16", "i32", "i64", "f32", "f64" +]; + + +#[proc_macro_derive(Validate, attributes(validate))] +pub fn derive_validation(input: TokenStream) -> TokenStream { +    let source = input.to_string(); +    // Parse the string representation to an AST +    let ast = syn::parse_macro_input(&source).unwrap(); + +    let expanded = expand_validation(&ast); +    expanded.parse().unwrap() +} + + +fn expand_validation(ast: &syn::MacroInput) -> quote::Tokens { +    let fields = match ast.body { +        syn::Body::Struct(syn::VariantData::Struct(ref fields)) => { +            if fields.iter().any(|field| field.ident.is_none()) { +                panic!("struct has unnamed fields"); +            } +            fields +        }, +        _ => panic!("#[derive(Validate)] can only be used with structs"), +    }; + +    let mut validations = vec![]; + +    for field in fields { +        let field_ident = match field.ident { +            Some(ref i) => i, +            None => unreachable!() +        }; + +        let (name, validators) = find_validators_for_field(field); +        for validator in &validators { +            validations.push(match validator { +                &Validator::Length {min, max, equal} =>  { +                    // Can't interpolate None +                    let min_tokens = option_u64_to_tokens(min); +                    let max_tokens = option_u64_to_tokens(max); +                    let equal_tokens = option_u64_to_tokens(equal); +                    quote!( +                        if !::validator::validate_length( +                            ::validator::Validator::Length { +                                min: #min_tokens, +                                max: #max_tokens, +                                equal: #equal_tokens +                            }, +                            &self.#field_ident +                        ) { +                            errors.entry(#name.to_string()).or_insert_with(|| vec![]).push("length".to_string()); +                        } +                    ) +                }, +                &Validator::Range {min, max} => { +                    quote!( +                        if !::validator::validate_range( +                            ::validator::Validator::Range {min: #min, max: #max}, +                            self.#field_ident as f64 +                        ) { +                            errors.entry(#name.to_string()).or_insert_with(|| vec![]).push("range".to_string()); +                        } +                    ) +                }, +                &Validator::Email => { +                    quote!( +                        if !::validator::validate_email(&self.#field_ident) { +                            errors.entry(#name.to_string()).or_insert_with(|| vec![]).push("email".to_string()); +                        } +                    ) +                } +                &Validator::Url => { +                    quote!( +                        if !::validator::validate_url(&self.#field_ident) { +                            errors.entry(#name.to_string()).or_insert_with(|| vec![]).push("url".to_string()); +                        } +                    ) +                }, +                &Validator::Custom(ref f) => { +                    let fn_ident = syn::Ident::new(f.clone()); +                    quote!( +                        match #fn_ident(&self.#field_ident) { +                            ::std::option::Option::Some(s) => errors.entry(#name.to_string()).or_insert_with(|| vec![]).push(s), +                            ::std::option::Option::None => (), +                        }; +                    ) +                }, +            }); +        } +    } + +    let ident = &ast.ident; +    let impl_ast = quote!( +        impl Validate for #ident { +            fn validate(&self) -> ::std::result::Result<(), ::validator::Errors> { +                use std::collections::HashMap; +                let mut errors = HashMap::new(); + +                 #(#validations)* + +                if errors.is_empty() { +                    ::std::result::Result::Ok(()) +                } else { +                    ::std::result::Result::Err(errors) +                } +            } +        } +    ); +    // println!("{}", impl_ast.to_string()); +    impl_ast +} + +/// Find everything we need to know about a Field. +fn find_validators_for_field(field: &syn::Field) -> (String, Vec<Validator>) { +    let mut field_name = match field.ident { +        Some(ref s) => s.to_string(), +        None => unreachable!(), +    }; + +    let error = |msg: &str| -> ! { +        panic!("Invalid attribute #[validate] on field `{}`: {}", field.ident.clone().unwrap().to_string(), msg); +    }; + +    let field_type = match field.ty { +        syn::Ty::Path(_, ref p) => { +            p.segments[0].ident.to_string() + +        }, +        _ => error(&format!("Type `{:?}` not supported", field.ty)) +    }; + +    let mut validators = vec![]; + +    let find_struct_validator = |name: String, meta_items: &Vec<syn::NestedMetaItem>| -> Validator { +        match name.as_ref() { +            "length" => { +                let mut min = None; +                let mut max = None; +                let mut equal = None; + +                for meta_item in meta_items { +                    match *meta_item { +                        syn::NestedMetaItem::MetaItem(ref item) => match *item { +                            syn::MetaItem::NameValue(ref name, ref val) => { +                                match name.to_string().as_ref() { +                                    "min" => { +                                        min = match lit_to_int(val) { +                                            Some(s) => Some(s), +                                            None => error("invalid argument type for `min` of `length` validator: only integers are allowed"), +                                        }; +                                    }, +                                    "max" => { +                                        max = match lit_to_int(val) { +                                            Some(s) => Some(s), +                                            None => error("invalid argument type for `max` of `length` validator: only integers are allowed"), +                                        }; +                                    }, +                                    "equal" => { +                                        equal = match lit_to_int(val) { +                                            Some(s) => Some(s), +                                            None => error("invalid argument type for `equal` of `length` validator: only integers are allowed"), +                                        }; +                                    }, +                                    _ => error(&format!( +                                        "unknown argument `{}` for validator `length` (it only has `min`, `max`, `equal`)", +                                        name.to_string() +                                    )) +                                } +                            }, +                            _ => panic!("unexpected item {:?} while parsing `length` validator", item) +                        }, +                        _=> unreachable!() +                    } +                } +                if equal.is_some() && (min.is_some() || max.is_some()) { +                    error("both `equal` and `min` or `max` have been set in `length` validator: probably a mistake"); +                } +                Validator::Length { min: min, max: max, equal: equal } +            }, +            "range" => { +                let mut min = 0.0; +                let mut max = 0.0; +                for meta_item in meta_items { +                    match *meta_item { +                        syn::NestedMetaItem::MetaItem(ref item) => match *item { +                            syn::MetaItem::NameValue(ref name, ref val) => { +                                match name.to_string().as_ref() { +                                    "min" => { +                                        min = match lit_to_float(val) { +                                            Some(s) => s, +                                            None => error("invalid argument type for `min` of `range` validator: only integers are allowed") +                                        }; +                                    }, +                                    "max" => { +                                        max = match lit_to_float(val) { +                                            Some(s) => s, +                                            None => error("invalid argument type for `max` of `range` validator: only integers are allowed") +                                        }; +                                    }, +                                    _ => error(&format!( +                                        "unknown argument `{}` for validator `range` (it only has `min`, `max`)", +                                        name.to_string() +                                    )) +                                } +                            }, +                            _ => panic!("unexpected item {:?} while parsing `range` validator", item) +                        }, +                        _=> unreachable!() +                    } +                } + +                Validator::Range { min: min, max: max} +            } +            _ => panic!("unexpected list validator: {:?}", name) +        } +    }; + +    for attr in &field.attrs { +        if attr.name() != "validate" && attr.name() != "serde" { +            continue; +        } + +        match attr.value { +            syn::MetaItem::List(_, ref meta_items) => { +                if attr.name() == "serde" { +                    match find_original_field_name(meta_items) { +                        Some(s) => { field_name = s }, +                        None => () +                    }; +                    continue; +                } + +                // only validation from there on +                for meta_item in meta_items { +                    match *meta_item { +                        syn::NestedMetaItem::MetaItem(ref item) => match *item { +                            // email, url +                            syn::MetaItem::Word(ref name) => match name.to_string().as_ref() { +                                "email" => { +                                    if field_type != "String" { +                                        panic!("`email` validator can only be used on String"); +                                    } +                                    validators.push(Validator::Email); +                                }, +                                "url" => { +                                    if field_type != "String" { +                                        panic!("`url` validator can only be used on String"); +                                    } +                                    validators.push(Validator::Url); +                                }, +                                _ => panic!("Unexpected word validator: {}", name) +                            }, +                            // custom +                            syn::MetaItem::NameValue(ref name, ref val) => { +                                if name == "custom" { +                                    match lit_to_string(val) { +                                        Some(s) => validators.push(Validator::Custom(s)), +                                        None => error("invalid argument for `custom` validator: only strings are allowed"), +                                    }; +                                } else { +                                    panic!("unexpected name value validator: {:?}", name); +                                } +                            }, +                            // validators with args: length for example +                            syn::MetaItem::List(ref name, ref meta_items) => { +                                // Some sanity checking first +                                if name == "length" { +                                    if !LENGTH_TYPES.contains(&field_type.as_ref()) { +                                        error(&format!( +                                            "Validator `length` can only be used on types `String` or `Vec` but found `{}`", +                                            field_type +                                        )); +                                    } + +                                    if meta_items.len() == 0 { +                                        error("Validator `length` requires at least 1 argument out of `min`, `max` and `equal`"); +                                    } +                                } + +                                if name == "range" { +                                    if !RANGE_TYPES.contains(&field_type.as_ref()) { +                                        error(&format!( +                                            "Validator `range` can only be used on number types but found `{}`", +                                            field_type +                                        )); +                                    } + +                                    if meta_items.len() != 2 { +                                        error("Validator `range` requires 2 arguments: `min` and `max`"); +                                    } +                                } + +                                validators.push(find_struct_validator(name.to_string(), meta_items)); +                            }, +                        }, +                        _ => unreachable!("Found a non MetaItem while looking for validators") +                    }; +                } +            }, +            _ => unreachable!("Got something other than a list of attributes while checking field `{}`", field_name), +        } +    } + +    if validators.is_empty() { +        error("it needs at least one validator"); +    } + +    (field_name, validators) +} + +/// Serde can be used to rename fields on deserialization but most of the times +/// we want the error on the original field. +/// +/// For example a JS frontend might send camelCase fields and Rust converts them to snake_case +/// but we want to send the errors back to the frontend with the original name +fn find_original_field_name(meta_items: &Vec<syn::NestedMetaItem>) -> Option<String> { +    let mut original_name = None; + +    for meta_item in meta_items { +        match *meta_item { +            syn::NestedMetaItem::MetaItem(ref item) => match *item { +                syn::MetaItem::Word(_) => continue, +                syn::MetaItem::NameValue(ref name, ref val) => { +                    if name == "rename" { +                        original_name = Some(lit_to_string(val).unwrap()); +                    } + +                }, +                // length +                syn::MetaItem::List(_, ref meta_items) => { +                    return find_original_field_name(meta_items); +                } +            }, +            _ => unreachable!() +        }; + +        if original_name.is_some() { +            return original_name; +        } +    } + +    original_name +} + +// +//fn quote_length_validator(min: Option<u64>, max: Option<u64>, equal: Option<u64>) { +// +//} + +fn lit_to_string(lit: &syn::Lit) -> Option<String> { +    match *lit { +        syn::Lit::Str(ref s, _) => Some(s.to_string()), +        _ => None, +    } +} + +fn lit_to_int(lit: &syn::Lit) -> Option<u64> { +    match *lit { +        syn::Lit::Int(ref s, _) => Some(*s), +        _ => None, +    } +} + +fn lit_to_float(lit: &syn::Lit) -> Option<f64> { +    match *lit { +        syn::Lit::Float(ref s, _) => Some(s.parse::<f64>().unwrap()), +        syn::Lit::Int(ref s, _) => Some(*s as f64), +        _ => None, +    } +} + +fn option_u64_to_tokens(opt: Option<u64>) -> quote::Tokens { +    let mut tokens = quote::Tokens::new(); +    tokens.append("::"); +    tokens.append("std"); +    tokens.append("::"); +    tokens.append("option"); +    tokens.append("::"); +    tokens.append("Option"); +    tokens.append("::"); +    match opt { +        Some(ref t) => { +            tokens.append("Some"); +            tokens.append("("); +            t.to_tokens(&mut tokens); +            tokens.append(")"); +        } +        None => { +            tokens.append("None"); +        } +    } +    tokens +} diff --git a/validator_derive/tests/compile-fail/custom_not_string.rs b/validator_derive/tests/compile-fail/custom_not_string.rs new file mode 100644 index 0000000..e576c5c --- /dev/null +++ b/validator_derive/tests/compile-fail/custom_not_string.rs @@ -0,0 +1,15 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +//~^ ERROR: custom derive attribute panicked +//~^^ HELP: Invalid attribute #[validate] on field `s`: invalid argument for `custom` validator: only strings are allowed +struct Test { +    #[validate(custom = 2)] +    s: String, +} + +fn main() {} diff --git a/validator_derive/tests/compile-fail/length/equal_and_min_max_set.rs b/validator_derive/tests/compile-fail/length/equal_and_min_max_set.rs new file mode 100644 index 0000000..cc6654c --- /dev/null +++ b/validator_derive/tests/compile-fail/length/equal_and_min_max_set.rs @@ -0,0 +1,15 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +//~^ ERROR: custom derive attribute panicked +//~^^ HELP: Invalid attribute #[validate] on field `s`: both `equal` and `min` or `max` have been set in `length` validator: probably a mistake +struct Test { +    #[validate(length(min = 1, equal = 2))] +    s: String, +} + +fn main() {} diff --git a/validator_derive/tests/compile-fail/length/no_args.rs b/validator_derive/tests/compile-fail/length/no_args.rs new file mode 100644 index 0000000..35a44df --- /dev/null +++ b/validator_derive/tests/compile-fail/length/no_args.rs @@ -0,0 +1,15 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +//~^ ERROR: custom derive attribute panicked +//~^^ HELP: Invalid attribute #[validate] on field `s`: Validator `length` requires at least 1 argument out of `min`, `max` and `equal` +struct Test { +    #[validate(length())] +    s: String, +} + +fn main() {} diff --git a/validator_derive/tests/compile-fail/length/unknown_arg.rs b/validator_derive/tests/compile-fail/length/unknown_arg.rs new file mode 100644 index 0000000..d8e6185 --- /dev/null +++ b/validator_derive/tests/compile-fail/length/unknown_arg.rs @@ -0,0 +1,15 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +//~^ ERROR: custom derive attribute panicked +//~^^ HELP: Invalid attribute #[validate] on field `s`: unknown argument `eq` for validator `length` (it only has `min`, `max`, `equal`) +struct Test { +    #[validate(length(eq = 2))] +    s: String, +} + +fn main() {} diff --git a/validator_derive/tests/compile-fail/length/wrong_arg_type.rs b/validator_derive/tests/compile-fail/length/wrong_arg_type.rs new file mode 100644 index 0000000..4d3f602 --- /dev/null +++ b/validator_derive/tests/compile-fail/length/wrong_arg_type.rs @@ -0,0 +1,15 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +//~^ ERROR: custom derive attribute panicked +//~^^ HELP: Invalid attribute #[validate] on field `s`: invalid argument type for `min` of `length` validator: only integers are allowed +struct Test { +    #[validate(length(min = "2"))] +    s: String, +} + +fn main() {} diff --git a/validator_derive/tests/compile-fail/length/wrong_type.rs b/validator_derive/tests/compile-fail/length/wrong_type.rs new file mode 100644 index 0000000..e8a28ca --- /dev/null +++ b/validator_derive/tests/compile-fail/length/wrong_type.rs @@ -0,0 +1,15 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +//~^ ERROR: custom derive attribute panicked +//~^^ HELP: Invalid attribute #[validate] on field `s`: Validator `length` can only be used on types `String` or `Vec` but found `usize` +struct Test { +    #[validate(length())] +    s: usize, +} + +fn main() {} diff --git a/validator_derive/tests/compile-fail/no_validations.rs b/validator_derive/tests/compile-fail/no_validations.rs new file mode 100644 index 0000000..0a65bdd --- /dev/null +++ b/validator_derive/tests/compile-fail/no_validations.rs @@ -0,0 +1,15 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +//~^ ERROR: custom derive attribute panicked +//~^^ HELP: Invalid attribute #[validate] on field `s`: it needs at least one validator +struct Test { +    #[validate()] +    s: String, +} + +fn main() {} diff --git a/validator_derive/tests/compile-fail/range/missing_arg.rs b/validator_derive/tests/compile-fail/range/missing_arg.rs new file mode 100644 index 0000000..94913cf --- /dev/null +++ b/validator_derive/tests/compile-fail/range/missing_arg.rs @@ -0,0 +1,15 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +//~^ ERROR: custom derive attribute panicked +//~^^ HELP: Invalid attribute #[validate] on field `s`: Validator `range` requires 2 arguments: `min` and `max` +struct Test { +    #[validate(range(min = 2.0))] +    s: i32, +} + +fn main() {} diff --git a/validator_derive/tests/compile-fail/range/no_args.rs b/validator_derive/tests/compile-fail/range/no_args.rs new file mode 100644 index 0000000..0532cc0 --- /dev/null +++ b/validator_derive/tests/compile-fail/range/no_args.rs @@ -0,0 +1,15 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +//~^ ERROR: custom derive attribute panicked +//~^^ HELP: Invalid attribute #[validate] on field `s`: Validator `range` requires 2 arguments: `min` and `max` +struct Test { +    #[validate(range())] +    s: i32, +} + +fn main() {} diff --git a/validator_derive/tests/compile-fail/range/unknown_arg.rs b/validator_derive/tests/compile-fail/range/unknown_arg.rs new file mode 100644 index 0000000..7d2ac57 --- /dev/null +++ b/validator_derive/tests/compile-fail/range/unknown_arg.rs @@ -0,0 +1,15 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +//~^ ERROR: custom derive attribute panicked +//~^^ HELP: Invalid attribute #[validate] on field `s`: unknown argument `mi` for validator `range` (it only has `min`, `max`) +struct Test { +    #[validate(range(mi = 2, max = 3))] +    s: i32, +} + +fn main() {} diff --git a/validator_derive/tests/compile-fail/range/wrong_arg_type.rs b/validator_derive/tests/compile-fail/range/wrong_arg_type.rs new file mode 100644 index 0000000..a62a5ff --- /dev/null +++ b/validator_derive/tests/compile-fail/range/wrong_arg_type.rs @@ -0,0 +1,15 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +//~^ ERROR: custom derive attribute panicked +//~^^ HELP: Invalid attribute #[validate] on field `s`: invalid argument type for `min` of `range` validator: only integers are allowed +struct Test { +    #[validate(range(min = "2", max = 3))] +    s: i32, +} + +fn main() {} diff --git a/validator_derive/tests/compile-fail/range/wrong_type.rs b/validator_derive/tests/compile-fail/range/wrong_type.rs new file mode 100644 index 0000000..e341364 --- /dev/null +++ b/validator_derive/tests/compile-fail/range/wrong_type.rs @@ -0,0 +1,15 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +//~^ ERROR: custom derive attribute panicked +//~^^ HELP: Invalid attribute #[validate] on field `s`: Validator `range` can only be used on number types but found `String` +struct Test { +    #[validate(range(min = 10.0, max = 12.0))] +    s: String, +} + +fn main() {} diff --git a/validator_derive/tests/compile_test.rs b/validator_derive/tests/compile_test.rs new file mode 100644 index 0000000..b6fe8e5 --- /dev/null +++ b/validator_derive/tests/compile_test.rs @@ -0,0 +1,24 @@ +extern crate compiletest_rs as compiletest; + +use std::path::PathBuf; + +fn run_mode(mode: &'static str) { +    let mut config = compiletest::default_config(); +    let cfg_mode = mode.parse().expect("Invalid mode"); + +    config.target_rustcflags = Some("-L target/debug/ -L target/debug/deps/".to_string()); +    config.mode = cfg_mode; +    config.src_base = PathBuf::from(format!("tests/{}", mode)); + +    compiletest::run_tests(&config); +} + +#[test] +fn test_compile_fail() { +    run_mode("compile-fail"); +} + +#[test] +fn test_run_pass() { +    run_mode("run-pass"); +} diff --git a/validator_derive/tests/run-pass/custom.rs b/validator_derive/tests/run-pass/custom.rs new file mode 100644 index 0000000..205198e --- /dev/null +++ b/validator_derive/tests/run-pass/custom.rs @@ -0,0 +1,17 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +struct Test { +    #[validate(custom = "validate_something")] +    s: String, +} + +fn validate_something(s: &str) -> Option<String> { +    Some(s.to_string()) +} + +fn main() {} diff --git a/validator_derive/tests/run-pass/email.rs b/validator_derive/tests/run-pass/email.rs new file mode 100644 index 0000000..edfc357 --- /dev/null +++ b/validator_derive/tests/run-pass/email.rs @@ -0,0 +1,13 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +struct Test { +    #[validate(email)] +    s: String, +} + +fn main() {} diff --git a/validator_derive/tests/run-pass/length.rs b/validator_derive/tests/run-pass/length.rs new file mode 100644 index 0000000..01b85ea --- /dev/null +++ b/validator_derive/tests/run-pass/length.rs @@ -0,0 +1,28 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +struct Test { +    #[validate(length(min = 1))] +    s: String, +    #[validate(length(min = 1, max = 2))] +    s2: String, +    #[validate(length(equal = 1))] +    s3: String, +    #[validate(length(max = 1))] +    s4: String, + +    #[validate(length(min = 1))] +    s5: Vec<String>, +    #[validate(length(min = 1, max = 2))] +    s6: Vec<String>, +    #[validate(length(equal = 1))] +    s7: Vec<String>, +    #[validate(length(max = 1))] +    s8: Vec<String>, +} + +fn main() {} diff --git a/validator_derive/tests/run-pass/range.rs b/validator_derive/tests/run-pass/range.rs new file mode 100644 index 0000000..8f3a047 --- /dev/null +++ b/validator_derive/tests/run-pass/range.rs @@ -0,0 +1,27 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +struct Test { +    #[validate(range(min = 1, max = 2.2))] +    s: isize, +    #[validate(range(min = 1, max = 2))] +    s2: usize, +    #[validate(range(min = 18, max = 22))] +    s3: i32, +    #[validate(range(min = 18, max = 22))] +    s4: i64, +    #[validate(range(min = 18, max = 22))] +    s5: u32, +    #[validate(range(min = 18, max = 22))] +    s6: u64, +    #[validate(range(min = 18.1, max = 22))] +    s7: i8, +    #[validate(range(min = 18.0, max = 22))] +    s8: u8, +} + +fn main() {} diff --git a/validator_derive/tests/run-pass/url.rs b/validator_derive/tests/run-pass/url.rs new file mode 100644 index 0000000..910676a --- /dev/null +++ b/validator_derive/tests/run-pass/url.rs @@ -0,0 +1,13 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +use validator::Validate; + +#[derive(Validate)] +struct Test { +    #[validate(url)] +    s: String, +} + +fn main() {} diff --git a/validator_derive/tests/test_derive.rs b/validator_derive/tests/test_derive.rs new file mode 100644 index 0000000..1e310b1 --- /dev/null +++ b/validator_derive/tests/test_derive.rs @@ -0,0 +1,136 @@ +#![feature(proc_macro, attr_literals)] + +#[macro_use] extern crate validator_derive; +extern crate validator; +#[macro_use] extern crate serde_derive; +extern crate serde_json; + +use validator::Validate; + + +#[derive(Debug, Validate, Deserialize)] +struct SignupData { +    #[validate(email)] +    mail: String, +    #[validate(url)] +    site: String, +    #[validate(length(min = 1), custom = "validate_unique_username")] +    #[serde(rename = "firstName")] +    first_name: String, +    #[validate(range(min = 18, max = 20))] +    age: u32, +} + +fn validate_unique_username(username: &str) -> Option<String> { +    if username == "xXxShad0wxXx" { +        return Some("terrible_username".to_string()); +    } + +    None +} + +#[test] +fn test_can_validate_ok() { +    let signup = SignupData { +        mail: "bob@bob.com".to_string(), +        site: "http://hello.com".to_string(), +        first_name: "Bob".to_string(), +        age: 18, +    }; + +    assert!(signup.validate().is_ok()); +} + +#[test] +fn test_bad_email_fails_validation() { +    let signup = SignupData { +        mail: "bob".to_string(), +        site: "http://hello.com".to_string(), +        first_name: "Bob".to_string(), +        age: 18, +    }; +    let res = signup.validate(); +    assert!(res.is_err()); +    let errs = res.unwrap_err(); +    assert!(errs.contains_key("mail")); +    assert_eq!(errs["mail"], vec!["email".to_string()]); +} + +#[test] +fn test_bad_url_fails_validation() { +    let signup = SignupData { +        mail: "bob@bob.com".to_string(), +        site: "//hello.com".to_string(), +        first_name: "Bob".to_string(), +        age: 18, +    }; +    let res = signup.validate(); +    assert!(res.is_err()); +    let errs = res.unwrap_err(); +    assert!(errs.contains_key("site")); +    assert_eq!(errs["site"], vec!["url".to_string()]); +} + +#[test] +fn test_bad_length_fails_validation_and_points_to_original_name() { +    let signup = SignupData { +        mail: "bob@bob.com".to_string(), +        site: "http://hello.com".to_string(), +        first_name: "".to_string(), +        age: 18, +    }; +    let res = signup.validate(); +    assert!(res.is_err()); +    let errs = res.unwrap_err(); +    println!("{:?}", errs); +    assert!(errs.contains_key("firstName")); +    assert_eq!(errs["firstName"], vec!["length".to_string()]); +} + + +#[test] +fn test_bad_range_fails_validation() { +    let signup = SignupData { +        mail: "bob@bob.com".to_string(), +        site: "https://hello.com".to_string(), +        first_name: "Bob".to_string(), +        age: 1, +    }; +    let res = signup.validate(); +    assert!(res.is_err()); +    let errs = res.unwrap_err(); +    assert!(errs.contains_key("age")); +    assert_eq!(errs["age"], vec!["range".to_string()]); +} + +#[test] +fn test_can_have_multiple_errors() { +    let signup = SignupData { +        mail: "bob@bob.com".to_string(), +        site: "https://hello.com".to_string(), +        first_name: "".to_string(), +        age: 1, +    }; +    let res = signup.validate(); +    assert!(res.is_err()); +    let errs = res.unwrap_err(); +    assert!(errs.contains_key("age")); +    assert!(errs.contains_key("firstName")); +    assert_eq!(errs["age"], vec!["range".to_string()]); +    assert_eq!(errs["firstName"], vec!["length".to_string()]); +} + +#[test] +fn test_custom_validation_error() { +    let signup = SignupData { +        mail: "bob@bob.com".to_string(), +        site: "https://hello.com".to_string(), +        first_name: "xXxShad0wxXx".to_string(), +        age: 18, +    }; +    let res = signup.validate(); +    assert!(res.is_err()); +    let errs = res.unwrap_err(); +    assert!(errs.contains_key("firstName")); +    assert_eq!(errs["firstName"], vec!["terrible_username".to_string()]); +} | 
