From 94b55ed66e8b29480ce196df8671010c290d0ab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Sun, 19 Apr 2026 19:55:38 +0200 Subject: [PATCH 1/2] Add Website (contact) Validates http/https URLs using the url crate; normalises scheme and host to lowercase on construction. Closes #11 --- Cargo.toml | 2 +- src/contact/mod.rs | 5 +- src/contact/website.rs | 176 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 src/contact/website.rs diff --git a/Cargo.toml b/Cargo.toml index 870bef1..047b83f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ rust-version = "1.85" default = [] # Domain modules — opt-in so you only pay for what you use -contact = ["dep:once_cell", "dep:regex"] +contact = ["dep:once_cell", "dep:regex", "dep:url"] # Cross-cutting concerns can be combined with any module serde = ["dep:serde"] diff --git a/src/contact/mod.rs b/src/contact/mod.rs index a8eb1c7..26ee637 100644 --- a/src/contact/mod.rs +++ b/src/contact/mod.rs @@ -1,8 +1,11 @@ -//! Contact-related value objects: email, phone, country code, and postal address. +//! Contact-related value objects: email, phone, country code, postal address, and website. mod country_code; mod email_address; mod phone_number; +mod website; pub use country_code::CountryCode; pub use email_address::EmailAddress; pub use phone_number::PhoneNumber; +pub use website::Website; +pub use website::WebsiteInput; diff --git a/src/contact/website.rs b/src/contact/website.rs new file mode 100644 index 0000000..3cab77a --- /dev/null +++ b/src/contact/website.rs @@ -0,0 +1,176 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; +use url::Url; + +/// Input type for [`Website`] — a raw string before validation. +pub type WebsiteInput = String; + +/// Output type for [`Website`] — a normalised URL string. +pub type WebsiteOutput = String; + +/// A validated website URL. +/// +/// Accepts `http` and `https` schemes only. On construction the value is +/// parsed and normalised (scheme and host lowercased) so `"HTTPS://Example.COM/"` +/// and `"https://example.com/"` produce equal instances. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::contact::Website; +/// use arvo::traits::ValueObject; +/// +/// let site = Website::new("https://Example.COM/path".into()).unwrap(); +/// assert_eq!(site.value(), "https://example.com/path"); +/// +/// assert!(Website::new("ftp://example.com".into()).is_err()); +/// assert!(Website::new("not-a-url".into()).is_err()); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct Website(String); + +impl ValueObject for Website { + type Input = WebsiteInput; + type Output = WebsiteOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let trimmed = value.trim(); + + if trimmed.is_empty() { + return Err(ValidationError::empty("Website")); + } + + let parsed = + Url::parse(trimmed).map_err(|_| ValidationError::invalid("Website", trimmed))?; + + match parsed.scheme() { + "http" | "https" => {} + _ => return Err(ValidationError::invalid("Website", trimmed)), + } + + if parsed.host().is_none() { + return Err(ValidationError::invalid("Website", trimmed)); + } + + Ok(Self(parsed.to_string())) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl Website { + /// Returns `true` if the scheme is `https`. + pub fn is_https(&self) -> bool { + self.0.starts_with("https://") + } + + /// Returns the host portion of the URL, e.g. `"example.com"`. + pub fn host(&self) -> &str { + let after_scheme = self + .0 + .find("://") + .map(|i| &self.0[i + 3..]) + .unwrap_or(&self.0); + after_scheme.split('/').next().unwrap_or("") + } +} + +/// Allows ergonomic construction from a string literal: `"https://example.com".try_into()` +impl TryFrom<&str> for Website { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + +/// Displays the website as its normalised URL string. +impl std::fmt::Display for Website { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_https_url() { + let w = Website::new("https://example.com".into()).unwrap(); + assert_eq!(w.value(), "https://example.com/"); + } + + #[test] + fn accepts_http_url() { + let w = Website::new("http://example.com".into()).unwrap(); + assert_eq!(w.value(), "http://example.com/"); + } + + #[test] + fn normalises_host_to_lowercase() { + let w = Website::new("https://EXAMPLE.COM/Path".into()).unwrap(); + assert_eq!(w.value(), "https://example.com/Path"); + } + + #[test] + fn trims_surrounding_whitespace() { + let w = Website::new(" https://example.com ".into()).unwrap(); + assert_eq!(w.value(), "https://example.com/"); + } + + #[test] + fn rejects_ftp_scheme() { + assert!(Website::new("ftp://example.com".into()).is_err()); + } + + #[test] + fn rejects_non_url() { + assert!(Website::new("not-a-url".into()).is_err()); + } + + #[test] + fn rejects_empty_string() { + assert!(Website::new(String::new()).is_err()); + } + + #[test] + fn equal_after_normalisation() { + let a = Website::new("https://example.com/".into()).unwrap(); + let b = Website::new("https://example.com/".into()).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn is_https_returns_true_for_https() { + let w = Website::new("https://example.com".into()).unwrap(); + assert!(w.is_https()); + } + + #[test] + fn is_https_returns_false_for_http() { + let w = Website::new("http://example.com".into()).unwrap(); + assert!(!w.is_https()); + } + + #[test] + fn host_returns_domain() { + let w = Website::new("https://example.com/path".into()).unwrap(); + assert_eq!(w.host(), "example.com"); + } + + #[test] + fn try_from_str() { + let w: Website = "https://example.com".try_into().unwrap(); + assert!(w.is_https()); + } +} From c750fa9b67abf5027f07732babe6f6cba535e0d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Sun, 19 Apr 2026 19:59:45 +0200 Subject: [PATCH 2/2] fix: pin url to ~2.4 to satisfy MSRV 1.85; update docs and roadmap url 2.5.x pulls in icu_* 2.2.0 which requires rustc 1.86. Pinning to ~2.4 keeps idna at 0.4.x (unicode-normalization based). Also marks Website as done in ROADMAP.md, updates README feature table and roadmap summary, and adds Website reference to docs/contact.md. --- Cargo.toml | 2 +- README.md | 4 ++-- ROADMAP.md | 6 +++--- docs/contact.md | 40 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 047b83f..a537d19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ rust_decimal = { version = "1", optional = true } chrono = { version = "0.4", optional = true, features = ["serde"] } uuid = { version = "1", optional = true, features = ["v4"] } ulid = { version = "1", optional = true } -url = { version = "2", optional = true } +url = { version = "~2.4", optional = true } serde = { version = "1", optional = true, features = ["derive"] } sqlx = { version = "0.8", optional = true, features = ["postgres"] } diff --git a/README.md b/README.md index 74a1d88..e075492 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Enable only the modules you need — unused features add zero dependencies. | Feature | What you get | Extra deps | |:---|:---|:---| -| `contact` | `EmailAddress`, `CountryCode`, `PhoneNumber` | `once_cell`, `regex` | +| `contact` | `EmailAddress`, `CountryCode`, `PhoneNumber`, `Website` | `once_cell`, `regex`, `url` | | `serde` | `Serialize` / `Deserialize` on all types | `serde` | | `full` | All domain modules | all of the above | @@ -195,7 +195,7 @@ let parsed: EmailAddress = serde_json::from_str(r#""hello@example.com""#)?; | Feature | Highlights | Types | Status | |:---|:---|:---:|:---:| -| `contact` | `EmailAddress`, `PhoneNumber`, `CountryCode`, `PostalAddress` | 5 | 3 / 5 | +| `contact` | `EmailAddress`, `PhoneNumber`, `CountryCode`, `Website` | 5 | 4 / 5 | | `identifiers` | `Slug`, `Ean13`, `Isbn13`, `Vin` | 7 | 0 / 7 | | `finance` | `Money`, `Iban`, `Bic`, `VatNumber`, `CreditCardNumber` | 9 | 0 / 9 | | `temporal` | `BirthDate`, `ExpiryDate`, `TimeRange`, `BusinessHours` | 5 | 0 / 5 | diff --git a/ROADMAP.md b/ROADMAP.md index 55aaeaf..b6d1a4a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ | `PhoneNumber` | ✅ | E.164, strips spaces/dashes | | `CountryCode` | ✅ | ISO 3166-1 alpha-2, normalised to uppercase | | `PostalAddress` | ⬜ | composite: street + city + zip + `CountryCode`; fields validated as non-empty | -| `Website` | ⬜ | valid URL, https preferred | +| `Website` | ✅ | valid URL, http/https only, normalised | --- @@ -134,7 +134,7 @@ | Feature | Total | Done | Remaining | |---|---|---|---| -| `contact` | 5 | 3 | 2 | +| `contact` | 5 | 4 | 1 | | `identifiers` | 7 | 0 | 7 | | `finance` | 9 | 0 | 9 | | `temporal` | 5 | 0 | 5 | @@ -142,4 +142,4 @@ | `net` | 10 | 0 | 10 | | `measurement` | 10 | 0 | 10 | | `primitives` | 10 | 0 | 10 | -| **Total** | **62** | **1** | **61** | +| **Total** | **62** | **4** | **58** | diff --git a/docs/contact.md b/docs/contact.md index c665b79..37ef72a 100644 --- a/docs/contact.md +++ b/docs/contact.md @@ -133,11 +133,49 @@ pub struct PhoneNumberInput { --- +## Website + +A validated website URL. Accepts `http` and `https` schemes only. Scheme and host are normalised to lowercase on construction. + +**Normalisation:** scheme and host lowercased. +**Validation:** must be a valid URL with `http` or `https` scheme and a host. + +```rust,ignore +use arvo::contact::Website; +use arvo::traits::ValueObject; + +let site = Website::new("https://EXAMPLE.COM/path".into())?; +assert_eq!(site.value(), "https://example.com/path"); +assert!(site.is_https()); +assert_eq!(site.host(), "example.com"); + +// try_into from &str +let site: Website = "https://example.com".try_into()?; +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"https://example.com/"` | +| `is_https()` | `bool` | `true` | +| `host()` | `&str` | `"example.com"` | +| `into_inner()` | `String` | `"https://example.com/"` | + +### Errors + +| Input | Error | +|---|---| +| `""` | `ValidationError::Empty` | +| `"not-a-url"` | `ValidationError::InvalidFormat` | +| `"ftp://example.com"` | `ValidationError::InvalidFormat` (scheme not allowed) | + +--- + ## Planned | Type | Notes | |---|---| | `PostalAddress` | composite: street + city + zip + `CountryCode` | -| `Website` | valid URL, https preferred | See [ROADMAP.md](../ROADMAP.md) for full details.