use std::fmt;
use std::fmt::{Debug, Formatter};
use std::str::FromStr;
use data_privacy::{DataClass, RedactedDebug, RedactedDisplay, RedactedToString, RedactionEngine, Sensitive};
use http::uri::{Parts, PathAndQuery as HttpPathAndQuery};
use crate::error::UriError;
use crate::{BasePath, BaseUri, Origin, PathAndQuery};
#[derive(Clone)]
pub struct Uri {
pub(crate) base_uri: Option<BaseUri>,
pub(crate) path_and_query: Option<PathAndQuery>,
}
impl Default for Uri {
fn default() -> Self {
Self::new()
}
}
impl Uri {
pub const DATA_CLASS: DataClass = DataClass::new(env!("CARGO_PKG_NAME"), "unknown_uri");
#[must_use]
pub fn new() -> Self {
Self {
base_uri: None,
path_and_query: None,
}
}
#[must_use]
pub fn from_static(uri: &'static str) -> Self {
Self::try_from(http::Uri::from_static(uri)).expect("static str is not a valid URI")
}
#[must_use]
pub fn from_parts(base: impl Into<Option<BaseUri>>, path_and_query: impl Into<Option<PathAndQuery>>) -> Self {
Self {
base_uri: base.into(),
path_and_query: path_and_query.into(),
}
}
#[must_use]
pub fn into_parts(self) -> (Option<BaseUri>, Option<PathAndQuery>) {
(self.base_uri, self.path_and_query)
}
#[must_use]
pub fn with_path_and_query(self, path_and_query: impl Into<PathAndQuery>) -> Self {
Self {
path_and_query: Some(path_and_query.into()),
..self
}
}
#[must_use]
pub fn with_base(self, base: impl Into<BaseUri>) -> Self {
Self {
base_uri: Some(base.into()),
..self
}
}
#[must_use]
pub fn to_path_and_query(&self) -> Option<PathAndQuery> {
self.path_and_query.clone()
}
pub fn to_string(&self) -> Sensitive<String> {
let mut path = self.base_uri.as_ref().map(ToString::to_string).unwrap_or_default();
match self.path_and_query.as_ref().map(PathAndQuery::to_string) {
Some(pq) if self.base_uri.is_some() => path.push_str(pq.declassify_ref().trim_start_matches('/')),
Some(pq) => path.push_str(pq.declassify_ref()),
None => {}
}
Sensitive::new(path, Self::DATA_CLASS)
}
}
impl RedactedDisplay for Uri {
#[cfg_attr(test, mutants::skip)] fn fmt(&self, engine: &RedactionEngine, f: &mut Formatter) -> fmt::Result {
if let Some(base_uri) = self.base_uri.as_ref() {
write!(f, "{base_uri}")?;
}
match self.path_and_query.as_ref().map(|p| p.to_redacted_string(engine)) {
Some(pq) if self.base_uri.is_some() => f.write_str(pq.trim_start_matches('/'))?,
Some(pq) => f.write_str(&pq)?,
None => {}
}
Ok(())
}
}
impl RedactedDebug for Uri {
#[cfg_attr(test, mutants::skip)] fn fmt(&self, engine: &RedactionEngine, f: &mut Formatter) -> fmt::Result {
if let Some(base_uri) = self.base_uri.as_ref() {
write!(f, "{base_uri}")?;
}
match self.path_and_query.as_ref().map(|p| p.to_redacted_string(engine)) {
Some(pq) if self.base_uri.is_some() => f.write_str(pq.trim_start_matches('/'))?,
Some(pq) => f.write_str(&pq)?,
None => {}
}
Ok(())
}
}
impl TryFrom<http::Uri> for Uri {
type Error = UriError;
fn try_from(uri: http::Uri) -> Result<Self, Self::Error> {
let parts = uri.into_parts();
let path_and_query = parts.path_and_query.map(PathAndQuery::from);
let (Some(authority), Some(scheme)) = (parts.authority, parts.scheme) else {
return Ok(Self {
base_uri: None,
path_and_query,
});
};
let base_uri = BaseUri::from_parts(Origin::from_parts(scheme, authority), BasePath::default());
Ok(Self {
base_uri: Some(base_uri),
path_and_query,
})
}
}
impl Debug for Uri {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut dbg = f.debug_struct("Uri");
if let Some(base_uri) = &self.base_uri {
dbg.field("base_uri", base_uri);
}
dbg.field("path_and_query", &self.path_and_query).finish()
}
}
impl FromStr for Uri {
type Err = UriError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let uri: http::Uri = http::Uri::from_str(s)?;
uri.try_into()
}
}
impl TryFrom<&str> for Uri {
type Error = UriError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::from_str(value)
}
}
impl TryFrom<String> for Uri {
type Error = UriError;
fn try_from(s: String) -> Result<Self, Self::Error> {
let uri = http::Uri::try_from(s)?;
uri.try_into()
}
}
impl TryFrom<Uri> for http::Uri {
type Error = UriError;
fn try_from(value: Uri) -> Result<Self, Self::Error> {
let Uri { base_uri, path_and_query } = value;
let path_and_query = path_and_query.map(|pq| HttpPathAndQuery::try_from(&pq)).transpose()?;
match (base_uri, path_and_query) {
(Some(base_uri), None) => Ok(base_uri.into()),
(Some(base_uri), Some(path_and_query)) => base_uri.build_http_uri(path_and_query),
(None, pq) => {
let mut parts = Parts::default();
parts.path_and_query = pq;
Self::from_parts(parts).map_err(Into::into)
}
}
}
}
impl From<BaseUri> for Uri {
fn from(value: BaseUri) -> Self {
Self {
base_uri: Some(value),
path_and_query: None,
}
}
}
impl From<http::uri::PathAndQuery> for Uri {
fn from(value: http::uri::PathAndQuery) -> Self {
Self {
base_uri: None,
path_and_query: Some(PathAndQuery::from(value)),
}
}
}
impl TryFrom<Uri> for HttpPathAndQuery {
type Error = UriError;
fn try_from(uri: Uri) -> Result<Self, Self::Error> {
let Uri { path_and_query, .. } = uri;
let path_and_query = path_and_query.ok_or_else(|| UriError::invalid_uri("URI does not have a path and query component"))?;
Self::try_from(&path_and_query)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_uri_from_base_uri() {
let base = BaseUri::from_static("https://example.com/api/");
let uri: Uri = base.into();
assert_eq!(uri.to_string().declassify_ref(), "https://example.com/api/");
assert!(uri.to_path_and_query().is_none());
}
#[test]
fn test_uri_from_http_path_and_query() {
let paq = http::uri::PathAndQuery::from_static("/path?query=1");
let uri: Uri = paq.into();
assert!(uri.base_uri.is_none());
assert_eq!(uri.to_string().declassify_ref(), "/path?query=1");
assert_eq!(
HttpPathAndQuery::try_from(uri).ok(),
Some(HttpPathAndQuery::from_static("/path?query=1"))
);
}
#[test]
fn from_static_parses_full_uri() {
let uri = Uri::from_static("https://example.com/path?query=1");
assert_eq!(uri.to_string().declassify_ref(), "https://example.com/path?query=1");
}
#[test]
fn from_parts_and_into_parts_round_trip() {
let base = BaseUri::from_static("http://example.com");
let path = PathAndQuery::from(HttpPathAndQuery::from_static("/path?query=1"));
let uri = Uri::from_parts(base.clone(), path);
let (got_base, got_path) = uri.clone().into_parts();
assert_eq!(got_base, Some(base));
assert!(got_path.is_some());
assert_eq!(uri.to_string().declassify_ref(), "http://example.com/path?query=1");
let empty = Uri::from_parts(None, None);
let (b, p) = empty.into_parts();
assert!(b.is_none());
assert!(p.is_none());
}
#[test]
fn test_uri_try_from_str() {
let uri_str = "https://example.com/path?query=1";
let uri = Uri::try_from(uri_str).unwrap();
assert_eq!(uri.to_string().declassify_ref(), uri_str);
}
#[test]
fn test_uri_try_from_string() {
let uri_str = String::from("https://example.com/path?query=1");
let uri: Uri = Uri::try_from(uri_str.clone()).unwrap();
assert_eq!(uri.to_string().declassify_into(), uri_str);
}
#[test]
fn test_uri_from_http_uri() {
let uri_str = "https://example.com/path?query=1";
let http_uri = http::Uri::from_static(uri_str);
let uri: Uri = http_uri.clone().try_into().expect("Failed to convert http::Uri to Uri");
assert_eq!(uri.to_string().declassify_ref(), uri_str);
let target_hyper_uri: http::Uri = uri.try_into().expect("Failed to convert Uri to http::Uri");
assert_eq!(target_hyper_uri, http_uri);
}
#[test]
fn test_uri_into_http_uri() {
let base_uri = BaseUri::from_static("https://example.com/");
let path_with_slash = HttpPathAndQuery::from_static("/path?query=1");
let path_without_slash = HttpPathAndQuery::from_static("path?query=1");
let uri: Uri = Uri::default().with_base(base_uri).with_path_and_query(path_with_slash.clone());
let http_uri: http::Uri = uri.try_into().expect("Failed to convert Uri to http::Uri");
assert_eq!(http_uri.to_string(), "https://example.com/path?query=1");
let base_uri = BaseUri::from_static("https://example.com/foo/");
let uri: Uri = Uri::default().with_base(base_uri.clone()).with_path_and_query(path_with_slash);
let http_uri: http::Uri = uri.try_into().expect("Failed to convert Uri to http::Uri");
assert_eq!(
http_uri.to_string(),
"https://example.com/foo/path?query=1",
"prefix works correctly with trailing slash"
);
let uri: Uri = Uri::default().with_base(base_uri).with_path_and_query(path_without_slash);
let http_uri: http::Uri = uri.try_into().expect("Failed to convert Uri to http::Uri");
assert_eq!(
http_uri.to_string(),
"https://example.com/foo/path?query=1",
"prefix works correctly without trailing slash"
);
}
#[test]
fn test_authority_only_uri_from_str() {
let uri_str = "https://example.com/";
let uri: Uri = uri_str.parse().unwrap();
assert_eq!(
HttpPathAndQuery::try_from(uri.clone()).ok(),
Some(HttpPathAndQuery::from_static("/"))
);
assert_eq!(&uri.to_string().declassify_ref(), &uri_str);
}
#[test]
fn test_path_only_uri() {
let uri_str = "/path/to/resource";
let uri: Uri = uri_str.parse().unwrap();
assert!(uri.base_uri.is_none());
assert_eq!(uri.to_string().declassify_ref(), uri_str);
}
#[test]
fn uri_compare() {
let uri1 = Uri::from_str("https://example.com/path?query=1").unwrap();
let uri2 = Uri::from_str("https://example.com/path?query=1").unwrap();
let uri3 = Uri::from_str("https://example.com/otherpath?query=2").unwrap();
let uri4 = Uri::from_str("https://www.example.com/otherpath?query=2").unwrap();
assert_eq!(uri1.to_string(), uri2.to_string());
assert_ne!(uri1.to_string(), uri3.to_string());
assert_ne!(uri4.to_string(), uri3.to_string());
}
#[test]
fn test_display_uri() {
let uri = Uri::from_str("https://example.com/path?query=1").unwrap();
assert_eq!(uri.to_string().declassify_ref(), "https://example.com/path?query=1");
}
#[test]
fn test_debug_uri() {
let uri = Uri::from_str("https://example.com/path?query=1").unwrap();
assert_eq!(
format!("{uri:?}"),
r#"Uri { base_uri: BaseUri { origin: Origin { scheme: "https", authority: example.com }, path: BasePath { inner: / } }, path_and_query: Some(PathAndQuery) }"#
);
}
#[test]
fn redact_path_uri() {
let insensitive_paq = |paq: &'static str| PathAndQuery::from_static(paq);
let redaction_engine = RedactionEngine::builder().build();
let paq_with_trailing_slash = insensitive_paq("/sensitive/path?query=secret");
let paq_without_trailing_slash = insensitive_paq("sensitive/path?query=secret");
let base_uri = BaseUri::from_static("https://example.com/api/v1/");
let redacted_uri = Uri::default()
.with_base(base_uri.clone())
.with_path_and_query(paq_without_trailing_slash.clone())
.to_redacted_string(&redaction_engine);
assert_eq!(
redacted_uri, "https://example.com/api/v1/",
"redaction should erase the entire path and query"
);
let redacted_uri = Uri::default()
.with_base(base_uri)
.with_path_and_query(paq_with_trailing_slash.clone())
.to_redacted_string(&redaction_engine);
assert_eq!(
redacted_uri, "https://example.com/api/v1/",
"redaction should erase the entire path and query and avoid double slashes"
);
let redacted_uri = Uri::default()
.with_path_and_query(paq_without_trailing_slash)
.to_redacted_string(&redaction_engine);
assert_eq!(redacted_uri, "");
let redacted_uri = Uri::default()
.with_path_and_query(paq_with_trailing_slash)
.to_redacted_string(&redaction_engine);
assert_eq!(redacted_uri, "");
}
#[test]
fn test_redacted_debug_uri() {
let insensitive_paq = |paq: &'static str| PathAndQuery::from_static(paq);
let redaction_engine = RedactionEngine::builder().build();
let base_uri = BaseUri::from_static("https://example.com/api/v1/");
let paq = insensitive_paq("/sensitive/path?query=secret");
let uri = Uri::default().with_base(base_uri.clone()).with_path_and_query(paq);
let mut redacted_debug = String::new();
redaction_engine.redacted_debug(&uri, &mut redacted_debug).unwrap();
assert_eq!(
redacted_debug, "https://example.com/api/v1/",
"RedactedDebug should erase the path and query"
);
let paq_only = insensitive_paq("/sensitive/path");
let uri_no_base = Uri::default().with_path_and_query(paq_only);
let mut redacted_debug = String::new();
redaction_engine.redacted_debug(&uri_no_base, &mut redacted_debug).unwrap();
assert_eq!(redacted_debug, "", "RedactedDebug should erase path-only URI");
let uri_base_only = Uri::default().with_base(base_uri);
let mut redacted_debug = String::new();
redaction_engine.redacted_debug(&uri_base_only, &mut redacted_debug).unwrap();
assert_eq!(
redacted_debug, "https://example.com/api/v1/",
"RedactedDebug should show base URI when no path and query is present"
);
let empty_uri = Uri::default();
let mut redacted_debug = String::new();
redaction_engine.redacted_debug(&empty_uri, &mut redacted_debug).unwrap();
assert_eq!(redacted_debug, "", "RedactedDebug should return empty string for empty URI");
let paq_no_slash = insensitive_paq("sensitive/path");
let uri_no_slash = Uri::default()
.with_base(BaseUri::from_static("https://example.com/api/"))
.with_path_and_query(paq_no_slash);
let mut redacted_debug = String::new();
redaction_engine.redacted_debug(&uri_no_slash, &mut redacted_debug).unwrap();
assert_eq!(
redacted_debug, "https://example.com/api/",
"RedactedDebug should handle paths without leading slash and avoid double slashes"
);
}
#[test]
fn try_into_http_uri() {
let uri = Uri::from_str("https://example.com/path?query=1").unwrap();
let http_uri = http::Uri::try_from(uri).unwrap();
assert_eq!(http_uri.to_string(), "https://example.com/path?query=1");
}
#[test]
fn test_try_from_uri_to_http_uri_base_only() {
let base_uri = BaseUri::from_static("https://example.com/api/");
let uri = Uri::default().with_base(base_uri);
let http_uri: http::Uri = uri.try_into().unwrap();
assert_eq!(http_uri.to_string(), "https://example.com/api/");
}
#[test]
fn test_try_from_uri_to_http_uri_path_only() {
let path = HttpPathAndQuery::from_static("/path?query=value");
let uri = Uri::default().with_path_and_query(path);
let http_uri: http::Uri = uri.try_into().unwrap();
assert_eq!(http_uri.to_string(), "/path?query=value");
}
#[test]
fn test_try_from_uri_to_path_success_paq() {
let path = HttpPathAndQuery::from_static("/success/path");
let uri = Uri::default().with_path_and_query(path);
let paq: HttpPathAndQuery = uri.try_into().unwrap();
assert_eq!(paq.to_string(), "/success/path");
}
#[test]
fn test_try_from_uri_to_path_error_paq() {
let uri = Uri::default().with_base(BaseUri::from_static("https://example.com/"));
let result: Result<HttpPathAndQuery, UriError> = uri.try_into();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("does not have a path and query component"));
}
#[test]
fn test_uri_with_base_uri_only_to_string() {
let base_uri = BaseUri::from_static("https://example.com/api/");
let uri = Uri::default().with_base(base_uri);
let uri_string = uri.to_string();
assert_eq!(uri_string.declassify_ref(), "https://example.com/api/");
}
#[test]
fn test_uri_with_base_uri_only_redacted_display() {
let base_uri = BaseUri::from_static("https://example.com/api/v1/");
let uri = Uri::default().with_base(base_uri);
let redaction_engine = RedactionEngine::builder().build();
let redacted = uri.to_redacted_string(&redaction_engine);
assert_eq!(redacted, "https://example.com/api/v1/");
}
}