use std::{borrow::Cow, fmt, io::Write as _};
use super::{Pattern, TryIntoPattern, TryIntoUriFmt, UriMatchError, fmt::UriFormatter};
use crate::uri::Uri;
use rama_core::{error::BoxError, telemetry::tracing};
use rama_utils::macros::generate_set_and_with;
use rama_utils::thirdparty::wildcard::Wildcard;
use serde::{Deserialize, Serialize, de::Error as _, ser::Error as _};
#[derive(Clone)]
pub struct UriMatchReplaceRule {
ptn: Wildcard<'static>,
fmt: UriFormatter,
ptn_include_query: bool,
include_query_overwrite: bool,
}
impl fmt::Debug for UriMatchReplaceRule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut d = f.debug_struct("UriMatchReplaceRule");
if let Ok(s) = core::str::from_utf8(self.ptn.pattern()) {
d.field("ptn", &s);
} else {
d.field("ptn", &"<[u8]>");
};
d.field("fmt", &self.fmt)
.field("ptn_include_query", &self.ptn_include_query)
.field("include_query_overwrite", &self.include_query_overwrite)
.finish()
}
}
impl Serialize for UriMatchReplaceRule {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
#[derive(Debug, Serialize)]
struct Presentation<'a> {
pattern: Cow<'a, str>,
template: Cow<'a, str>,
}
let presentation = Presentation {
pattern: Cow::Borrowed(
core::str::from_utf8(self.ptn.pattern()).map_err(S::Error::custom)?,
),
template: Cow::Borrowed(
core::str::from_utf8(self.fmt.template()).map_err(S::Error::custom)?,
),
};
presentation.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for UriMatchReplaceRule {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Debug, Deserialize)]
struct Presentation {
pattern: String,
template: String,
}
let Presentation { pattern, template } = Presentation::deserialize(deserializer)?;
Self::try_new(pattern, template).map_err(D::Error::custom)
}
}
impl UriMatchReplaceRule {
#[inline]
#[must_use]
pub fn http_to_https() -> Self {
#[expect(
clippy::expect_used,
reason = "this is a valid static pattern which is verified to not fail in doc+unit tests"
)]
Self::try_new("http://*", "https://$1").expect("to be always valid")
}
pub fn try_new(ptn: impl TryIntoPattern, fmt: impl TryIntoUriFmt) -> Result<Self, BoxError> {
let Pattern {
wildcard: ptn,
include_query: ptn_include_query,
} = ptn.try_into_wildcard()?;
let fmt = fmt.try_into_fmt()?;
let include_query_overwrite = !ptn_include_query && ptn.pattern().last() == Some(&b'*');
Ok(Self {
ptn,
fmt,
ptn_include_query,
include_query_overwrite,
})
}
generate_set_and_with! {
pub fn include_query_overwrite(mut self, include_query: bool) -> Self {
self.include_query_overwrite = include_query;
self
}
}
pub(super) fn include_query(&self) -> bool {
self.include_query_overwrite || self.ptn_include_query || self.fmt.include_query()
}
pub(super) fn try_match_replace_uri_slice(&self, b: &[u8]) -> Option<Uri> {
self.ptn
.captures(b)
.and_then(|captures| self.fmt.fmt_uri(captures.as_ref()).inspect_err(|err| {
tracing::debug!("unexpected error while formatting matched uri bytes: {err}; ignore as None (~ no match)");
}).ok())
}
}
impl super::UriMatchReplace for UriMatchReplaceRule {
#[inline]
fn match_replace_uri<'a>(&self, uri: Cow<'a, Uri>) -> Result<Cow<'a, Uri>, UriMatchError<'a>> {
let v = uri_to_small_vec(uri.as_ref(), self.include_query());
match self.try_match_replace_uri_slice(&v) {
Some(new_uri) => Ok(Cow::Owned(new_uri)),
None => Err(UriMatchError::NoMatch(uri)),
}
}
}
#[inline]
pub(super) fn uri_to_small_vec(uri: &Uri, include_query: bool) -> super::SmallUriStr {
let mut output = super::SmallUriStr::new();
uri_to_small_vec_with_buffer(uri, include_query, &mut output);
output
}
pub(super) fn uri_to_small_vec_with_buffer(
uri: &Uri,
include_query: bool,
output: &mut super::SmallUriStr,
) {
let query = if include_query {
uri.query_or_empty()
} else {
Cow::Borrowed("")
};
output.clear();
let path = uri.path_ref_or_root().trimmed_slashes().as_encoded_str();
if let Some(authority) = uri.authority() {
_ = write!(
output,
"{}://{authority}{}{path}{}{query}",
uri.scheme_str().unwrap_or("http"),
if path.as_ref().is_empty() { "" } else { "/" },
if query.is_empty() { "" } else { "?" },
);
} else {
_ = write!(
output,
"{}{path}{}{query}",
if path.is_empty() { "" } else { "/" },
if query.is_empty() { "" } else { "?" },
);
}
}
#[cfg(test)]
mod tests {
use core::str::FromStr;
use super::*;
use crate::http::uri::UriMatchReplace as _;
fn rule(ptn: &'static str, fmt: &'static str) -> UriMatchReplaceRule {
UriMatchReplaceRule::try_new(ptn, fmt)
.unwrap_or_else(|_| panic!("valid rule expected for ptn={ptn:?}, fmt={fmt:?}"))
}
fn uri(s: &str) -> Uri {
Uri::from_str(s).unwrap_or_else(|_| panic!("valid URI expected: {s:?}"))
}
fn match_replace_uri_to_test_option_str(
result: Result<Cow<'_, Uri>, UriMatchError<'_>>,
) -> Option<String> {
match result {
Ok(uri) => Some(uri.to_string()),
Err(UriMatchError::NoMatch(_)) => None,
Err(UriMatchError::Unexpected(err)) => {
panic!("unexpected match replace uri error: {err}")
}
}
}
fn apply(rule: &UriMatchReplaceRule, input: &str) -> Option<String> {
match_replace_uri_to_test_option_str(rule.match_replace_uri(Cow::Owned(uri(input)))).map(
|s| {
s.trim_end_matches('/') .to_owned()
.replace("/?", "?")
},
)
}
#[test]
fn uri_to_small_vec_uses_typed_path_trimming_and_preserves_encoding() {
let out = uri_to_small_vec(&uri("http://example.com//a%2Fb///?q=1"), true);
assert_eq!(
core::str::from_utf8(&out).unwrap(),
"http://example.com/a%2Fb?q=1"
);
let out = uri_to_small_vec(&uri("/a%2Fb///?q=1"), true);
assert_eq!(core::str::from_utf8(&out).unwrap(), "/a%2Fb?q=1");
let out = uri_to_small_vec(&uri("http://example.com///"), false);
assert_eq!(core::str::from_utf8(&out).unwrap(), "http://example.com");
}
#[test]
fn scheme_upgrade_single_wildcard() {
let r = rule("http://*", "https://$1");
let cases = [
("http://a.com", Some("https://a.com")),
("http://example.com", Some("https://example.com")),
(
"http://example.com/x?y=1",
Some("https://example.com/x?y=1"),
),
("https://example.com", None), ("ftp://example.com", None),
];
for (input, want) in cases {
let got = apply(&r, input);
let want = want.map(|s| s.to_owned());
assert_eq!(got, want, "input: {input}");
}
}
#[test]
fn multiple_wildcards_and_reordering() {
let r = rule("https://*/docs/*", "https://$1/knowledge/$2");
let cases = [
(
"https://a.example.com/docs/rust",
Some("https://a.example.com/knowledge/rust"),
),
(
"https://host/docs/part/leaf",
Some("https://host/knowledge/part/leaf"),
),
("https://host/other/part", None), ("http://host/docs/x", None), ];
for (input, want) in cases {
let got = apply(&r, input);
let want = want.map(|s| s.to_owned());
assert_eq!(got, want, "input: {input}");
}
}
#[test]
fn empty_capture_allowed() {
let r = rule("https://example.com/*/end", "https://example.com/$1/end");
let cases = [
("https://example.com//end", None), (
"https://example.com/x/end",
Some("https://example.com/x/end"),
),
(
"https://example.com/xx/end",
Some("https://example.com/xx/end"),
),
("https://example.com/x/end/extra", None),
];
for (input, want) in cases {
let got = apply(&r, input);
let want = want.map(|s| s.to_owned());
assert_eq!(got, want, "input: {input}");
}
}
#[test]
fn identity_with_star_only_and_missing_indices() {
let r = rule("*", "$2$1");
let cases = [
("https://x/y", Some("https://x/y")),
("http://a/b?c#frag", Some("http://a/b?c")), ];
for (input, want) in cases {
let got = apply(&r, input);
let want = want.map(|s| s.to_owned());
assert_eq!(got, want, "input: {input}");
}
}
#[test]
fn query_capture_and_preservation() {
let r = rule("https://example.com/search\\?*", "https://example.com/s?$1");
let cases = [
(
"https://example.com/search?q=hi&x=1",
Some("https://example.com/s?q=hi&x=1"),
),
("https://example.com/search?", None),
(
"https://example.com/SEARCH?q=hi",
Some("https://example.com/s?q=hi"),
), ];
for (input, want) in cases {
let got = apply(&r, input);
let want = want.map(|s| s.to_owned());
assert_eq!(got, want, "input: {input}");
}
}
#[test]
fn tiny_fuzz_http_to_https_never_panics_and_preserves_tail() {
let r = UriMatchReplaceRule::http_to_https();
let hosts = ["a.com", "b.org", "x.y"];
let paths = ["", "/p", "/p/q"];
let queries = ["", "?k=v", "?x=1&y=2"];
for h in hosts {
for p in paths {
for q in queries {
let input = format!("http://{h}{p}{q}");
let got = apply(&r, &input).expect("match expected for http input");
let expected = format!("https://{h}{p}{q}");
assert_eq!(got, expected, "input: {input}");
}
}
}
let https_inputs = ["https://a.com", "https://a.com/p?q=1", "https://x.y/p/q"];
for input in https_inputs {
assert_eq!(apply(&r, input), None, "should not match: {input}");
}
}
#[test]
fn deserialize_uri_match_replace_rule() {
for (raw_json_input, uri_input, expected_output) in [
(
r##"{"pattern":"http://*","template":"https://$1"}"##,
"http://example.com?foo=bar",
Some("https://example.com?foo=bar"),
),
(
r##"{"pattern":"http://example.org/*","template":"http://example.com/$1"}"##,
"http://example.org/foo?q=v",
Some("http://example.com/foo?q=v"),
),
] {
let rule: UriMatchReplaceRule = serde_json::from_str(raw_json_input).unwrap();
assert_eq!(
match_replace_uri_to_test_option_str(
rule.match_replace_uri(Cow::Owned(Uri::from_static(uri_input)))
)
.map(|s| s.replace("/?", "?").trim_end_matches('/').to_owned()),
expected_output.map(ToOwned::to_owned),
)
}
}
#[test]
fn deserialize_serialize_uri_match_replace_rule() {
for raw_json_input in [
r##"{"pattern":"http://*","template":"https://$1"}"##,
r##"{"pattern":"http://example.org/*","template":"http://example.com/$1"}"##,
r##"{"pattern":"*/v1/*","template":"$1/v2/$2"}"##,
r##"{"pattern":"*","template":"any.com"}"##,
] {
let rule: UriMatchReplaceRule = serde_json::from_str(raw_json_input).unwrap();
let raw_json_output = serde_json::to_string(&rule).unwrap();
assert_eq!(raw_json_input, raw_json_output);
}
}
}