use std::str::FromStr;
use http::Uri;
use serde::{Deserialize, Serialize};
use crate::error::{Error, ErrorKind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointUrl(Uri);
impl Serialize for EndpointUrl {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(&self.0)
}
}
impl<'de> Deserialize<'de> for EndpointUrl {
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 EndpointUrl {
fn from_uri(uri: Uri) -> Result<Self, Error> {
let (Some(scheme), Some(authority)) = (uri.scheme(), uri.authority()) else {
return Err(Error::from(ErrorKind::Config).with_context(format!(
"endpoint URL must be absolute (have a scheme and authority): {uri}"
)));
};
if scheme != &http::uri::Scheme::HTTP && scheme != &http::uri::Scheme::HTTPS {
return Err(Error::from(ErrorKind::Config)
.with_context(format!("endpoint URL scheme must be http or https: {uri}")));
}
if authority.as_str().contains('@') {
return Err(Error::from(ErrorKind::Config).with_context(format!(
"endpoint URL must not contain userinfo (credentials) in its authority: {uri}"
)));
}
Ok(Self(uri))
}
#[must_use]
pub fn as_uri(&self) -> &Uri {
&self.0
}
#[must_use]
pub fn into_uri(self) -> Uri {
self.0
}
}
impl std::fmt::Display for EndpointUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl AsRef<Uri> for EndpointUrl {
fn as_ref(&self) -> &Uri {
&self.0
}
}
impl From<EndpointUrl> for Uri {
fn from(endpoint: EndpointUrl) -> Self {
endpoint.0
}
}
fn invalid_uri(source: http::uri::InvalidUri) -> Error {
Error::new(ErrorKind::Config, source).with_context("invalid endpoint URL")
}
impl TryFrom<Uri> for EndpointUrl {
type Error = Error;
fn try_from(uri: Uri) -> Result<Self, Error> {
Self::from_uri(uri)
}
}
#[cfg(feature = "url")]
impl TryFrom<url::Url> for EndpointUrl {
type Error = Error;
fn try_from(url: url::Url) -> Result<Self, Error> {
url.as_str().parse()
}
}
impl FromStr for EndpointUrl {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
if s.contains('#') {
return Err(Error::from(ErrorKind::Config).with_context(format!(
"endpoint URL must not contain a fragment component: {s}"
)));
}
s.parse::<Uri>()
.map_err(invalid_uri)
.and_then(Self::from_uri)
}
}
impl TryFrom<&str> for EndpointUrl {
type Error = Error;
fn try_from(s: &str) -> Result<Self, Error> {
s.parse()
}
}
impl TryFrom<String> for EndpointUrl {
type Error = Error;
fn try_from(s: String) -> Result<Self, Error> {
s.parse()
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[test]
fn accepts_absolute_https() {
let e = EndpointUrl::try_from("https://as.example.com/token").unwrap();
assert_eq!(e.as_uri().to_string(), "https://as.example.com/token");
}
#[test]
fn parse_via_fromstr() {
let e: EndpointUrl = "https://as.example.com/token".parse().unwrap();
assert_eq!(e.as_uri().to_string(), "https://as.example.com/token");
}
#[test]
fn accepts_absolute_http_for_loopback_and_test_servers() {
assert!(EndpointUrl::try_from("http://127.0.0.1:8080/token").is_ok());
}
#[rstest]
#[case::relative_reference("/token")]
#[case::scheme_without_authority("mailto:ops@example.com")]
#[case::authority_only_without_scheme("as.example.com/token")]
fn rejects_invalid_endpoint(#[case] input: &str) {
let err = EndpointUrl::try_from(input).unwrap_err();
assert!(matches!(err.kind(), ErrorKind::Config));
}
#[rstest]
#[case::ftp("ftp://as.example.com/token")]
#[case::websocket("wss://as.example.com/token")]
#[case::custom_scheme("custom-app://as.example.com/token")]
fn rejects_non_http_scheme(#[case] input: &str) {
let err = EndpointUrl::try_from(input).unwrap_err();
assert!(matches!(err.kind(), ErrorKind::Config));
assert!(err.to_string().contains("http or https"), "{err}");
}
#[rstest]
#[case::credentials("https://user:pass@as.example.com/token")]
#[case::bare_user("https://user@as.example.com/token")]
fn rejects_userinfo(#[case] input: &str) {
let err = EndpointUrl::try_from(input).unwrap_err();
assert!(matches!(err.kind(), ErrorKind::Config));
assert!(err.to_string().contains("userinfo"), "{err}");
}
#[test]
fn rejects_userinfo_via_uri_conversion() {
let uri: Uri = "https://user:pass@as.example.com/token".parse().unwrap();
assert!(EndpointUrl::try_from(uri).is_err());
}
#[rstest]
#[case::bare_fragment("https://as.example.com/token#frag")]
#[case::query_then_fragment("https://as.example.com/authorize?foo=bar#frag")]
#[case::empty_fragment("https://as.example.com/token#")]
fn rejects_fragment(#[case] input: &str) {
let err = EndpointUrl::try_from(input).unwrap_err();
assert!(matches!(err.kind(), ErrorKind::Config));
}
#[test]
fn accepts_and_preserves_query() {
let e = EndpointUrl::try_from("https://as.example.com/authorize?foo=bar&baz=1").unwrap();
assert_eq!(
e.as_uri().to_string(),
"https://as.example.com/authorize?foo=bar&baz=1"
);
}
#[test]
fn uri_conversion_is_now_checked() {
let relative: Uri = "/authorize".parse().unwrap();
assert!(EndpointUrl::try_from(relative).is_err());
}
#[test]
fn display_matches_inner_uri() {
let e: EndpointUrl = "https://as.example.com/authorize?foo=bar".parse().unwrap();
assert_eq!(e.to_string(), "https://as.example.com/authorize?foo=bar");
assert_eq!(format!("{e}"), e.as_uri().to_string());
}
#[test]
fn serde_round_trips_as_a_string() {
let e: EndpointUrl = "https://as.example.com/authorize?foo=bar".parse().unwrap();
let json = serde_json::to_string(&e).unwrap();
assert_eq!(json, r#""https://as.example.com/authorize?foo=bar""#);
let back: EndpointUrl = serde_json::from_str(&json).unwrap();
assert_eq!(back, e);
}
#[test]
fn converts_back_to_uri() {
let e: EndpointUrl = "https://as.example.com/token".parse().unwrap();
let by_ref: &Uri = e.as_ref();
assert_eq!(by_ref, e.as_uri());
let owned: Uri = e.into();
assert_eq!(owned.to_string(), "https://as.example.com/token");
}
}