Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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"] }

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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 |
Expand Down
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down Expand Up @@ -134,12 +134,12 @@

| Feature | Total | Done | Remaining |
|---|---|---|---|
| `contact` | 5 | 3 | 2 |
| `contact` | 5 | 4 | 1 |
| `identifiers` | 7 | 0 | 7 |
| `finance` | 9 | 0 | 9 |
| `temporal` | 5 | 0 | 5 |
| `geo` | 6 | 0 | 6 |
| `net` | 10 | 0 | 10 |
| `measurement` | 10 | 0 | 10 |
| `primitives` | 10 | 0 | 10 |
| **Total** | **62** | **1** | **61** |
| **Total** | **62** | **4** | **58** |
40 changes: 39 additions & 1 deletion docs/contact.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 4 additions & 1 deletion src/contact/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
176 changes: 176 additions & 0 deletions src/contact/website.rs
Original file line number Diff line number Diff line change
@@ -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<Self, Self::Error> {
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, Self::Error> {
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());
}
}
Loading