#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
macro_rules! database_url_text_type {
($type_name:ident, $empty_error:expr) => {
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct $type_name(String);
impl $type_name {
pub fn new(input: impl AsRef<str>) -> Result<Self, DatabaseUrlError> {
validate_text(input.as_ref(), $empty_error).map(|value| Self(value.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_string(self) -> String {
self.0
}
}
impl AsRef<str> for $type_name {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for $type_name {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for $type_name {
type Err = DatabaseUrlError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
};
}
database_url_text_type!(DatabaseUrl, DatabaseUrlError::EmptyUrl);
database_url_text_type!(DatabaseHost, DatabaseUrlError::EmptyHost);
database_url_text_type!(DatabaseDsn, DatabaseUrlError::EmptyDsn);
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DatabaseScheme(String);
impl DatabaseScheme {
pub fn new(input: impl AsRef<str>) -> Result<Self, DatabaseUrlError> {
let trimmed = validate_text(input.as_ref(), DatabaseUrlError::EmptyScheme)?;
let mut characters = trimmed.chars();
let Some(first) = characters.next() else {
return Err(DatabaseUrlError::EmptyScheme);
};
if !first.is_ascii_alphabetic()
|| !characters.all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.')
})
{
return Err(DatabaseUrlError::InvalidScheme);
}
Ok(Self(trimmed.to_ascii_lowercase()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for DatabaseScheme {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DatabasePort(u16);
impl DatabasePort {
pub const fn new(port: u16) -> Result<Self, DatabaseUrlError> {
if port == 0 {
Err(DatabaseUrlError::InvalidPort)
} else {
Ok(Self(port))
}
}
#[must_use]
pub const fn value(self) -> u16 {
self.0
}
}
impl fmt::Display for DatabasePort {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.0)
}
}
#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DatabasePath(String);
impl DatabasePath {
pub fn new(input: impl AsRef<str>) -> Result<Self, DatabaseUrlError> {
let input = input.as_ref();
if input.chars().any(char::is_control) {
return Err(DatabaseUrlError::ControlCharacter);
}
let text = input.trim();
Ok(Self(text.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for DatabasePath {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DatabaseUrlParts {
pub scheme: DatabaseScheme,
pub host: Option<DatabaseHost>,
pub port: Option<DatabasePort>,
pub path: DatabasePath,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DatabaseUrlError {
EmptyUrl,
EmptyScheme,
EmptyHost,
EmptyDsn,
ControlCharacter,
InvalidScheme,
InvalidPort,
MissingScheme,
}
impl fmt::Display for DatabaseUrlError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyUrl => formatter.write_str("database URL cannot be empty"),
Self::EmptyScheme => formatter.write_str("database URL scheme cannot be empty"),
Self::EmptyHost => formatter.write_str("database URL host cannot be empty"),
Self::EmptyDsn => formatter.write_str("database DSN cannot be empty"),
Self::ControlCharacter => {
formatter.write_str("database URL text cannot contain control characters")
},
Self::InvalidScheme => formatter.write_str("database URL scheme is invalid"),
Self::InvalidPort => formatter.write_str("database URL port is invalid"),
Self::MissingScheme => formatter.write_str("database URL is missing a scheme"),
}
}
}
impl Error for DatabaseUrlError {}
impl DatabaseUrl {
pub fn parse_basic(&self) -> Result<DatabaseUrlParts, DatabaseUrlError> {
parse_database_url_basic(self.as_str())
}
#[must_use]
pub fn scheme(&self) -> Option<DatabaseScheme> {
self.parse_basic().ok().map(|parts| parts.scheme)
}
}
pub fn parse_database_url_basic(input: &str) -> Result<DatabaseUrlParts, DatabaseUrlError> {
let trimmed = validate_text(input, DatabaseUrlError::EmptyUrl)?;
let scheme_end = trimmed.find(':').ok_or(DatabaseUrlError::MissingScheme)?;
let scheme = DatabaseScheme::new(&trimmed[..scheme_end])?;
let mut remainder = &trimmed[scheme_end + 1..];
let mut host = None;
let mut port = None;
if let Some(after_slashes) = remainder.strip_prefix("//") {
let authority_end = after_slashes
.find(['/', '?', '#'])
.unwrap_or(after_slashes.len());
let authority = &after_slashes[..authority_end];
remainder = &after_slashes[authority_end..];
if !authority.is_empty() {
let host_port = authority
.rsplit_once('@')
.map_or(authority, |(_, tail)| tail);
if let Some((host_text, port_text)) = host_port.rsplit_once(':') {
if !host_text.is_empty()
&& port_text
.chars()
.all(|character| character.is_ascii_digit())
{
host = Some(DatabaseHost::new(host_text)?);
port = Some(DatabasePort::new(
port_text
.parse()
.map_err(|_| DatabaseUrlError::InvalidPort)?,
)?);
} else {
host = Some(DatabaseHost::new(host_port)?);
}
} else {
host = Some(DatabaseHost::new(host_port)?);
}
}
}
let suffix_end = remainder.find(['?', '#']).unwrap_or(remainder.len());
let path = DatabasePath::new(&remainder[..suffix_end])?;
Ok(DatabaseUrlParts {
scheme,
host,
port,
path,
})
}
#[must_use]
pub fn looks_like_database_url(input: &str) -> bool {
parse_database_url_basic(input).is_ok()
}
fn validate_text(input: &str, empty_error: DatabaseUrlError) -> Result<&str, DatabaseUrlError> {
if input.chars().any(char::is_control) {
return Err(DatabaseUrlError::ControlCharacter);
}
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(empty_error);
}
Ok(trimmed)
}
#[cfg(test)]
mod tests {
use super::{
DatabasePort, DatabaseScheme, DatabaseUrl, DatabaseUrlError, looks_like_database_url,
};
#[test]
fn parses_database_urls() -> Result<(), DatabaseUrlError> {
let url = DatabaseUrl::new("postgresql://localhost:5432/app")?;
let parts = url.parse_basic()?;
assert_eq!(parts.scheme.as_str(), "postgresql");
assert_eq!(parts.host.expect("host").as_str(), "localhost");
assert_eq!(parts.port.expect("port").value(), 5432);
assert_eq!(parts.path.as_str(), "/app");
assert!(looks_like_database_url("sqlite:///tmp/app.db"));
Ok(())
}
#[test]
fn validates_scheme_and_port() {
assert_eq!(
DatabaseScheme::new("1bad"),
Err(DatabaseUrlError::InvalidScheme)
);
assert_eq!(DatabasePort::new(0), Err(DatabaseUrlError::InvalidPort));
assert_eq!(DatabaseUrl::new(""), Err(DatabaseUrlError::EmptyUrl));
}
}