use std::fmt::{Debug, Display};
use std::str::FromStr;
use http::uri::{Authority, Parts, PathAndQuery, Scheme};
use crate::origin::{HTTP_DEFAULT_PORT, HTTPS_DEFAULT_PORT};
use crate::{BasePath, Origin, UriError};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct BaseUri {
origin: Origin,
path: BasePath,
}
impl BaseUri {
#[must_use]
pub fn with_path(self, path: impl Into<BasePath>) -> Self {
Self { path: path.into(), ..self }
}
pub fn try_with_path<P>(self, path: P) -> Result<Self, UriError>
where
P: TryInto<BasePath>,
UriError: From<<P as TryInto<BasePath>>::Error>,
{
Ok(Self {
path: path.try_into()?,
..self
})
}
pub fn from_parts(origin: impl Into<Origin>, path: impl Into<BasePath>) -> Self {
Self {
origin: origin.into(),
path: path.into(),
}
}
#[must_use]
pub fn into_parts(self) -> (Origin, BasePath) {
(self.origin, self.path)
}
pub fn try_from_raw_parts(
scheme: impl TryInto<Scheme, Error: Into<http::Error>>,
host: impl AsRef<str>,
port: u16,
path: impl TryInto<BasePath, Error: Into<UriError>>,
) -> Result<Self, UriError> {
let scheme = scheme.try_into().map_err(|e| UriError::from(e.into()))?;
let authority: Authority = format!("{}:{}", host.as_ref(), port).parse()?;
let path = path.try_into().map_err(Into::into)?;
Ok(Self::from_parts(Origin::from_parts(scheme, authority), path))
}
#[must_use]
#[expect(clippy::expect_used, reason = "from_static is documented to panic on invalid input")]
pub fn from_static(uri: &'static str) -> Self {
Self::try_from(&http::Uri::from_static(uri)).expect("static str is not a valid base URI")
}
pub const fn scheme(&self) -> &Scheme {
self.origin.scheme()
}
pub const fn authority(&self) -> &Authority {
self.origin.authority()
}
pub fn host(&self) -> &str {
self.origin.authority().host()
}
pub fn origin(&self) -> &Origin {
&self.origin
}
#[must_use]
pub fn with_origin(self, origin: Origin) -> Self {
Self { origin, path: self.path }
}
#[must_use]
pub fn port(&self) -> Option<u16> {
self.origin.port()
}
#[must_use]
pub fn with_port(self, port: u16) -> Self {
Self {
origin: self.origin.with_port(port),
path: self.path,
}
}
pub const fn path(&self) -> &BasePath {
&self.path
}
pub fn is_https(&self) -> bool {
self.origin.is_https()
}
pub fn build_http_uri(&self, path: impl TryInto<PathAndQuery, Error: Into<http::Error>>) -> Result<http::Uri, UriError> {
let path = path.try_into().map_err(|e| UriError::from(e.into()))?;
self.build_http_uri_inner(&path)
}
fn build_http_uri_inner(&self, path: &PathAndQuery) -> Result<http::Uri, UriError> {
let full_path = self.path.join_path_and_query(path)?;
let mut parts = Parts::default();
parts.scheme = Some(self.scheme().clone());
parts.authority = Some(self.authority().clone());
parts.path_and_query = Some(full_path);
http::Uri::from_parts(parts).map_err(Into::into)
}
}
impl TryFrom<http::Uri> for BaseUri {
type Error = UriError;
fn try_from(uri: http::Uri) -> Result<Self, Self::Error> {
Self::try_from(&uri)
}
}
impl TryFrom<&http::Uri> for BaseUri {
type Error = UriError;
fn try_from(uri: &http::Uri) -> Result<Self, Self::Error> {
let (Some(scheme), Some(authority)) = (uri.scheme(), uri.authority()) else {
return Err(UriError::invalid_uri("URI must have both scheme and authority components"));
};
let path = match uri.path() {
"" | "/" => BasePath::default(),
p => BasePath::try_from(p)?,
};
Ok(Self::from_parts(Origin::from_parts(scheme.clone(), authority.clone()), path))
}
}
impl TryFrom<&str> for BaseUri {
type Error = UriError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
s.parse()
}
}
impl From<Origin> for BaseUri {
fn from(origin: Origin) -> Self {
Self {
origin,
path: BasePath::default(),
}
}
}
impl FromStr for BaseUri {
type Err = UriError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
http::Uri::from_str(s)?.try_into()
}
}
impl From<BaseUri> for http::Uri {
fn from(value: BaseUri) -> Self {
let (scheme, authority) = value.origin.into_parts();
let mut parts = Parts::default();
parts.scheme = Some(scheme);
parts.authority = Some(authority);
parts.path_and_query = Some(value.path.into());
Self::from_parts(parts).expect("all inputs are already validated, this call never fails")
}
}
impl Display for BaseUri {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}://", self.scheme())?;
match (self.scheme().as_str(), self.authority().port_u16()) {
(s, Some(HTTP_DEFAULT_PORT)) if s == Scheme::HTTP.as_str() => write!(f, "{}", self.host())?,
(s, Some(HTTPS_DEFAULT_PORT)) if s == Scheme::HTTPS.as_str() => write!(f, "{}", self.host())?,
_ => write!(f, "{}", self.authority())?,
}
write!(f, "{}", self.path)
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for BaseUri {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for BaseUri {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
mod from_parts {
use super::*;
#[test]
fn valid_base_uri() {
let origin = Origin::from_parts(Scheme::HTTPS, Authority::from_static("example.com"));
let base_uri = BaseUri::from_parts(origin, BasePath::default());
assert_eq!(base_uri.scheme(), &Scheme::HTTPS);
assert_eq!(base_uri.authority().as_str(), "example.com");
}
#[test]
fn with_custom_port() {
let origin = Origin::from_parts(Scheme::HTTP, Authority::from_static("example.com:8080"));
let base_uri = BaseUri::from_parts(origin, BasePath::default());
assert_eq!(base_uri.scheme(), &Scheme::HTTP);
assert_eq!(base_uri.authority().as_str(), "example.com:8080");
}
#[test]
fn non_http_scheme_is_accepted() {
let origin = Origin::from_parts(Scheme::try_from("ftp").unwrap(), Authority::from_static("example.com:21"));
let base_uri = BaseUri::from_parts(origin, BasePath::default());
assert_eq!(base_uri.scheme().as_str(), "ftp");
assert_eq!(base_uri.port(), Some(21));
assert_eq!(base_uri.to_string(), "ftp://example.com:21/");
}
#[test]
fn with_path() {
let origin = Origin::from_parts(Scheme::HTTPS, Authority::from_static("example.com:443"));
let base_uri = BaseUri::from_parts(origin, BasePath::from_static("/example/"));
assert_eq!(base_uri.scheme(), &Scheme::HTTPS);
assert_eq!(base_uri.authority().as_str(), "example.com:443");
assert_eq!(base_uri.to_string(), "https://example.com/example/");
}
}
#[test]
fn try_from_raw_parts_builds_uri_with_try_into_args() {
let base_uri = BaseUri::try_from_raw_parts("https", "example.com", 1234, "/api/v1/").unwrap();
assert_eq!(base_uri.to_string(), "https://example.com:1234/api/v1/");
let base_uri = BaseUri::try_from_raw_parts("ftp", "example.com", 21, BasePath::default()).unwrap();
assert_eq!(base_uri.scheme().as_str(), "ftp");
assert_eq!(base_uri.port(), Some(21));
BaseUri::try_from_raw_parts("https", "not a host", 1234, BasePath::default()).unwrap_err();
}
mod from_uri_static {
use super::*;
#[test]
fn valid_uri() {
let base_uri = BaseUri::from_static("https://example.com");
assert_eq!(base_uri.scheme(), &Scheme::HTTPS);
assert_eq!(base_uri.authority().as_str(), "example.com");
}
#[test]
fn with_path() {
let base_uri = BaseUri::from_static("https://example.com/path/to/resource/");
assert_eq!(base_uri.scheme(), &Scheme::HTTPS);
assert_eq!(base_uri.authority().as_str(), "example.com");
assert_eq!(base_uri.to_string(), "https://example.com/path/to/resource/");
}
#[should_panic(expected = "static str is not a valid base URI")]
#[test]
fn invalid_uri() {
let _base_uri = BaseUri::from_static("not-a-valid-uri");
}
}
mod from_uri_str {
use ohno::ErrorExt;
use super::*;
#[test]
fn valid_uri() {
let base_uri = BaseUri::from_str("https://example.com/").unwrap();
assert_eq!(base_uri.scheme(), &Scheme::HTTPS);
assert_eq!(base_uri.authority().as_str(), "example.com");
}
#[test]
fn with_path() {
let base_uri = BaseUri::from_str("https://example.com/path/").unwrap();
assert_eq!(base_uri.scheme(), &Scheme::HTTPS);
assert_eq!(base_uri.authority().as_str(), "example.com");
}
#[test]
fn strips_query_from_path() {
let base_uri = BaseUri::from_str("https://example.com/path/?query=1&other=2").unwrap();
assert_eq!(base_uri.to_string(), "https://example.com/path/");
}
#[test]
fn invalid_uri() {
let err = BaseUri::from_str("not-a-valid-uri").unwrap_err();
assert_eq!(err.message(), "URI must have both scheme and authority components");
}
}
mod from_uri {
use super::*;
#[test]
fn valid_uri() {
let uri = http::Uri::from_static("https://example.com");
let base_uri = BaseUri::try_from(&uri).unwrap();
assert_eq!(base_uri.scheme(), &Scheme::HTTPS);
assert_eq!(base_uri.authority().as_str(), "example.com");
}
#[test]
fn with_path() {
let uri = http::Uri::from_static("https://example.com/path/");
let base_uri = BaseUri::try_from(&uri).unwrap();
assert_eq!(base_uri.scheme(), &Scheme::HTTPS);
assert_eq!(base_uri.authority().as_str(), "example.com");
assert_eq!(base_uri.path().as_str(), "/path/");
}
#[test]
fn strips_query_from_path() {
let uri = http::Uri::from_static("https://example.com/path/?query=1");
let base_uri = BaseUri::try_from(&uri).unwrap();
assert_eq!(base_uri.to_string(), "https://example.com/path/");
}
#[test]
fn missing_components() {
let uri = http::Uri::from_static("/just-a-path");
let err = BaseUri::try_from(&uri).unwrap_err();
assert!(err.to_string().contains("URI must have both scheme and authority"));
}
}
mod with_path {
use super::*;
#[test]
fn replaces_path_infallibly() {
let base_uri = BaseUri::from_static("https://example.com/old/").with_path(BasePath::from_static("/api/v1/"));
assert_eq!(base_uri.to_string(), "https://example.com/api/v1/");
}
#[test]
fn try_with_path_from_str() {
let base_uri = BaseUri::from_static("https://example.com/").try_with_path("/api/v1/").unwrap();
assert_eq!(base_uri.to_string(), "https://example.com/api/v1/");
}
#[test]
fn try_with_path_invalid_returns_error() {
BaseUri::from_static("https://example.com/")
.try_with_path("no-leading-slash/")
.unwrap_err();
}
}
#[test]
fn into_parts_round_trips_with_from_parts() {
let base_uri = BaseUri::from_static("https://example.com:1234/api/");
let (origin, path) = base_uri.clone().into_parts();
assert_eq!(origin, Origin::from_static("https://example.com:1234"));
assert_eq!(path, BasePath::from_static("/api/"));
assert_eq!(BaseUri::from_parts(origin, path), base_uri);
}
#[test]
fn try_from_str_delegates_to_from_str() {
let base_uri = BaseUri::try_from("https://example.com/api/").unwrap();
assert_eq!(base_uri.to_string(), "https://example.com/api/");
BaseUri::try_from("not-a-valid-uri").unwrap_err();
}
mod accessors {
use super::*;
#[test]
fn scheme() {
let base_uri = BaseUri::from_static("https://example.com");
assert_eq!(base_uri.scheme().as_str(), "https");
}
#[test]
fn authority() {
let base_uri = BaseUri::from_static("https://example.com:8443");
assert_eq!(base_uri.authority().as_str(), "example.com:8443");
}
#[test]
fn host() {
let base_uri = BaseUri::from_static("https://example.com:8443");
assert_eq!(base_uri.host(), "example.com");
}
#[test]
fn port_explicit() {
let base_uri = BaseUri::from_static("https://example.com:8443");
assert_eq!(base_uri.port(), Some(8443));
}
}
mod is_https {
use super::*;
#[test]
fn secure() {
let base_uri = BaseUri::from_static("https://example.com");
assert!(base_uri.is_https());
}
#[test]
fn insecure() {
let base_uri = BaseUri::from_static("http://example.com");
assert!(!base_uri.is_https());
}
}
mod build_uri {
use super::*;
#[test]
fn with_path_string() {
let base_uri = BaseUri::from_static("https://example.com");
let uri = base_uri.build_http_uri("/api/resource").unwrap();
assert_eq!(uri.to_string(), "https://example.com/api/resource");
}
#[test]
fn with_empty_uri() {
let base_uri = BaseUri::from_static("https://example.com");
let uri = base_uri.build_http_uri("/").unwrap();
assert_eq!(uri.to_string(), "https://example.com/");
}
#[test]
fn with_path_query_string() {
let base_uri = BaseUri::from_static("https://example.com");
let uri = base_uri.build_http_uri("/api/resource?param=value").unwrap();
assert_eq!(uri.to_string(), "https://example.com/api/resource?param=value");
}
#[test]
fn with_path_object() {
let base_uri = BaseUri::from_static("https://example.com");
let path = PathAndQuery::from_static("/api/resource?param=value");
let uri = base_uri.build_http_uri(path).unwrap();
assert_eq!(uri.to_string(), "https://example.com/api/resource?param=value");
}
#[test]
fn invalid_path() {
let base_uri = BaseUri::from_static("https://example.com");
let invalid_path = "some path/?invalid\\character";
let err = base_uri.build_http_uri(invalid_path).unwrap_err();
assert!(err.to_string().contains("invalid uri character"));
}
}
mod conversions {
use super::*;
#[test]
fn uri_to_base_uri() {
let uri = http::Uri::from_static("https://example.com/path/");
let base_uri: BaseUri = uri.try_into().unwrap();
assert_eq!(base_uri.scheme(), &Scheme::HTTPS);
assert_eq!(base_uri.authority().as_str(), "example.com");
}
#[test]
fn base_uri_to_uri() {
let base_uri = BaseUri::from_static("https://example.com");
let uri: http::Uri = base_uri.into();
assert_eq!(uri.to_string(), "https://example.com/");
}
#[test]
fn origin_to_base_uri() {
let origin = Origin::from_static("https://example.com:8443");
let base_uri: BaseUri = origin.into();
assert_eq!(base_uri.to_string(), "https://example.com:8443/");
}
#[test]
fn from_str_valid() {
let base_uri: BaseUri = "https://example.com:8443/api/".parse().unwrap();
assert_eq!(base_uri.to_string(), "https://example.com:8443/api/");
}
#[test]
fn from_str_invalid() {
let err = "not-a-valid-uri".parse::<BaseUri>().unwrap_err();
assert!(err.to_string().contains("URI must have both scheme and authority"));
}
}
mod display {
use super::*;
#[test]
fn http_default_port() {
let base_uri = BaseUri::from_static("http://example.com:80");
assert_eq!(base_uri.to_string(), "http://example.com/");
}
#[test]
fn https_default_port() {
let base_uri = BaseUri::from_static("https://example.com:443");
assert_eq!(base_uri.to_string(), "https://example.com/");
}
#[test]
fn custom_port() {
let base_uri = BaseUri::from_static("https://example.com:8443");
assert_eq!(base_uri.to_string(), "https://example.com:8443/");
}
#[test]
fn https_with_http_default_port_keeps_port() {
let base_uri = BaseUri::from_static("https://example.com:80");
assert_eq!(base_uri.to_string(), "https://example.com:80/");
}
#[test]
fn http_with_https_default_port_keeps_port() {
let base_uri = BaseUri::from_static("http://example.com:443");
assert_eq!(base_uri.to_string(), "http://example.com:443/");
}
}
mod with_origin {
use super::*;
#[test]
fn replaces_origin() {
let base_uri = BaseUri::from_static("https://example.com/api/");
let new_origin = Origin::from_static("https://new-example.com:8080");
let new_base_uri = base_uri.with_origin(new_origin.clone());
assert_eq!(new_base_uri.origin(), &new_origin);
assert_eq!(new_base_uri.scheme(), &Scheme::HTTPS);
assert_eq!(new_base_uri.authority().as_str(), "new-example.com:8080");
assert_eq!(new_base_uri.port(), Some(8080));
assert_eq!(new_base_uri.path().as_str(), "/api/");
assert_eq!(new_base_uri.to_string(), "https://new-example.com:8080/api/");
}
}
mod with_port {
use super::*;
#[test]
fn changes_port() {
let base_uri = BaseUri::from_static("https://example.com/api/");
let new_base_uri = base_uri.with_port(8443);
assert_eq!(new_base_uri.origin().port(), Some(8443));
assert_eq!(new_base_uri.port(), Some(8443));
assert_eq!(new_base_uri.to_string(), "https://example.com:8443/api/");
}
}
#[cfg(feature = "serde")]
mod serde_tests {
use super::*;
#[test]
fn base_uri_roundtrip() {
let original = BaseUri::from_static("https://example.com:8443/api/");
let json = serde_json::to_string(&original).unwrap();
assert_eq!(json, r#""https://example.com:8443/api/""#);
let deserialized: BaseUri = serde_json::from_str(&json).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn base_uri_deserialize_rejects_invalid() {
serde_json::from_str::<BaseUri>(r#""not-a-uri""#).unwrap_err();
}
}
}