use crate::errors::ValidationError;
use crate::traits::ValueObject;
pub type UrlInput = String;
pub type UrlOutput = String;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct Url(String);
const ALLOWED_SCHEMES: &[&str] = &["ftp", "ftps", "http", "https", "ws", "wss"];
impl ValueObject for Url {
type Input = UrlInput;
type Output = UrlOutput;
type Error = ValidationError;
fn new(value: Self::Input) -> Result<Self, Self::Error> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(ValidationError::empty("Url"));
}
let parsed =
::url::Url::parse(trimmed).map_err(|_| ValidationError::invalid("Url", trimmed))?;
if parsed.host_str().is_none() {
return Err(ValidationError::invalid("Url", trimmed));
}
let scheme = parsed.scheme();
if ALLOWED_SCHEMES.binary_search(&scheme).is_err() {
return Err(ValidationError::invalid("Url", trimmed));
}
let canonical = parsed.to_string();
Ok(Self(canonical))
}
fn value(&self) -> &Self::Output {
&self.0
}
fn into_inner(self) -> Self::Input {
self.0
}
}
impl Url {
pub fn scheme(&self) -> &str {
self.0.split("://").next().unwrap_or("")
}
pub fn host(&self) -> &str {
let after_scheme = self.0.split("://").nth(1).unwrap_or("");
after_scheme
.split('/')
.next()
.unwrap_or("")
.split('?')
.next()
.unwrap_or("")
}
}
impl TryFrom<&str> for Url {
type Error = ValidationError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value.to_owned())
}
}
impl std::fmt::Display for Url {
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 url = Url::new("https://example.com/path".into()).unwrap();
assert_eq!(url.value(), "https://example.com/path");
}
#[test]
fn normalises_scheme_and_host() {
let url = Url::new("HTTPS://EXAMPLE.COM/path".into()).unwrap();
assert_eq!(url.scheme(), "https");
assert_eq!(url.host(), "example.com");
}
#[test]
fn accepts_http() {
assert!(Url::new("http://example.com".into()).is_ok());
}
#[test]
fn accepts_ftp() {
assert!(Url::new("ftp://files.example.com/file.txt".into()).is_ok());
}
#[test]
fn accepts_ws() {
assert!(Url::new("ws://example.com/socket".into()).is_ok());
}
#[test]
fn rejects_empty() {
assert!(Url::new(String::new()).is_err());
}
#[test]
fn rejects_invalid_url() {
assert!(Url::new("not-a-url".into()).is_err());
}
#[test]
fn rejects_disallowed_scheme() {
assert!(Url::new("mailto:user@example.com".into()).is_err());
assert!(Url::new("file:///etc/passwd".into()).is_err());
}
#[test]
fn rejects_no_host() {
assert!(Url::new("https://".into()).is_err());
}
#[test]
fn try_from_str() {
let url: Url = "https://example.com".try_into().unwrap();
assert_eq!(url.scheme(), "https");
}
}