use std::fmt::Display;
use http::uri::{Authority, Scheme};
use crate::UriError;
pub(crate) const HTTP_DEFAULT_PORT: u16 = 80;
pub(crate) const HTTPS_DEFAULT_PORT: u16 = 443;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Origin {
scheme: Scheme,
authority: Authority,
}
impl Origin {
#[must_use]
#[expect(clippy::expect_used, reason = "from_static is documented to panic on invalid input")]
pub fn from_static(s: &'static str) -> Self {
s.parse().expect("invalid origin passed to Origin::from_static")
}
#[must_use]
pub fn from_parts(scheme: Scheme, authority: Authority) -> Self {
Self { scheme, authority }
}
pub fn try_from_parts(
scheme: impl TryInto<Scheme, Error: Into<http::Error>>,
authority: impl TryInto<Authority, Error: Into<http::Error>>,
) -> Result<Self, UriError> {
let scheme = scheme.try_into().map_err(|e| UriError::from(e.into()))?;
let authority = authority.try_into().map_err(|e| UriError::from(e.into()))?;
Ok(Self::from_parts(scheme, authority))
}
#[must_use]
pub const fn scheme(&self) -> &Scheme {
&self.scheme
}
#[must_use]
pub const fn authority(&self) -> &Authority {
&self.authority
}
#[must_use]
pub fn into_parts(self) -> (Scheme, Authority) {
(self.scheme, self.authority)
}
#[must_use]
pub fn port(&self) -> Option<u16> {
self.authority.port_u16()
}
#[must_use]
pub fn effective_port(&self) -> Option<u16> {
if let Some(port) = self.authority.port_u16() {
return Some(port);
}
match self.scheme.as_str() {
s if s == Scheme::HTTP.as_str() => Some(HTTP_DEFAULT_PORT),
s if s == Scheme::HTTPS.as_str() => Some(HTTPS_DEFAULT_PORT),
_ => None,
}
}
#[must_use]
#[expect(
clippy::expect_used,
reason = "host comes from a valid Authority and u16 always formats as a valid port, so the resulting authority is always parseable"
)]
#[expect(clippy::missing_panics_doc, reason = "the documented expect is unreachable")]
pub fn with_port(self, port: u16) -> Self {
let host = self.authority.host();
let authority = format!("{host}:{port}")
.parse::<Authority>()
.expect("host originated from a valid Authority and u16 is always a valid port");
Self::from_parts(self.scheme, authority)
}
pub fn is_https(&self) -> bool {
self.scheme == Scheme::HTTPS
}
}
impl std::str::FromStr for Origin {
type Err = UriError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let uri: http::Uri = s.parse().map_err(UriError::from)?;
let scheme = uri.scheme().ok_or_else(|| UriError::invalid_uri("missing scheme"))?.clone();
let authority = uri.authority().ok_or_else(|| UriError::invalid_uri("missing authority"))?.clone();
Ok(Self::from_parts(scheme, authority))
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for Origin {
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 Origin {
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)
}
}
impl Display for Origin {
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.authority.host()),
(s, Some(HTTPS_DEFAULT_PORT)) if s == Scheme::HTTPS.as_str() => write!(f, "{}", self.authority.host()),
_ => write!(f, "{}", self.authority),
}
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
#[test]
fn test_port() {
let origin_implicit_http = Origin::from_parts(Scheme::HTTP, Authority::from_static("example.com"));
assert_eq!(origin_implicit_http.port(), None);
let origin_implicit_https = Origin::from_parts(Scheme::HTTPS, Authority::from_static("example.com"));
assert_eq!(origin_implicit_https.port(), None);
let origin_explicit = Origin::from_parts(Scheme::HTTP, Authority::from_static("example.com:8080"));
assert_eq!(origin_explicit.port(), Some(8080));
let origin_explicit = Origin::from_parts(Scheme::HTTPS, Authority::from_static("example.com:8443"));
assert_eq!(origin_explicit.port(), Some(8443));
}
#[test]
fn test_port_other_scheme() {
let origin_no_port = Origin::from_parts(Scheme::from_str("ftp").unwrap(), Authority::from_static("example.com"));
assert_eq!(origin_no_port.port(), None);
let origin_with_port = Origin::from_parts(Scheme::from_str("ftp").unwrap(), Authority::from_static("example.com:21"));
assert_eq!(origin_with_port.port(), Some(21));
}
#[test]
fn test_effective_port() {
let origin = Origin::from_parts(Scheme::HTTP, Authority::from_static("example.com:8080"));
assert_eq!(origin.effective_port(), Some(8080));
let origin = Origin::from_parts(Scheme::HTTPS, Authority::from_static("example.com:8443"));
assert_eq!(origin.effective_port(), Some(8443));
let origin = Origin::from_parts(Scheme::HTTP, Authority::from_static("example.com"));
assert_eq!(origin.effective_port(), Some(HTTP_DEFAULT_PORT));
let origin = Origin::from_parts(Scheme::HTTPS, Authority::from_static("example.com"));
assert_eq!(origin.effective_port(), Some(HTTPS_DEFAULT_PORT));
let origin = Origin::from_parts(Scheme::from_str("ftp").unwrap(), Authority::from_static("example.com"));
assert_eq!(origin.effective_port(), None);
let origin = Origin::from_parts(Scheme::from_str("ftp").unwrap(), Authority::from_static("example.com:21"));
assert_eq!(origin.effective_port(), Some(21));
}
#[test]
fn test_origin_display() {
let origin = Origin::from_parts(Scheme::HTTP, Authority::from_static("example.com"));
assert_eq!(format!("{origin}"), "http://example.com");
let origin = Origin::from_parts(Scheme::HTTP, Authority::from_static("example.com:80"));
assert_eq!(format!("{origin}"), "http://example.com");
let origin = Origin::from_parts(Scheme::HTTPS, Authority::from_static("example.com:443"));
assert_eq!(format!("{origin}"), "https://example.com");
let origin = Origin::from_parts(Scheme::HTTPS, Authority::from_static("example.com:8443"));
assert_eq!(format!("{origin}"), "https://example.com:8443");
let origin = Origin::from_parts(Scheme::HTTPS, Authority::from_static("[::1]:8443"));
assert_eq!(format!("{origin}"), "https://[::1]:8443");
let origin = Origin::from_parts(Scheme::HTTPS, Authority::from_static("[2001:db8::1]:443"));
assert_eq!(format!("{origin}"), "https://[2001:db8::1]");
let origin = Origin::from_parts(Scheme::HTTP, Authority::from_static("[::1]:80"));
assert_eq!(format!("{origin}"), "http://[::1]");
let origin = Origin::from_parts(Scheme::HTTPS, Authority::from_static("[::1]"));
assert_eq!(format!("{origin}"), "https://[::1]");
let origin = Origin::from_parts(Scheme::from_str("ftp").unwrap(), Authority::from_static("example.com:21"));
assert_eq!(format!("{origin}"), "ftp://example.com:21");
}
#[test]
fn test_scheme_accessor() {
let origin_http = Origin::from_parts(Scheme::HTTP, Authority::from_static("example.com"));
assert_eq!(origin_http.scheme().as_str(), "http");
let origin_https = Origin::from_parts(Scheme::HTTPS, Authority::from_static("example.com:8443"));
assert_eq!(origin_https.scheme().as_str(), "https");
}
#[test]
fn test_authority_accessor() {
let origin = Origin::from_parts(Scheme::HTTPS, Authority::from_static("example.com:8443"));
assert_eq!(origin.authority().as_str(), "example.com:8443");
let origin_no_port = Origin::from_parts(Scheme::HTTP, Authority::from_static("example.com"));
assert_eq!(origin_no_port.authority().as_str(), "example.com");
let origin_ipv6 = Origin::from_parts(Scheme::HTTPS, Authority::from_static("[::1]:8080"));
assert_eq!(origin_ipv6.authority().as_str(), "[::1]:8080");
}
#[test]
fn test_into_parts() {
let origin = Origin::from_parts(Scheme::HTTPS, Authority::from_static("example.com:8443"));
let (scheme, authority) = origin.into_parts();
assert_eq!(scheme.as_str(), "https");
assert_eq!(authority.as_str(), "example.com:8443");
}
#[test]
fn test_with_port() {
let origin = Origin::from_parts(Scheme::HTTPS, Authority::from_static("example.com"));
let with_port = origin.with_port(8443);
assert_eq!(with_port.port(), Some(8443));
assert_eq!(format!("{with_port}"), "https://example.com:8443");
}
#[test]
fn test_with_port_ipv6() {
let origin = Origin::from_parts(Scheme::HTTPS, Authority::from_static("[2001:db8::1]:8080"));
let with_port = origin.with_port(9090);
assert_eq!(with_port.authority().as_str(), "[2001:db8::1]:9090");
assert_eq!(with_port.port(), Some(9090));
assert_eq!(format!("{with_port}"), "https://[2001:db8::1]:9090");
let origin = Origin::from_parts(Scheme::HTTPS, Authority::from_static("[::1]"));
let with_port = origin.with_port(8443);
assert_eq!(with_port.authority().as_str(), "[::1]:8443");
assert_eq!(format!("{with_port}"), "https://[::1]:8443");
}
#[test]
fn try_from_parts_valid() {
let origin = Origin::try_from_parts("https", "example.com:8443").unwrap();
assert_eq!(origin.scheme().as_str(), "https");
assert_eq!(origin.authority().as_str(), "example.com:8443");
let origin = Origin::try_from_parts(Scheme::HTTP, Authority::from_static("example.com")).unwrap();
assert_eq!(origin.scheme(), &Scheme::HTTP);
assert_eq!(origin.authority().as_str(), "example.com");
}
#[test]
fn try_from_parts_invalid_scheme() {
Origin::try_from_parts("not a scheme", "example.com").unwrap_err();
}
#[test]
fn try_from_parts_invalid_authority() {
Origin::try_from_parts("https", "not a host").unwrap_err();
}
#[test]
fn from_str_valid() {
let origin: Origin = "https://example.com:8443".parse().unwrap();
assert_eq!(origin.scheme().as_str(), "https");
assert_eq!(origin.authority().as_str(), "example.com:8443");
}
#[test]
fn from_str_missing_scheme() {
"example.com".parse::<Origin>().unwrap_err();
}
#[test]
fn from_str_non_http_scheme() {
let origin: Origin = "ftp://example.com".parse().unwrap();
assert_eq!(origin.scheme().as_str(), "ftp");
assert_eq!(origin.authority().as_str(), "example.com");
assert_eq!(origin.port(), None);
}
#[test]
fn from_static_valid() {
let origin = Origin::from_static("https://example.com:8443");
assert_eq!(origin.scheme().as_str(), "https");
assert_eq!(origin.authority().as_str(), "example.com:8443");
}
#[test]
fn from_static_non_http_scheme() {
let origin = Origin::from_static("ftp://example.com:21");
assert_eq!(origin.scheme().as_str(), "ftp");
assert_eq!(origin.port(), Some(21));
}
#[test]
fn display_non_http_scheme_with_http_default_port_keeps_port() {
let origin = Origin::from_parts(Scheme::from_str("ftp").unwrap(), Authority::from_static("example.com:80"));
assert_eq!(format!("{origin}"), "ftp://example.com:80");
let origin = Origin::from_parts(Scheme::HTTPS, Authority::from_static("example.com:80"));
assert_eq!(format!("{origin}"), "https://example.com:80");
}
#[test]
fn display_non_http_scheme_with_https_default_port_keeps_port() {
let origin = Origin::from_parts(Scheme::from_str("ftp").unwrap(), Authority::from_static("example.com:443"));
assert_eq!(format!("{origin}"), "ftp://example.com:443");
let origin = Origin::from_parts(Scheme::HTTP, Authority::from_static("example.com:443"));
assert_eq!(format!("{origin}"), "http://example.com:443");
}
#[test]
#[should_panic(expected = "invalid origin passed to Origin::from_static")]
fn from_static_invalid() {
let _ = Origin::from_static("example.com");
}
#[cfg(feature = "serde")]
mod serde_tests {
use super::*;
#[test]
fn origin_roundtrip() {
let original = Origin::from_parts(Scheme::HTTPS, Authority::from_static("example.com:8443"));
let json = serde_json::to_string(&original).unwrap();
assert_eq!(json, r#""https://example.com:8443""#);
let deserialized: Origin = serde_json::from_str(&json).unwrap();
assert_eq!(original, deserialized);
}
}
}