use serde::{Deserialize, Serialize};
use crate::config::{RouteCache, RouteHeader, RoutePlugin, RouteUpstream};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Location {
pub path: String,
#[serde(default)]
pub exact: bool,
#[serde(default)]
pub upstreams: Vec<RouteUpstream>,
pub headers: Option<RouteHeader>,
pub plugins: Option<Vec<RoutePlugin>>,
pub cache: Option<RouteCache>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeouts: Option<LocationTimeouts>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rewrite: Option<PathRewrite>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PathRewrite {
pub strip_prefix: Option<String>,
pub add_prefix: Option<String>,
#[serde(rename = "match")]
pub pattern: Option<String>,
pub replace: Option<String>,
}
const PATTERN_SIZE_LIMIT: usize = 1 << 20;
impl PathRewrite {
pub fn compile(&self) -> Result<CompiledRewrite, String> {
for (name, prefix) in [
("strip_prefix", &self.strip_prefix),
("add_prefix", &self.add_prefix),
] {
if let Some(prefix) = prefix {
if !prefix.starts_with('/') || prefix.contains(['?', '#']) {
return Err(format!(
"`{name}` must be a path starting with `/`, not {prefix:?}"
));
}
}
}
let prefixes = self.strip_prefix.is_some() || self.add_prefix.is_some();
let pattern = match (&self.pattern, &self.replace) {
(None, None) if !prefixes => {
return Err(
"`rewrite` needs `strip_prefix` or `add_prefix`, or `match` and `replace`"
.to_string(),
);
}
(None, None) => None,
(Some(_), None) | (None, Some(_)) => {
return Err("`match` and `replace` go together".to_string());
}
(Some(_), Some(_)) if prefixes => {
return Err(
"`match` and `replace` can't be combined with `strip_prefix` or `add_prefix`"
.to_string(),
);
}
(Some(pattern), Some(_)) => Some(
regex::RegexBuilder::new(pattern)
.size_limit(PATTERN_SIZE_LIMIT)
.build()
.map_err(|err| format!("invalid `match`: {err}"))?,
),
};
Ok(CompiledRewrite {
settings: self.clone(),
pattern,
})
}
}
#[derive(Debug, Clone)]
pub struct CompiledRewrite {
settings: PathRewrite,
pattern: Option<regex::Regex>,
}
impl CompiledRewrite {
pub fn apply(&self, path: &str, query: Option<&str>) -> String {
let mut rewritten = match (&self.pattern, &self.settings.replace) {
(Some(pattern), Some(replace)) => {
let replaced = pattern.replace(path, replace.as_str());
if replaced.starts_with('/') {
replaced.into_owned()
} else {
format!("/{replaced}")
}
}
_ => self.prefixes(path),
};
if let Some(query) = query {
rewritten.push('?');
rewritten.push_str(query);
}
rewritten
}
fn prefixes(&self, path: &str) -> String {
let mut path = path;
if let Some(prefix) = self
.settings
.strip_prefix
.as_deref()
.map(|p| p.trim_end_matches('/'))
{
if let Some(rest) = path.strip_prefix(prefix) {
if rest.is_empty() || rest.starts_with('/') {
path = rest;
}
}
}
match self
.settings
.add_prefix
.as_deref()
.map(|p| p.trim_end_matches('/'))
{
Some(prefix) if path.is_empty() || path == "/" => {
if prefix.is_empty() {
"/".to_string()
} else {
prefix.to_string()
}
}
Some(prefix) => format!("{prefix}{path}"),
None if path.is_empty() => "/".to_string(),
None => path.to_string(),
}
}
}
#[allow(clippy::struct_field_names)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LocationTimeouts {
pub read_secs: Option<u64>,
pub write_secs: Option<u64>,
pub upstream: Option<UpstreamTimeouts>,
}
impl LocationTimeouts {
pub fn or(self, defaults: Option<Self>) -> Self {
let defaults = defaults.unwrap_or_default();
Self {
read_secs: self.read_secs.or(defaults.read_secs),
write_secs: self.write_secs.or(defaults.write_secs),
upstream: match self.upstream {
Some(upstream) => Some(upstream.or(defaults.upstream)),
None => defaults.upstream,
},
}
}
}
#[allow(clippy::struct_field_names)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpstreamTimeouts {
pub connect_secs: Option<u64>,
pub tls_secs: Option<u64>,
pub read_secs: Option<u64>,
pub write_secs: Option<u64>,
pub idle_secs: Option<u64>,
}
impl UpstreamTimeouts {
pub fn or(self, defaults: Option<Self>) -> Self {
let defaults = defaults.unwrap_or_default();
Self {
connect_secs: self.connect_secs.or(defaults.connect_secs),
tls_secs: self.tls_secs.or(defaults.tls_secs),
read_secs: self.read_secs.or(defaults.read_secs),
write_secs: self.write_secs.or(defaults.write_secs),
idle_secs: self.idle_secs.or(defaults.idle_secs),
}
}
}
impl Location {
pub fn patterns(&self) -> Vec<String> {
if self.exact || self.path.ends_with(['*', '+']) {
return vec![self.path.clone()];
}
let base = self.path.trim_end_matches('/');
vec![self.path.clone(), format!("{base}/*")]
}
}
#[cfg(test)]
mod tests {
use super::Location;
fn location(path: &str, exact: bool) -> Location {
Location {
path: path.to_string(),
exact,
upstreams: vec![],
headers: None,
plugins: None,
cache: None,
timeouts: None,
rewrite: None,
}
}
#[test]
fn test_location_patterns_match_the_path_and_below() {
assert_eq!(
location("/v1/session", false).patterns(),
vec!["/v1/session", "/v1/session/*"]
);
assert_eq!(location("/", false).patterns(), vec!["/", "/*"]);
assert_eq!(location("/api/", false).patterns(), vec!["/api/", "/api/*"]);
}
#[test]
fn test_location_patterns_with_wildcards_or_exact() {
assert_eq!(location("/*", false).patterns(), vec!["/*"]);
assert_eq!(
location("/files/:path*", false).patterns(),
vec!["/files/:path*"]
);
assert_eq!(
location("/files/:path+", false).patterns(),
vec!["/files/:path+"]
);
assert_eq!(location("/auth", true).patterns(), vec!["/auth"]);
}
#[test]
fn test_rewrites_the_path() {
let rewrite = |strip: Option<&str>, add: Option<&str>| {
super::PathRewrite {
strip_prefix: strip.map(ToString::to_string),
add_prefix: add.map(ToString::to_string),
..Default::default()
}
.compile()
.unwrap()
};
let strip = rewrite(Some("/api"), None);
assert_eq!(strip.apply("/api/users", Some("page=2")), "/users?page=2");
assert_eq!(strip.apply("/api", None), "/");
assert_eq!(strip.apply("/api/", None), "/");
assert_eq!(strip.apply("/apis", None), "/apis");
assert_eq!(strip.apply("/other", None), "/other");
assert_eq!(
rewrite(Some("/api/"), None).apply("/api/users", None),
"/users"
);
let both = rewrite(Some("/api"), Some("/v2"));
assert_eq!(both.apply("/api/users", None), "/v2/users");
assert_eq!(both.apply("/api", Some("x=1")), "/v2?x=1");
assert_eq!(
rewrite(None, Some("/v2/")).apply("/users", None),
"/v2/users"
);
assert_eq!(rewrite(None, Some("/")).apply("/users", None), "/users");
}
#[test]
fn test_invalid_rewrites() {
let rewrite = |strip: Option<&str>, add: Option<&str>| super::PathRewrite {
strip_prefix: strip.map(ToString::to_string),
add_prefix: add.map(ToString::to_string),
..Default::default()
};
assert!(rewrite(None, None).compile().is_err());
assert!(rewrite(Some("api"), None).compile().is_err());
assert!(rewrite(None, Some("/v2?x=1")).compile().is_err());
assert!(rewrite(Some("/api"), Some("/v2")).compile().is_ok());
let regex = |pattern: Option<&str>, replace: Option<&str>| super::PathRewrite {
pattern: pattern.map(ToString::to_string),
replace: replace.map(ToString::to_string),
..Default::default()
};
assert!(regex(Some("^/a/(\\d+)$"), None).compile().is_err());
assert!(regex(Some("^/a/(\\d+"), Some("/b/$1"))
.compile()
.err()
.unwrap()
.starts_with("invalid `match`"));
assert!(regex(Some("\\w{1000}{1000}"), Some("/")).compile().is_err());
let mut combined = regex(Some("^/a$"), Some("/b"));
combined.strip_prefix = Some("/a".to_string());
assert!(combined.compile().is_err());
}
#[test]
fn test_rewrites_with_regular_expressions() {
let rewrite = super::PathRewrite {
pattern: Some("^/users/(?P<id>\\d+)/profile$".to_string()),
replace: Some("/profiles/${id}".to_string()),
..Default::default()
}
.compile()
.unwrap();
assert_eq!(
rewrite.apply("/users/42/profile", Some("tab=posts")),
"/profiles/42?tab=posts"
);
assert_eq!(rewrite.apply("/users/42", None), "/users/42");
let relative = super::PathRewrite {
pattern: Some("^/old/(.*)$".to_string()),
replace: Some("$1".to_string()),
..Default::default()
}
.compile()
.unwrap();
assert_eq!(relative.apply("/old/page", None), "/page");
}
#[test]
#[ignore = "measures time"]
fn rewrite_overhead() {
let iterations = 1_000_000;
let measure = |name: &str, rewrite: Option<super::CompiledRewrite>| {
let started = std::time::Instant::now();
for i in 0..iterations {
let path = if i % 2 == 0 { "/users/42/profile" } else { "/users/42/posts" };
let rewritten = match &rewrite {
Some(rewrite) => rewrite.apply(path, Some("tab=1")),
None => path.to_string(),
};
std::hint::black_box(rewritten);
}
let per = started.elapsed() / iterations;
println!("{name}: {per:?} per request");
};
let compile = |rewrite: super::PathRewrite| Some(rewrite.compile().unwrap());
measure("no rewrite", None);
measure(
"prefixes",
compile(super::PathRewrite {
strip_prefix: Some("/users".to_string()),
add_prefix: Some("/v2".to_string()),
..Default::default()
}),
);
measure(
"regex",
compile(super::PathRewrite {
pattern: Some("^/users/(\\d+)/profile$".to_string()),
replace: Some("/profiles/$1".to_string()),
..Default::default()
}),
);
measure(
"regex, named groups",
compile(super::PathRewrite {
pattern: Some("^/(?P<kind>[a-z]+)/(?P<id>\\d+)/(?P<rest>.*)$".to_string()),
replace: Some("/api/${kind}/${rest}/${id}".to_string()),
..Default::default()
}),
);
}
}