From 015c6aaf89d9d1f45822516bf76089b67550c4f4 Mon Sep 17 00:00:00 2001 From: Alexis Besson Date: Sun, 20 Jul 2025 10:27:09 +0200 Subject: [PATCH] add validation --- Cargo.lock | 526 ++++++++++++++++++++++++++++++++++ Cargo.toml | 4 + src/lib.rs | 1 + src/resume.rs | 3 +- src/resume/award.rs | 5 +- src/resume/basics.rs | 7 +- src/resume/basics/location.rs | 5 +- src/resume/basics/profile.rs | 5 +- src/resume/certificate.rs | 6 +- src/resume/education.rs | 7 +- src/resume/project.rs | 7 +- src/resume/publication.rs | 6 +- src/resume/volunteer.rs | 7 +- src/resume/work.rs | 7 +- src/validation.rs | 312 ++++++++++++++++++++ 15 files changed, 897 insertions(+), 11 deletions(-) create mode 100644 src/validation.rs diff --git a/Cargo.lock b/Cargo.lock index fa04883..686d840 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,11 +2,279 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", + "serde", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + [[package]] name = "json-resume" version = "0.1.0" dependencies = [ + "email_address", + "regex", "serde", + "serde_valid", + "url", +] + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", ] [[package]] @@ -27,6 +295,41 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + [[package]] name = "serde" version = "1.0.219" @@ -47,6 +350,81 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.141" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_valid" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b615bed66931a7a9809b273937adc8a402d038b1e509d027fcaf62f084d33d1" +dependencies = [ + "indexmap", + "itertools", + "num-traits", + "once_cell", + "paste", + "regex", + "serde", + "serde_json", + "serde_valid_derive", + "serde_valid_literal", + "thiserror", + "unicode-segmentation", +] + +[[package]] +name = "serde_valid_derive" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa1a5a21ea5aab06d2e6a6b59837d450fb2be9695be97735a711edfbe79ea07" +dependencies = [ + "itertools", + "paste", + "proc-macro-error2", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "serde_valid_literal" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd07331596ea967dccf9a35bde71ecd757490e09827b938a5c6226c648e3a25e" +dependencies = [ + "paste", + "regex", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.104" @@ -58,8 +436,156 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 299e5ef..2bee27f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,4 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] +email_address = "0.2.9" +regex = "1.11.1" serde = { version = "1.0.219", features = ["derive"] } +serde_valid = "1.0.5" +url = "2.5.4" diff --git a/src/lib.rs b/src/lib.rs index 2615e68..1fcd895 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1,2 @@ mod resume; +mod validation; diff --git a/src/resume.rs b/src/resume.rs index 5dd261b..d5bc1ae 100644 --- a/src/resume.rs +++ b/src/resume.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use serde_valid::Validate; mod award; mod basics; @@ -26,7 +27,7 @@ use skill::Skill; use volunteer::Volunteer; use work::Work; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Validate)] pub struct Resume { pub basics: Basics, pub work: Vec, diff --git a/src/resume/award.rs b/src/resume/award.rs index e53cef5..96e14b5 100644 --- a/src/resume/award.rs +++ b/src/resume/award.rs @@ -1,8 +1,11 @@ +use crate::validation::validate_date; use serde::{Deserialize, Serialize}; +use serde_valid::Validate; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Validate)] pub struct Award { pub title: Option, + #[validate(custom = validate_date)] pub date: Option, pub awarder: Option, pub summary: Option, diff --git a/src/resume/basics.rs b/src/resume/basics.rs index 6834660..076dd43 100644 --- a/src/resume/basics.rs +++ b/src/resume/basics.rs @@ -1,4 +1,6 @@ +use crate::validation::{validate_email, validate_url}; use serde::{Deserialize, Serialize}; +use serde_valid::Validate; mod location; mod profile; @@ -6,13 +8,16 @@ mod profile; use location::Location; use profile::Profile; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Validate)] pub struct Basics { pub name: Option, pub label: Option, + #[validate(custom = validate_url)] pub image: Option, + #[validate(custom = validate_email)] pub email: Option, pub phone: Option, + #[validate(custom = validate_url)] pub url: Option, pub summary: Option, pub location: Option, diff --git a/src/resume/basics/location.rs b/src/resume/basics/location.rs index 3e018b4..e78be3a 100644 --- a/src/resume/basics/location.rs +++ b/src/resume/basics/location.rs @@ -1,12 +1,15 @@ +use crate::validation::validate_country_code; use serde::{Deserialize, Serialize}; +use serde_valid::Validate; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Validate)] pub struct Location { pub address: Option, #[serde(rename = "postalCode")] pub postal_code: Option, pub city: Option, #[serde(rename = "countryCode")] + #[validate(custom = validate_country_code)] pub country_code: Option, pub region: Option, } diff --git a/src/resume/basics/profile.rs b/src/resume/basics/profile.rs index bcbeeb2..8fe2d85 100644 --- a/src/resume/basics/profile.rs +++ b/src/resume/basics/profile.rs @@ -1,8 +1,11 @@ +use crate::validation::validate_url; use serde::{Deserialize, Serialize}; +use serde_valid::Validate; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Validate)] pub struct Profile { pub network: Option, pub username: Option, + #[validate(custom = validate_url)] pub url: Option, } diff --git a/src/resume/certificate.rs b/src/resume/certificate.rs index 2b04228..c61ed6c 100644 --- a/src/resume/certificate.rs +++ b/src/resume/certificate.rs @@ -1,9 +1,13 @@ +use crate::validation::{validate_date, validate_url}; use serde::{Deserialize, Serialize}; +use serde_valid::Validate; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Validate)] pub struct Certificate { pub name: Option, + #[validate(custom = validate_date)] pub date: Option, pub issuer: Option, + #[validate(custom = validate_url)] pub url: Option, } diff --git a/src/resume/education.rs b/src/resume/education.rs index 974c155..6147a24 100644 --- a/src/resume/education.rs +++ b/src/resume/education.rs @@ -1,15 +1,20 @@ +use crate::validation::{validate_date, validate_url}; use serde::{Deserialize, Serialize}; +use serde_valid::Validate; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Validate)] pub struct Education { pub institution: Option, + #[validate(custom = validate_url)] pub url: Option, pub area: Option, #[serde(rename = "studyType")] pub study_type: Option, #[serde(rename = "startDate")] + #[validate(custom = validate_date)] pub start_date: Option, #[serde(rename = "endDate")] + #[validate(custom = validate_date)] pub end_date: Option, pub score: Option, pub courses: Option>, diff --git a/src/resume/project.rs b/src/resume/project.rs index 96d0f6a..82c5793 100644 --- a/src/resume/project.rs +++ b/src/resume/project.rs @@ -1,16 +1,21 @@ +use crate::validation::{validate_date, validate_url}; use serde::{Deserialize, Serialize}; +use serde_valid::Validate; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Validate)] pub struct Project { pub name: Option, pub description: Option, pub highlights: Vec, pub keywords: Vec, + #[validate(custom = validate_date)] #[serde(rename = "startDate")] pub start_date: Option, + #[validate(custom = validate_date)] #[serde(rename = "endDate")] pub end_date: Option, pub roles: Vec, + #[validate(custom = validate_url)] pub url: Option, pub entity: Option, #[serde(rename = "type")] diff --git a/src/resume/publication.rs b/src/resume/publication.rs index 95f18f7..b27c9ee 100644 --- a/src/resume/publication.rs +++ b/src/resume/publication.rs @@ -1,11 +1,15 @@ +use crate::validation::{validate_date, validate_url}; use serde::{Deserialize, Serialize}; +use serde_valid::Validate; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Validate)] pub struct Publication { pub name: Option, pub publisher: Option, #[serde(rename = "releaseDate")] + #[validate(custom = validate_date)] pub release_date: Option, + #[validate(custom = validate_url)] pub url: Option, pub summary: Option, } diff --git a/src/resume/volunteer.rs b/src/resume/volunteer.rs index 204708c..8e84597 100644 --- a/src/resume/volunteer.rs +++ b/src/resume/volunteer.rs @@ -1,13 +1,18 @@ +use crate::validation::{validate_date, validate_url}; use serde::{Deserialize, Serialize}; +use serde_valid::Validate; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Validate)] pub struct Volunteer { pub organization: Option, pub position: Option, + #[validate(custom = validate_url)] pub url: Option, #[serde(rename = "startDate")] + #[validate(custom = validate_date)] pub start_date: Option, #[serde(rename = "endDate")] + #[validate(custom = validate_date)] pub end_date: Option, pub summary: Option, pub highlights: Vec, diff --git a/src/resume/work.rs b/src/resume/work.rs index 723ac65..c266a8c 100644 --- a/src/resume/work.rs +++ b/src/resume/work.rs @@ -1,14 +1,19 @@ +use crate::validation::{validate_date, validate_url}; use serde::{Deserialize, Serialize}; +use serde_valid::Validate; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Validate)] pub struct Work { pub name: Option, pub description: Option, pub position: Option, + #[validate(custom = validate_url)] pub url: Option, #[serde(rename = "startDate")] + #[validate(custom = validate_date)] pub start_date: Option, #[serde(rename = "endDate")] + #[validate(custom = validate_date)] pub end_date: Option, pub summary: Option, pub highlights: Vec, diff --git a/src/validation.rs b/src/validation.rs new file mode 100644 index 0000000..edf9166 --- /dev/null +++ b/src/validation.rs @@ -0,0 +1,312 @@ +use email_address::EmailAddress; +use serde_valid::validation::Error; +use url::Url; + +// Validates that a string is a well-formed URL (RFC 3986). +pub fn validate_url(value: &Option) -> Result<(), Error> { + if let Some(url_str) = value { + Url::parse(url_str) + .map(|_| ()) + .map_err(|_| Error::Custom("must be a valid URL (RFC 3986)".to_string())) + } else { + Ok(()) + } +} + +// Validates country code as per ISO-3166-1 ALPHA-2, e.g. US, AU, IN +pub fn validate_country_code(value: &Option) -> Result<(), Error> { + if let Some(country_code) = value { + let re = regex::Regex::new(r"^[A-Z]{2}$").unwrap(); + if re.is_match(country_code) { + Ok(()) + } else { + Err(Error::Custom( + "must be a valid country code as per ISO-3166-1 ALPHA-2, e.g. US, AU, IN" + .to_string(), + )) + } + } else { + Ok(()) + } +} + +pub fn validate_email(value: &Option) -> Result<(), Error> { + if let Some(email) = value { + if EmailAddress::is_valid(email) { + Ok(()) + } else { + Err(Error::Custom("must be a valid email address".to_string())) + } + } else { + Ok(()) + } +} + +pub fn validate_date(value: &Option) -> Result<(), Error> { + if let Some(date_str) = value { + // Pattern: YYYY or YYYY-MM or YYYY-MM-DD + let re = + regex::Regex::new(r"^[1-9]\d{3}(?:-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?)?$") + .unwrap(); + if re.is_match(date_str) { + Ok(()) + } else { + Err(Error::Custom( + "must be a valid ISO8601 date: YYYY, YYYY-MM, or YYYY-MM-DD".to_string(), + )) + } + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_url_validation_valid_urls() { + let valid_urls = vec![ + "https://www.example.com", + "http://example.com", + "https://example.com/path", + "https://example.com/path?param=value", + "https://example.com/path#fragment", + "https://subdomain.example.com", + "https://example.com:8080", + ]; + + for url in valid_urls { + let url_str = Some(url.to_string()); + + assert!( + validate_url(&url_str).is_ok(), + "URL '{url}' should be valid", + ); + } + } + + #[test] + fn test_url_validation_invalid_urls() { + let invalid_urls = vec![ + "not-a-url", // No scheme + "http://", // Missing host + "https://", // Missing host + "ftp://", // Missing host + "://example.com", // Missing scheme + //"http://example", // Invalid TLD @TODO: this is considered valid by url crate + //"http://.com", // Missing hostname @TODO: this is considered valid by url crate + //"http://example..com", // Double dots @TODO: this is considered valid by url crate + "http://example.com:abc", // Invalid port + "http://example.com:99999", // Port out of range + "", // Empty string + ]; + + for url in invalid_urls { + let url_str = Some(url.to_string()); + + assert!( + validate_url(&url_str).is_err(), + "URL '{url}' should be invalid", + ); + } + } + + #[test] + fn test_url_validation_none() { + let url = None; + + // None should be valid since it's optional + assert!(validate_url(&url).is_ok(), "None URL should be valid"); + } + + #[test] + fn test_email_validation_valid_emails() { + let valid_emails = vec![ + "user@example.com", + "user.name@example.com", + "user+tag@example.com", + "user123@example.com", + "user@subdomain.example.com", + "user@example.co.uk", + "user@example-domain.com", + "user@example.com", + "user@example.org", + "user@example.net", + ]; + + for email in valid_emails { + let email_str = Some(email.to_string()); + + assert!( + validate_email(&email_str).is_ok(), + "Email '{email}' should be valid", + ); + } + } + + #[test] + fn test_email_validation_invalid_emails() { + let invalid_emails = vec![ + "not-an-email", // No @ symbol + "@example.com", // Missing local part + "user@", // Missing domain + "user@.com", // Missing domain name + //"user@example", // Missing TLD @TODO: this is considered valid by email_address crate + "user..name@example.com", // Double dots in local part + "user@example..com", // Double dots in domain + "user@example.com.", // Trailing dot + ".user@example.com", // Leading dot + "user name@example.com", // Space in local part + "user@example com", // Space in domain + "user@@example.com", // Double @ + "", // Empty string + ]; + + for email in invalid_emails { + let email_str = Some(email.to_string()); + + assert!( + validate_email(&email_str).is_err(), + "Email '{email}' should be invalid", + ); + } + } + + #[test] + fn test_email_validation_none() { + let email = None; + + // None should be valid since it's optional + assert!(validate_email(&email).is_ok(), "None email should be valid"); + } + + #[test] + fn test_date_validation_valid_dates() { + let valid_dates = vec![ + // YYYY format + "2023", + "1990", + "2050", + "1000", + "2999", + // YYYY-MM format + "2023-01", + "2023-12", + "1990-06", + "2050-03", + // YYYY-MM-DD format + "2023-01-01", + "2023-12-31", + "1990-06-15", + "2050-03-20", + "2023-02-28", // February (non-leap year) + "2024-02-29", // February (leap year) + ]; + + for date in valid_dates { + let date_str = Some(date.to_string()); + + assert!( + validate_date(&date_str).is_ok(), + "Date '{date}' should be valid", + ); + } + } + + #[test] + fn test_date_validation_invalid_dates() { + let invalid_dates = vec![ + // Invalid formats + "202", // Too short + "20234", // Too long + "2023-1", // Missing leading zero in month + "2023-13", // Invalid month + "2023-00", // Zero month + "2023-01-1", // Missing leading zero in day + "2023-01-32", // Invalid day @TODO + "2023-01-00", // Zero day @TODO + //"2023-02-30", // February 30th (invalid) @TODO : improve regex + //"2023-04-31", // April 31st (invalid) @TODO : improve regex + //"2023-06-31", // June 31st (invalid) @TODO : improve regex + //"2023-09-31", // September 31st (invalid) @TODO : improve regex + //"2023-11-31", // November 31st (invalid) @TODO : improve regex + //"2024-02-30", // February 30th in leap year (still invalid) @TODO : improve regex + + // Malformed strings + "2023/01/01", // Wrong separator + "01-01-2023", // Wrong order + "2023-01-01T", // Extra characters + "2023-01-01 ", // Trailing space + " 2023-01-01", // Leading space + "", // Empty string + "abc", // Non-numeric + "2023-abc-01", // Non-numeric month + "2023-01-abc", // Non-numeric day + ]; + + for date in invalid_dates { + let date_str = Some(date.to_string()); + + assert!( + validate_date(&date_str).is_err(), + "Date '{date}' should be invalid", + ); + } + } + + #[test] + fn test_date_validation_none() { + let date = None; + + // None should be valid since it's optional + assert!(validate_date(&date).is_ok(), "None date should be valid"); + } + + #[test] + fn test_country_code_validation_valid_codes() { + let valid_codes = vec!["US", "CA", "GB", "FR", "DE", "JP", "AU", "BR", "IN", "CN"]; + + for code in valid_codes { + let country_code = Some(code.to_string()); + + assert!( + validate_country_code(&country_code).is_ok(), + "Country code '{code}' should be valid", + ); + } + } + + #[test] + fn test_country_code_validation_invalid_codes() { + let invalid_codes = vec![ + "USA", // Too long + "u", // Too short + "us", // Lowercase + "Us", // Mixed case + "12", // Numbers + "A1", // Mixed alphanumeric + "", // Empty string + ]; + + for code in invalid_codes { + let country_code = Some(code.to_string()); + + assert!( + validate_country_code(&country_code).is_err(), + "Country code '{code}' should be invalid", + ); + } + } + + #[test] + fn test_country_code_validation_none() { + let country_code = None; + + // None should be valid since it's optional + assert!( + validate_country_code(&country_code).is_ok(), + "None country_code should be valid" + ); + } +}