use core::fmt;
use std::str::FromStr;
use crate::context::{Cx, try_app_context};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BaseUrl {
url: String,
}
impl BaseUrl {
pub fn new(url: impl AsRef<str>) -> Result<BaseUrl, BaseUrlError> {
let url = url.as_ref();
if url.contains('#') {
return Err(BaseUrlError::HasFragment);
}
let uri: http::Uri = url.parse().map_err(BaseUrlError::Invalid)?;
let Some(scheme) = uri.scheme_str() else {
return Err(BaseUrlError::NotAbsolute);
};
if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
return Err(BaseUrlError::UnsupportedScheme);
}
let Some(authority) = uri.authority() else {
return Err(BaseUrlError::NotAbsolute);
};
if uri.query().is_some() {
return Err(BaseUrlError::HasQuery);
}
let path = uri.path().trim_end_matches('/');
Ok(BaseUrl {
url: format!("{}://{authority}{path}", scheme.to_ascii_lowercase()),
})
}
#[must_use]
pub fn join(&self, path: &str) -> String {
format!("{}/{}", self.url, path.trim_start_matches('/'))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.url
}
}
impl fmt::Display for BaseUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.url)
}
}
impl FromStr for BaseUrl {
type Err = BaseUrlError;
fn from_str(s: &str) -> Result<BaseUrl, BaseUrlError> {
BaseUrl::new(s)
}
}
impl TryFrom<&str> for BaseUrl {
type Error = BaseUrlError;
fn try_from(url: &str) -> Result<BaseUrl, BaseUrlError> {
url.parse()
}
}
impl TryFrom<String> for BaseUrl {
type Error = BaseUrlError;
fn try_from(url: String) -> Result<BaseUrl, BaseUrlError> {
url.parse()
}
}
impl TryFrom<&String> for BaseUrl {
type Error = BaseUrlError;
fn try_from(url: &String) -> Result<BaseUrl, BaseUrlError> {
url.parse()
}
}
impl From<&BaseUrl> for BaseUrl {
fn from(base_url: &BaseUrl) -> BaseUrl {
base_url.clone()
}
}
#[derive(Debug, thiserror::Error)]
pub enum BaseUrlError {
#[error("invalid base URL: {0}")]
Invalid(#[source] http::uri::InvalidUri),
#[error("base URL must be absolute, like `https://example.com`")]
NotAbsolute,
#[error("base URL scheme must be `http` or `https`")]
UnsupportedScheme,
#[error("base URL cannot have a query string")]
HasQuery,
#[error("base URL cannot have a fragment")]
HasFragment,
}
#[must_use]
pub fn base_url(cx: &Cx) -> &BaseUrl {
match try_base_url(cx) {
Some(base_url) => base_url,
None => panic!(
"attempted to access the base URL, but none was registered; \
register one on the router builder with `.base_url(...)`"
),
}
}
#[must_use]
pub fn try_base_url(cx: &Cx) -> Option<&BaseUrl> {
try_app_context(cx)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::CxTestBuilder;
#[test]
fn accepts_and_normalizes_absolute_http_urls() -> Result<(), BaseUrlError> {
let base = BaseUrl::new("https://example.com")?;
assert_eq!(base.as_str(), "https://example.com");
assert_eq!(base.to_string(), "https://example.com");
let dev: BaseUrl = "http://localhost:3000".parse()?;
assert_eq!(dev.as_str(), "http://localhost:3000");
let prefixed = BaseUrl::try_from("https://example.com/app/")?;
assert_eq!(prefixed.as_str(), "https://example.com/app");
let uppercase = BaseUrl::new("HTTPS://example.com/")?;
assert_eq!(uppercase.as_str(), "https://example.com");
Ok(())
}
#[test]
fn rejects_urls_that_cannot_serve_as_a_base() {
assert!(matches!(
BaseUrl::new("example.com"),
Err(BaseUrlError::NotAbsolute)
));
assert!(matches!(
BaseUrl::new("/app"),
Err(BaseUrlError::NotAbsolute)
));
assert!(matches!(
BaseUrl::new("ftp://example.com"),
Err(BaseUrlError::UnsupportedScheme)
));
assert!(matches!(
BaseUrl::new("https://example.com?page=1"),
Err(BaseUrlError::HasQuery)
));
assert!(matches!(
BaseUrl::new("https://example.com#top"),
Err(BaseUrlError::HasFragment)
));
assert!(matches!(
BaseUrl::new("https://exa mple.com"),
Err(BaseUrlError::Invalid(_))
));
}
#[test]
fn joins_paths_below_the_base() -> Result<(), BaseUrlError> {
let base = BaseUrl::new("https://example.com")?;
assert_eq!(
base.join("/assets/logo.png"),
"https://example.com/assets/logo.png"
);
assert_eq!(
base.join("assets/logo.png"),
"https://example.com/assets/logo.png"
);
assert_eq!(
base.join("/posts?page=2"),
"https://example.com/posts?page=2"
);
assert_eq!(base.join("/"), "https://example.com/");
let prefixed = BaseUrl::new("https://example.com/app")?;
assert_eq!(
prefixed.join("/assets/logo.png"),
"https://example.com/app/assets/logo.png"
);
Ok(())
}
#[test]
fn reads_the_registered_base_url_from_context() -> Result<(), BaseUrlError> {
let cx = CxTestBuilder::new()
.app_context(BaseUrl::new("https://example.com")?)
.build();
assert_eq!(base_url(&cx).as_str(), "https://example.com");
assert_eq!(
try_base_url(&cx),
Some(&BaseUrl::new("https://example.com")?)
);
Ok(())
}
#[test]
fn try_base_url_is_none_when_unregistered() {
let cx = Cx::default();
assert_eq!(try_base_url(&cx), None);
}
#[test]
#[should_panic(expected = "attempted to access the base URL")]
fn base_url_panics_when_unregistered() {
let cx = Cx::default();
let _ = base_url(&cx);
}
}