use std::{collections::HashMap, sync::Arc};
use serde::{Deserialize, Serialize};
use super::RetryPolicy;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(untagged)]
pub enum PathMatch {
Exact {
path: String,
},
Prefix {
path_prefix: String,
},
}
impl PathMatch {
pub fn from_parts(path: Option<String>, path_prefix: Option<String>) -> Result<Self, String> {
match (path, path_prefix) {
(Some(path), None) => {
if path.is_empty() {
return Err("route 'path' must not be empty (request paths always begin with '/')".to_owned());
}
if !path.starts_with('/') {
return Err(format!(
"route 'path' must start with '/' (got '{path}'); request paths always begin with '/', \
so this route can never match"
));
}
Ok(Self::Exact { path })
},
(None, Some(path_prefix)) => {
if !path_prefix.is_empty() && !path_prefix.starts_with('/') {
return Err(format!(
"route 'path_prefix' must start with '/' or be empty (got '{path_prefix}'); \
request paths always begin with '/', so this prefix can never match"
));
}
Ok(Self::Prefix { path_prefix })
},
(Some(_), Some(_)) => Err("route cannot set both 'path' and 'path_prefix' (use exactly one)".to_owned()),
(None, None) => Err("route requires either 'path' or 'path_prefix'".to_owned()),
}
}
pub fn is_exact(&self) -> bool {
matches!(self, Self::Exact { .. })
}
pub fn len(&self) -> usize {
self.value().len()
}
pub fn is_empty(&self) -> bool {
self.value().is_empty()
}
pub fn value(&self) -> &str {
match self {
Self::Exact { path } => path,
Self::Prefix { path_prefix } => path_prefix,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(try_from = "RouteRaw")]
pub struct Route {
#[serde(flatten)]
pub path_match: PathMatch,
pub cluster: Arc<str>,
pub headers: Option<HashMap<String, String>>,
pub host: Option<String>,
#[serde(default)]
pub retry_policy: Option<RetryPolicy>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RouteRaw {
#[serde(default)]
path: Option<String>,
#[serde(default)]
path_prefix: Option<String>,
cluster: Arc<str>,
#[serde(default)]
headers: Option<HashMap<String, String>>,
#[serde(default)]
host: Option<String>,
#[serde(default)]
retry_policy: Option<RetryPolicy>,
}
impl Route {
pub fn validate_semantics(&self) -> Result<(), String> {
if let Some(policy) = &self.retry_policy {
policy.validate_timeout_bounds(&format!("route '{}'", self.path_match.value()))?;
}
if let Some(headers) = &self.headers
&& headers.keys().any(String::is_empty)
{
return Err(format!(
"route '{}': header match key must not be empty (an empty header name can never match)",
self.path_match.value()
));
}
if self.host.as_ref().is_some_and(String::is_empty) {
return Err(format!(
"route '{}': host must not be empty (an empty host can never match; \
omit 'host' to match any host)",
self.path_match.value()
));
}
Ok(())
}
}
impl TryFrom<RouteRaw> for Route {
type Error = String;
fn try_from(raw: RouteRaw) -> Result<Self, Self::Error> {
let route = Self {
path_match: PathMatch::from_parts(raw.path, raw.path_prefix)?,
cluster: raw.cluster,
headers: raw.headers,
host: raw.host,
retry_policy: raw.retry_policy,
};
route.validate_semantics()?;
Ok(route)
}
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::needless_raw_strings,
clippy::needless_raw_string_hashes,
reason = "tests use unwrap/expect/indexing/raw strings for brevity"
)]
mod tests {
use super::*;
#[test]
fn parse_route_without_host() {
let yaml = r#"
path_prefix: "/api"
cluster: "backend"
"#;
let route: Route = serde_yaml::from_str(yaml).unwrap();
assert_eq!(route.path_match.value(), "/api", "path value mismatch");
assert!(!route.path_match.is_exact(), "should be prefix match");
assert_eq!(&*route.cluster, "backend", "cluster mismatch");
assert!(route.host.is_none(), "host should be None when omitted");
}
#[test]
fn parse_route_with_headers() {
let yaml = r#"
path_prefix: "/"
cluster: "backend"
headers:
x-model: "model-alpha-1"
x-version: "v1"
"#;
let route: Route = serde_yaml::from_str(yaml).unwrap();
let headers = route.headers.unwrap();
assert_eq!(headers.len(), 2, "should have 2 header constraints");
assert_eq!(
headers.get("x-model").unwrap(),
"model-alpha-1",
"x-model header mismatch"
);
assert_eq!(headers.get("x-version").unwrap(), "v1", "x-version header mismatch");
}
#[test]
fn parse_route_with_host() {
let yaml = r#"
path_prefix: "/"
host: "api.example.com"
cluster: "api"
"#;
let route: Route = serde_yaml::from_str(yaml).unwrap();
assert_eq!(route.host.as_deref(), Some("api.example.com"), "host should be parsed");
}
#[test]
fn parse_exact_path() {
let yaml = r#"
path: "/exact"
cluster: "backend"
"#;
let route: Route = serde_yaml::from_str(yaml).unwrap();
assert!(route.path_match.is_exact(), "should be exact match");
assert_eq!(route.path_match.value(), "/exact", "exact path mismatch");
}
#[test]
fn reject_unknown_route_keys() {
let yaml = r#"
path_prfix: "/api"
path_prefix: "/api"
cluster: "backend"
"#;
let err = serde_yaml::from_str::<Route>(yaml).unwrap_err();
assert!(
err.to_string().contains("path_prfix") || err.to_string().contains("unknown field"),
"typoed keys must be rejected, not silently absorbed: {err}"
);
}
#[test]
fn reject_both_path_and_prefix() {
let yaml = r#"
path: "/one"
path_prefix: "/api"
cluster: "backend"
"#;
let err = serde_yaml::from_str::<Route>(yaml).unwrap_err();
assert!(
err.to_string().contains("both 'path' and 'path_prefix'"),
"setting both path keys must be rejected: {err}"
);
}
#[test]
fn reject_missing_path_keys() {
let yaml = r#"
cluster: "backend"
"#;
let err = serde_yaml::from_str::<Route>(yaml).unwrap_err();
assert!(
err.to_string().contains("either 'path' or 'path_prefix'"),
"a route without a path key must be rejected: {err}"
);
}
#[test]
fn reject_prefix_without_leading_slash() {
let yaml = r#"
path_prefix: "api"
cluster: "backend"
"#;
let err = serde_yaml::from_str::<Route>(yaml).unwrap_err();
assert!(
err.to_string().contains("must start with '/'"),
"a prefix that can never match a request path must be rejected: {err}"
);
}
#[test]
fn reject_exact_path_without_leading_slash() {
let yaml = r#"
path: "health"
cluster: "backend"
"#;
let err = serde_yaml::from_str::<Route>(yaml).unwrap_err();
assert!(err.to_string().contains("must start with '/'"), "got: {err}");
}
#[test]
fn reject_empty_exact_path() {
let yaml = r#"
path: ""
cluster: "backend"
"#;
let err = serde_yaml::from_str::<Route>(yaml).unwrap_err();
assert!(err.to_string().contains("must not be empty"), "got: {err}");
}
#[test]
fn empty_prefix_is_match_all() {
let route: Route = serde_yaml::from_str("path_prefix: \"\"\ncluster: backend\n").unwrap();
assert_eq!(route.path_match.value(), "");
}
#[test]
fn reject_empty_header_match_key() {
let yaml = r#"
path_prefix: "/api"
cluster: "backend"
headers:
"": "value"
"#;
let err = serde_yaml::from_str::<Route>(yaml).unwrap_err();
assert!(
err.to_string().contains("header match key must not be empty"),
"an empty header name can never match and must be rejected: {err}"
);
}
#[test]
fn reject_empty_host_match() {
let yaml = r#"
path_prefix: "/api"
cluster: "backend"
host: ""
"#;
let err = serde_yaml::from_str::<Route>(yaml).unwrap_err();
assert!(
err.to_string().contains("host must not be empty"),
"an empty host can never match and must be rejected: {err}"
);
}
#[test]
fn reject_route_retry_policy_zero_timeout() {
let yaml = r#"
path_prefix: "/api"
cluster: "backend"
retry_policy:
per_try_timeout_ms: 0
"#;
let err = serde_yaml::from_str::<Route>(yaml).unwrap_err();
assert!(
err.to_string().contains("per_try_timeout_ms is 0"),
"route-level retry overrides get the same timeout bounds as clusters: {err}"
);
}
#[test]
fn route_serializes_with_flattened_path() {
let route = Route {
path_match: PathMatch::Prefix {
path_prefix: "/api".to_owned(),
},
cluster: Arc::from("backend"),
headers: None,
host: None,
retry_policy: None,
};
let yaml = serde_yaml::to_string(&route).unwrap();
assert!(
yaml.contains("path_prefix: /api"),
"serialization keeps the flattened path key: {yaml}"
);
}
#[test]
fn path_match_len() {
let prefix = PathMatch::Prefix {
path_prefix: "/api/".to_owned(),
};
assert_eq!(prefix.len(), 5, "prefix length mismatch");
let exact = PathMatch::Exact {
path: "/one".to_owned(),
};
assert_eq!(exact.len(), 4, "exact length mismatch");
}
}