use crate::error::LimiteronError;
use crate::i18n::t;
use crate::matchers::RequestContext;
use ahash::AHashMap as HashMap;
use async_trait::async_trait;
use chrono::Timelike;
use log::{debug, error, info, warn};
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::RwLock;
const MAX_MATCHER_NAME_LENGTH: usize = 100;
const MAX_HEADER_NAME_LENGTH: usize = 256;
const MAX_HEADER_VALUE_LENGTH: usize = 4096;
const MAX_ALLOWED_VALUES_COUNT: usize = 100;
fn validate_matcher_name(name: &str) -> Result<(), LimiteronError> {
if name.is_empty() {
return Err(LimiteronError::ConfigError(t("matcher-name-empty", &[])));
}
if name.len() > MAX_MATCHER_NAME_LENGTH {
return Err(LimiteronError::ConfigError(t(
"matcher-name-too-long",
&[("max", MAX_MATCHER_NAME_LENGTH.to_string())],
)));
}
if !name
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
{
return Err(LimiteronError::ConfigError(t(
"matcher-name-invalid-chars",
&[],
)));
}
Ok(())
}
fn validate_header_name(name: &str) -> Result<(), LimiteronError> {
if name.is_empty() {
return Err(LimiteronError::ConfigError(t("header-name-empty", &[])));
}
if name.len() > MAX_HEADER_NAME_LENGTH {
return Err(LimiteronError::ConfigError(t(
"header-name-too-long",
&[("max", MAX_HEADER_NAME_LENGTH.to_string())],
)));
}
if !name.chars().all(|c| c.is_alphanumeric() || c == '-') {
return Err(LimiteronError::ConfigError(t(
"header-name-invalid-chars",
&[],
)));
}
Ok(())
}
fn validate_header_value(value: &str) -> Result<(), LimiteronError> {
if value.is_empty() {
return Err(LimiteronError::ConfigError(t("header-value-empty", &[])));
}
if value.len() > MAX_HEADER_VALUE_LENGTH {
return Err(LimiteronError::ConfigError(t(
"header-value-too-long",
&[("max", MAX_HEADER_VALUE_LENGTH.to_string())],
)));
}
Ok(())
}
fn validate_method(method: &str) -> Result<(), LimiteronError> {
if method.is_empty() {
return Err(LimiteronError::ConfigError(t(
"matcher-method-invalid",
&[("value", method.to_string())],
)));
}
if !method.bytes().all(is_token_byte) {
return Err(LimiteronError::ConfigError(t(
"matcher-method-invalid",
&[("value", method.to_string())],
)));
}
Ok(())
}
fn is_token_byte(b: u8) -> bool {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
}
#[async_trait]
pub trait CustomMatcher: Send + Sync {
fn name(&self) -> &str;
async fn matches(&self, context: &RequestContext) -> Result<bool, LimiteronError>;
fn load_config(&mut self, config: Value) -> Result<(), LimiteronError>;
}
#[derive(Clone)]
pub struct CustomMatcherRegistry {
matchers: Arc<RwLock<HashMap<String, std::sync::Arc<dyn CustomMatcher>>>>,
}
impl std::fmt::Debug for CustomMatcherRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CustomMatcherRegistry")
.field("matchers", &"<custom matchers>")
.finish()
}
}
impl CustomMatcherRegistry {
pub fn new() -> Self {
Self {
matchers: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn builder() -> CustomMatcherRegistryBuilder {
CustomMatcherRegistryBuilder::new()
}
pub fn with_dependencies() -> Self {
Self::new()
}
pub async fn register(
&self,
name: String,
matcher: Box<dyn CustomMatcher>,
) -> Result<(), LimiteronError> {
validate_matcher_name(&name)?;
let mut matchers = self.matchers.write().await;
if matchers.contains_key(&name) {
let error_msg = t("matcher-already-exists", &[("name", name.clone())]);
warn!("{}", error_msg);
return Err(LimiteronError::ConfigError(error_msg));
}
info!("{}", t("matcher-registered", &[("name", name.clone())]));
matchers.insert(name.clone(), std::sync::Arc::from(matcher));
debug!("registered matcher count: {}", matchers.len());
Ok(())
}
pub async fn get(&self, name: &str) -> Option<std::sync::Arc<dyn CustomMatcher>> {
let matchers = self.matchers.read().await;
let found = matchers.get(name).cloned();
if found.is_some() {
debug!("query matcher: {}", name);
} else {
debug!("matcher not found: {}", name);
}
found
}
pub async fn contains(&self, name: &str) -> bool {
let matchers = self.matchers.read().await;
matchers.contains_key(name)
}
pub async fn unregister(&self, name: &str) -> Result<(), LimiteronError> {
let mut matchers = self.matchers.write().await;
if !matchers.contains_key(name) {
let error_msg = t("matcher-not-found", &[("name", name.to_string())]);
warn!("{}", error_msg);
return Err(LimiteronError::ConfigError(error_msg));
}
info!(
"{}",
t("matcher-unregistered", &[("name", name.to_string())])
);
matchers.remove(name);
debug!("registered matcher count: {}", matchers.len());
Ok(())
}
#[allow(clippy::map_clone)]
pub async fn list(&self) -> Vec<String> {
let matchers = self.matchers.read().await;
matchers.keys().map(|k| k.clone()).collect()
}
pub async fn count(&self) -> usize {
let matchers = self.matchers.read().await;
matchers.len()
}
pub async fn clear(&self) {
let mut matchers = self.matchers.write().await;
info!("{}", t("matcher-registry-cleared", &[]));
matchers.clear();
}
pub async fn match_with(
&self,
name: &str,
context: &RequestContext,
) -> Result<bool, LimiteronError> {
let matchers = self.matchers.read().await;
let matcher = matchers.get(name).ok_or_else(|| {
let error_msg = t("matcher-not-found", &[("name", name.to_string())]);
error!("{}", error_msg);
LimiteronError::ConfigError(error_msg)
})?;
debug!("checking request with matcher '{}'", name);
matcher.matches(context).await
}
}
impl Default for CustomMatcherRegistry {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Default)]
pub struct CustomMatcherRegistryBuilder;
impl CustomMatcherRegistryBuilder {
pub fn new() -> Self {
Self
}
pub fn build(self) -> CustomMatcherRegistry {
CustomMatcherRegistry::new()
}
}
#[derive(Debug, Clone)]
pub struct TimeWindowMatcher {
start_hour: u8,
end_hour: u8,
}
impl TimeWindowMatcher {
pub fn new(start_hour: u8, end_hour: u8) -> Self {
assert!(start_hour <= 23, "start_hour must be in range 0-23");
assert!(end_hour <= 23, "end_hour must be in range 0-23");
Self {
start_hour,
end_hour,
}
}
pub fn start_hour(&self) -> u8 {
self.start_hour
}
pub fn end_hour(&self) -> u8 {
self.end_hour
}
pub fn builder() -> TimeWindowMatcherBuilder {
TimeWindowMatcherBuilder::new()
}
pub fn with_dependencies(start_hour: u8, end_hour: u8) -> Self {
Self::new(start_hour, end_hour)
}
}
#[async_trait]
impl CustomMatcher for TimeWindowMatcher {
fn name(&self) -> &str {
"time_window"
}
async fn matches(&self, _context: &RequestContext) -> Result<bool, LimiteronError> {
let now = chrono::Utc::now();
let hour = now.hour() as u8;
let matches = if self.start_hour <= self.end_hour {
hour >= self.start_hour && hour <= self.end_hour
} else {
hour >= self.start_hour || hour <= self.end_hour
};
debug!(
"time window match: current hour {}, window {}-{}, result: {}",
hour, self.start_hour, self.end_hour, matches
);
Ok(matches)
}
fn load_config(&mut self, config: Value) -> Result<(), LimiteronError> {
let start_hour_u64 = config["start_hour"].as_u64().ok_or_else(|| {
LimiteronError::ConfigError(t(
"matcher-config-missing",
&[("field", "start_hour".to_string())],
))
})?;
if start_hour_u64 > 23 {
return Err(LimiteronError::ConfigError(t(
"matcher-hour-out-of-range",
&[("field", "start_hour".to_string())],
)));
}
let start_hour = start_hour_u64 as u8;
let end_hour_u64 = config["end_hour"].as_u64().ok_or_else(|| {
LimiteronError::ConfigError(t(
"matcher-config-missing",
&[("field", "end_hour".to_string())],
))
})?;
if end_hour_u64 > 23 {
return Err(LimiteronError::ConfigError(t(
"matcher-hour-out-of-range",
&[("field", "end_hour".to_string())],
)));
}
let end_hour = end_hour_u64 as u8;
self.start_hour = start_hour;
self.end_hour = end_hour;
info!(
"{}",
t(
"matcher-time-window-config-loaded",
&[
("start", self.start_hour.to_string()),
("end", self.end_hour.to_string()),
]
)
);
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct TimeWindowMatcherBuilder {
start_hour: u8,
end_hour: u8,
}
impl TimeWindowMatcherBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn start_hour(mut self, start_hour: u8) -> Self {
self.start_hour = start_hour;
self
}
pub fn end_hour(mut self, end_hour: u8) -> Self {
self.end_hour = end_hour;
self
}
pub fn build(self) -> TimeWindowMatcher {
TimeWindowMatcher::new(self.start_hour, self.end_hour)
}
}
#[derive(Debug, Clone)]
pub struct HeaderMatcher {
header_name: String,
allowed_values: Vec<String>,
allowed_values_normalized: Vec<String>,
values_ascii: Vec<bool>,
case_sensitive: bool,
match_mode: HeaderMatchMode,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HeaderMatchMode {
#[default]
Exact,
Prefix,
}
impl HeaderMatcher {
pub fn new(header_name: &str, allowed_values: Vec<String>) -> Result<Self, LimiteronError> {
validate_header_name(header_name)?;
if allowed_values.is_empty() {
return Err(LimiteronError::ConfigError(t(
"matcher-header-values-empty",
&[],
)));
}
if allowed_values.len() > MAX_ALLOWED_VALUES_COUNT {
return Err(LimiteronError::ValidationError(t(
"matcher-allowed-values-too-many",
&[("max", MAX_ALLOWED_VALUES_COUNT.to_string())],
)));
}
for value in &allowed_values {
validate_header_value(value)?;
}
let allowed_values_normalized = allowed_values.iter().map(|v| v.to_lowercase()).collect();
let values_ascii = allowed_values.iter().map(|v| v.is_ascii()).collect();
Ok(Self {
header_name: header_name.to_lowercase(),
allowed_values,
allowed_values_normalized,
values_ascii,
case_sensitive: false,
match_mode: HeaderMatchMode::Exact,
})
}
pub fn with_match_mode(mut self, match_mode: HeaderMatchMode) -> Self {
self.match_mode = match_mode;
self
}
pub fn match_mode(&self) -> HeaderMatchMode {
self.match_mode
}
pub fn with_case_sensitive(mut self, case_sensitive: bool) -> Self {
self.case_sensitive = case_sensitive;
self
}
pub fn header_name(&self) -> &str {
&self.header_name
}
pub fn allowed_values(&self) -> &[String] {
&self.allowed_values
}
pub fn builder() -> HeaderMatcherBuilder {
HeaderMatcherBuilder::new()
}
pub fn with_dependencies(
header_name: &str,
allowed_values: Vec<String>,
case_sensitive: bool,
) -> Result<Self, LimiteronError> {
Self::new(header_name, allowed_values).map(|mut m| {
m.case_sensitive = case_sensitive;
m
})
}
}
#[async_trait]
impl CustomMatcher for HeaderMatcher {
fn name(&self) -> &str {
"header"
}
async fn matches(&self, context: &RequestContext) -> Result<bool, LimiteronError> {
let header_value = match context.get_header(&self.header_name) {
Some(value) => value,
None => {
debug!("HTTP header '{}' not present", self.header_name);
return Ok(false);
}
};
let lower_value = || header_value.to_lowercase();
let matches = match self.match_mode {
HeaderMatchMode::Exact => {
if self.case_sensitive {
self.allowed_values.contains(header_value)
} else {
let mut lower: Option<String> = None;
self.allowed_values_normalized
.iter()
.zip(self.values_ascii.iter())
.any(|(v, ascii)| {
if *ascii {
v.eq_ignore_ascii_case(header_value)
} else {
lower.get_or_insert_with(lower_value);
*v == lower.as_deref().unwrap()
}
})
}
}
HeaderMatchMode::Prefix => {
if self.case_sensitive {
self.allowed_values
.iter()
.any(|v| header_value.starts_with(v))
} else {
let header_bytes = header_value.as_bytes();
let mut lower: Option<String> = None;
self.allowed_values_normalized
.iter()
.zip(self.values_ascii.iter())
.any(|(v, ascii)| {
let v_bytes = v.as_bytes();
if header_bytes.len() < v_bytes.len() {
return false;
}
if *ascii {
header_bytes[..v_bytes.len()].eq_ignore_ascii_case(v_bytes)
} else {
let lower_str = lower.get_or_insert_with(lower_value);
lower_str.starts_with(v.as_str())
}
})
}
}
};
debug!(
"HTTP header match: header='{}', value='{}', result: {}",
self.header_name, header_value, matches
);
Ok(matches)
}
fn load_config(&mut self, config: Value) -> Result<(), LimiteronError> {
if let Some(header_name) = config["header_name"].as_str() {
validate_header_name(header_name)?;
self.header_name = header_name.to_lowercase();
}
if let Some(values) = config["allowed_values"].as_array() {
if values.len() > MAX_ALLOWED_VALUES_COUNT {
return Err(LimiteronError::ConfigError(t(
"matcher-allowed-values-too-many",
&[("max", MAX_ALLOWED_VALUES_COUNT.to_string())],
)));
}
self.allowed_values = values
.iter()
.map(|v| {
let s = v.as_str().ok_or_else(|| {
LimiteronError::ConfigError(t(
"matcher-value-invalid",
&[("value", v.to_string())],
))
})?;
validate_header_value(s).map(|_| s.to_string())
})
.collect::<Result<Vec<_>, LimiteronError>>()?;
self.allowed_values_normalized = self
.allowed_values
.iter()
.map(|v| v.to_lowercase())
.collect();
self.values_ascii = self.allowed_values.iter().map(|v| v.is_ascii()).collect();
}
if let Some(case_sensitive) = config["case_sensitive"].as_bool() {
self.case_sensitive = case_sensitive;
}
if let Some(mode) = config["match_mode"].as_str() {
self.match_mode =
serde_json::from_value(Value::String(mode.to_string())).map_err(|e| {
LimiteronError::ConfigError(t(
"matcher-header-match-mode-invalid",
&[("mode", mode.to_string()), ("error", e.to_string())],
))
})?;
}
info!(
"{}",
t(
"matcher-header-config-loaded",
&[
("header", self.header_name.clone()),
("values", format!("{:?}", self.allowed_values)),
("case_sensitive", self.case_sensitive.to_string()),
]
)
);
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct HeaderMatcherBuilder {
header_name: Option<String>,
allowed_values: Vec<String>,
case_sensitive: bool,
match_mode: HeaderMatchMode,
}
impl HeaderMatcherBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn header_name(mut self, header_name: &str) -> Self {
self.header_name = Some(header_name.to_string());
self
}
pub fn allowed_values(mut self, allowed_values: Vec<String>) -> Self {
self.allowed_values = allowed_values;
self
}
pub fn add_allowed_value(mut self, value: &str) -> Self {
self.allowed_values.push(value.to_string());
self
}
pub fn case_sensitive(mut self, case_sensitive: bool) -> Self {
self.case_sensitive = case_sensitive;
self
}
pub fn match_mode(mut self, match_mode: HeaderMatchMode) -> Self {
self.match_mode = match_mode;
self
}
pub fn build(self) -> Result<HeaderMatcher, LimiteronError> {
HeaderMatcher::new(
self.header_name.as_deref().unwrap_or(""),
self.allowed_values,
)
.map(|mut m| {
m.case_sensitive = self.case_sensitive;
m.match_mode = self.match_mode;
m
})
}
}
#[derive(Debug, Clone)]
pub struct MethodMatcher {
methods: Vec<String>,
}
impl MethodMatcher {
pub fn new(methods: Vec<String>) -> Result<Self, LimiteronError> {
if methods.is_empty() {
return Err(LimiteronError::ConfigError(t("matcher-methods-empty", &[])));
}
if methods.len() > MAX_ALLOWED_VALUES_COUNT {
return Err(LimiteronError::ValidationError(t(
"matcher-allowed-values-too-many",
&[("max", MAX_ALLOWED_VALUES_COUNT.to_string())],
)));
}
let methods = methods
.iter()
.map(|m| validate_method(m).map(|_| m.to_ascii_uppercase()))
.collect::<Result<Vec<_>, LimiteronError>>()?;
Ok(Self { methods })
}
pub fn methods(&self) -> &[String] {
&self.methods
}
pub fn builder() -> MethodMatcherBuilder {
MethodMatcherBuilder::new()
}
}
#[async_trait]
impl CustomMatcher for MethodMatcher {
fn name(&self) -> &str {
"method"
}
async fn matches(&self, context: &RequestContext) -> Result<bool, LimiteronError> {
let matches = self
.methods
.iter()
.any(|m| m.eq_ignore_ascii_case(&context.method));
debug!(
"HTTP method match: method='{}', result: {}",
context.method, matches
);
Ok(matches)
}
fn load_config(&mut self, config: Value) -> Result<(), LimiteronError> {
if let Some(methods) = config["methods"].as_array() {
if methods.len() > MAX_ALLOWED_VALUES_COUNT {
return Err(LimiteronError::ConfigError(t(
"matcher-allowed-values-too-many",
&[("max", MAX_ALLOWED_VALUES_COUNT.to_string())],
)));
}
let parsed: Vec<String> = methods
.iter()
.map(|v| {
let str_value = v.as_str().ok_or_else(|| {
LimiteronError::ConfigError(t(
"matcher-method-invalid",
&[("value", v.to_string())],
))
})?;
validate_method(str_value).map(|_| str_value.to_ascii_uppercase())
})
.collect::<Result<Vec<_>, LimiteronError>>()?;
if parsed.is_empty() {
return Err(LimiteronError::ConfigError(t("matcher-methods-empty", &[])));
}
self.methods = parsed;
}
info!(
"{}",
t(
"matcher-method-config-loaded",
&[("methods", format!("{:?}", self.methods))]
)
);
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct MethodMatcherBuilder {
methods: Vec<String>,
}
impl MethodMatcherBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn methods(mut self, methods: Vec<String>) -> Self {
self.methods = methods;
self
}
pub fn add_method(mut self, method: &str) -> Self {
self.methods.push(method.to_string());
self
}
pub fn build(self) -> Result<MethodMatcher, LimiteronError> {
MethodMatcher::new(self.methods)
}
}
#[cfg(feature = "regex-matching")]
#[derive(Debug, Clone)]
pub struct RegexPathMatcher {
pattern: regex::Regex,
source: String,
case_insensitive: bool,
}
#[cfg(feature = "regex-matching")]
impl RegexPathMatcher {
pub fn new(pattern: &str, case_insensitive: bool) -> Result<Self, LimiteronError> {
if pattern.is_empty() {
return Err(LimiteronError::ConfigError(t(
"matcher-regex-pattern-empty",
&[],
)));
}
let source = if case_insensitive {
format!("(?i){pattern}")
} else {
pattern.to_string()
};
let pattern = regex::Regex::new(&source).map_err(|e| {
LimiteronError::ConfigError(t(
"matcher-regex-pattern-invalid",
&[("error", e.to_string())],
))
})?;
Ok(Self {
pattern,
source,
case_insensitive,
})
}
pub fn pattern(&self) -> &str {
&self.source
}
pub fn case_insensitive(&self) -> bool {
self.case_insensitive
}
}
#[cfg(feature = "regex-matching")]
#[async_trait]
impl CustomMatcher for RegexPathMatcher {
fn name(&self) -> &str {
"regex_path"
}
async fn matches(&self, context: &RequestContext) -> Result<bool, LimiteronError> {
let matches = self.pattern.is_match(&context.path);
debug!(
"regex path match: path='{}', result: {}",
context.path, matches
);
Ok(matches)
}
fn load_config(&mut self, config: Value) -> Result<(), LimiteronError> {
if let Some(case_insensitive) = config["case_insensitive"].as_bool()
&& case_insensitive != self.case_insensitive
{
self.case_insensitive = case_insensitive;
let base = self.source.strip_prefix("(?i)").unwrap_or(&self.source);
self.source = if case_insensitive {
format!("(?i){base}")
} else {
base.to_string()
};
self.pattern = regex::Regex::new(&self.source).map_err(|e| {
LimiteronError::ConfigError(t(
"matcher-regex-pattern-invalid",
&[("error", e.to_string())],
))
})?;
}
if let Some(pattern) = config["pattern"].as_str() {
if pattern.is_empty() {
return Err(LimiteronError::ConfigError(t(
"matcher-regex-pattern-empty",
&[],
)));
}
self.source = if self.case_insensitive {
format!("(?i){pattern}")
} else {
pattern.to_string()
};
self.pattern = regex::Regex::new(&self.source).map_err(|e| {
LimiteronError::ConfigError(t(
"matcher-regex-pattern-invalid",
&[("error", e.to_string())],
))
})?;
}
info!(
"{}",
t(
"matcher-regex-config-loaded",
&[("pattern", self.source.clone())]
)
);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::matchers::RequestContext;
#[tokio::test]
async fn test_registry_new() {
let registry = CustomMatcherRegistry::new();
assert_eq!(registry.count().await, 0);
}
#[tokio::test]
async fn test_registry_register() {
let registry = CustomMatcherRegistry::new();
let matcher = TimeWindowMatcher::new(9, 18);
assert!(
registry
.register("time_window".to_string(), Box::new(matcher))
.await
.is_ok()
);
assert_eq!(registry.count().await, 1);
assert!(registry.contains("time_window").await);
}
#[tokio::test]
async fn test_registry_register_duplicate() {
let registry = CustomMatcherRegistry::new();
let matcher = TimeWindowMatcher::new(9, 18);
assert!(
registry
.register("time_window".to_string(), Box::new(matcher))
.await
.is_ok()
);
let result = registry
.register(
"time_window".to_string(),
Box::new(TimeWindowMatcher::new(10, 20)),
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_registry_unregister() {
let registry = CustomMatcherRegistry::new();
let matcher = TimeWindowMatcher::new(9, 18);
registry
.register("time_window".to_string(), Box::new(matcher))
.await
.unwrap();
assert!(registry.unregister("time_window").await.is_ok());
assert_eq!(registry.count().await, 0);
assert!(!registry.contains("time_window").await);
}
#[tokio::test]
async fn test_registry_unregister_nonexistent() {
let registry = CustomMatcherRegistry::new();
let result = registry.unregister("nonexistent").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_registry_list() {
let registry = CustomMatcherRegistry::new();
registry
.register(
"matcher1".to_string(),
Box::new(TimeWindowMatcher::new(9, 18)),
)
.await
.unwrap();
registry
.register(
"matcher2".to_string(),
Box::new(HeaderMatcher::new("X-API-Key", vec!["secret".to_string()]).unwrap()),
)
.await
.unwrap();
let list = registry.list().await;
assert_eq!(list.len(), 2);
assert!(list.contains(&"matcher1".to_string()));
assert!(list.contains(&"matcher2".to_string()));
}
#[tokio::test]
async fn test_registry_clear() {
let registry = CustomMatcherRegistry::new();
registry
.register(
"matcher1".to_string(),
Box::new(TimeWindowMatcher::new(9, 18)),
)
.await
.unwrap();
registry.clear().await;
assert_eq!(registry.count().await, 0);
}
#[tokio::test]
async fn test_registry_match_with() {
let registry = CustomMatcherRegistry::new();
let matcher = TimeWindowMatcher::new(0, 23);
registry
.register("time_window".to_string(), Box::new(matcher))
.await
.unwrap();
let context = RequestContext::new();
let result = registry.match_with("time_window", &context).await;
assert!(result.is_ok());
assert!(result.unwrap());
}
#[tokio::test]
async fn test_registry_match_with_nonexistent() {
let registry = CustomMatcherRegistry::new();
let context = RequestContext::new();
let result = registry.match_with("nonexistent", &context).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_time_window_matcher_new() {
let matcher = TimeWindowMatcher::new(9, 18);
assert_eq!(matcher.name(), "time_window");
assert_eq!(matcher.start_hour(), 9);
assert_eq!(matcher.end_hour(), 18);
}
#[tokio::test]
async fn test_time_window_matcher_matches() {
let matcher = TimeWindowMatcher::new(0, 23); let context = RequestContext::new();
let result = matcher.matches(&context).await.unwrap();
assert!(result);
}
#[tokio::test]
async fn test_time_window_matcher_load_config() {
let mut matcher = TimeWindowMatcher::new(9, 18);
let config = serde_json::json!({
"start_hour": 10,
"end_hour": 20
});
assert!(matcher.load_config(config).is_ok());
assert_eq!(matcher.start_hour(), 10);
assert_eq!(matcher.end_hour(), 20);
}
#[tokio::test]
async fn test_time_window_matcher_load_config_invalid() {
let mut matcher = TimeWindowMatcher::new(9, 18);
let config = serde_json::json!({
"start_hour": 25
});
let result = matcher.load_config(config);
assert!(result.is_err());
}
#[test]
#[should_panic(expected = "start_hour must be in range 0-23")]
fn test_time_window_matcher_invalid_start_hour() {
TimeWindowMatcher::new(25, 18);
}
#[test]
#[should_panic(expected = "end_hour must be in range 0-23")]
fn test_time_window_matcher_invalid_end_hour() {
TimeWindowMatcher::new(9, 25);
}
#[tokio::test]
async fn test_header_matcher_new() {
let matcher = HeaderMatcher::new("X-API-Key", vec!["secret123".to_string()]).unwrap();
assert_eq!(matcher.name(), "header");
assert_eq!(matcher.header_name(), "x-api-key");
assert_eq!(matcher.allowed_values().len(), 1);
}
#[tokio::test]
async fn test_header_matcher_matches() {
let matcher = HeaderMatcher::new("X-API-Key", vec!["secret123".to_string()]).unwrap();
let context = RequestContext::new().with_header("X-API-Key", "secret123");
let result = matcher.matches(&context).await.unwrap();
assert!(result);
}
#[tokio::test]
async fn test_header_matcher_not_matches() {
let matcher = HeaderMatcher::new("X-API-Key", vec!["secret123".to_string()]).unwrap();
let context = RequestContext::new().with_header("X-API-Key", "wrong");
let result = matcher.matches(&context).await.unwrap();
assert!(!result);
}
#[tokio::test]
async fn test_header_matcher_missing_header() {
let matcher = HeaderMatcher::new("X-API-Key", vec!["secret123".to_string()]).unwrap();
let context = RequestContext::new();
let result = matcher.matches(&context).await.unwrap();
assert!(!result);
}
#[tokio::test]
async fn test_header_matcher_case_insensitive() {
let matcher = HeaderMatcher::new("X-API-Key", vec!["SECRET123".to_string()]).unwrap();
let context = RequestContext::new().with_header("X-API-Key", "secret123");
let result = matcher.matches(&context).await.unwrap();
assert!(result);
}
#[tokio::test]
async fn test_header_header_matcher_case_sensitive() {
let matcher = HeaderMatcher::new("X-API-Key", vec!["SECRET123".to_string()])
.unwrap()
.with_case_sensitive(true);
let context = RequestContext::new().with_header("X-API-Key", "secret123");
let result = matcher.matches(&context).await.unwrap();
assert!(!result);
}
#[tokio::test]
async fn test_header_matcher_multiple_values() {
let matcher = HeaderMatcher::new(
"X-API-Key",
vec!["secret123".to_string(), "secret456".to_string()],
)
.unwrap();
let context1 = RequestContext::new().with_header("X-API-Key", "secret123");
let context2 = RequestContext::new().with_header("X-API-Key", "secret456");
assert!(matcher.matches(&context1).await.unwrap());
assert!(matcher.matches(&context2).await.unwrap());
}
#[tokio::test]
async fn test_header_matcher_load_config() {
let mut matcher = HeaderMatcher::new("X-API-Key", vec!["secret123".to_string()]).unwrap();
let config = serde_json::json!({
"header_name": "Authorization",
"allowed_values": ["Bearer token123"],
"case_sensitive": true
});
assert!(matcher.load_config(config).is_ok());
assert_eq!(matcher.header_name(), "authorization");
assert_eq!(matcher.allowed_values().len(), 1);
}
#[tokio::test]
async fn test_registry_concurrent_register() {
let registry = Arc::new(CustomMatcherRegistry::new());
let mut handles = vec![];
for i in 0..10 {
let registry_clone = Arc::clone(®istry);
handles.push(tokio::spawn(async move {
let matcher = TimeWindowMatcher::new(i as u8, (i + 10) as u8);
registry_clone
.register(format!("matcher_{}", i), Box::new(matcher))
.await
}));
}
let mut success_count = 0;
for handle in handles {
if handle.await.unwrap().is_ok() {
success_count += 1;
}
}
assert_eq!(success_count, 10);
assert_eq!(registry.count().await, 10);
}
#[tokio::test]
async fn test_registry_concurrent_match() {
let registry = Arc::new(CustomMatcherRegistry::new());
let matcher = TimeWindowMatcher::new(0, 23);
registry
.register("time_window".to_string(), Box::new(matcher))
.await
.unwrap();
let mut handles = vec![];
for _ in 0..100 {
let registry_clone = Arc::clone(®istry);
handles.push(tokio::spawn(async move {
let context = RequestContext::new();
registry_clone.match_with("time_window", &context).await
}));
}
let mut success_count = 0;
for handle in handles {
if let Ok(Ok(true)) = handle.await {
success_count += 1;
}
}
assert_eq!(success_count, 100);
}
#[test]
fn test_validate_matcher_name_empty() {
let result = validate_matcher_name("");
assert!(result.is_err());
}
#[test]
fn test_validate_matcher_name_too_long() {
let long_name = "a".repeat(MAX_MATCHER_NAME_LENGTH + 1);
let result = validate_matcher_name(&long_name);
assert!(result.is_err());
}
#[test]
fn test_validate_matcher_name_invalid_chars() {
let result = validate_matcher_name("invalid@name!");
assert!(result.is_err());
}
#[test]
fn test_validate_header_name_empty() {
let result = validate_header_name("");
assert!(result.is_err());
}
#[test]
fn test_validate_header_name_too_long() {
let long_name = "a".repeat(MAX_HEADER_NAME_LENGTH + 1);
let result = validate_header_name(&long_name);
assert!(result.is_err());
}
#[test]
fn test_validate_header_name_invalid_chars() {
let result = validate_header_name("X-Header@Name");
assert!(result.is_err());
}
#[test]
fn test_validate_header_value_too_long() {
let long_value = "a".repeat(MAX_HEADER_VALUE_LENGTH + 1);
let result = validate_header_value(&long_value);
assert!(result.is_err());
assert!(
validate_header_value("").is_err(),
"an empty value semantically flipping to a wildcard in Prefix mode must be rejected"
);
assert!(validate_header_value("valid-value").is_ok());
}
#[tokio::test]
async fn test_registry_default() {
let registry = CustomMatcherRegistry::default();
assert_eq!(registry.count().await, 0);
}
#[test]
fn test_registry_debug() {
let registry = CustomMatcherRegistry::new();
let debug_str = format!("{:?}", registry);
assert!(debug_str.contains("CustomMatcherRegistry"));
assert!(debug_str.contains("<custom matchers>"));
}
#[tokio::test]
async fn test_registry_builder() {
let registry = CustomMatcherRegistry::builder().build();
assert_eq!(registry.count().await, 0);
}
#[tokio::test]
async fn test_registry_with_dependencies() {
let registry = CustomMatcherRegistry::with_dependencies();
assert_eq!(registry.count().await, 0);
}
#[tokio::test]
async fn test_registry_register_with_invalid_name() {
let registry = CustomMatcherRegistry::new();
let matcher = TimeWindowMatcher::new(9, 18);
let result = registry.register("".to_string(), Box::new(matcher)).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_registry_get_found() {
let registry = CustomMatcherRegistry::new();
registry
.register("test".to_string(), Box::new(TimeWindowMatcher::new(9, 18)))
.await
.unwrap();
let result = registry.get("test").await;
assert!(
result.is_some(),
"a registered matcher must be retrievable by get"
);
assert_eq!(result.unwrap().name(), "time_window");
}
#[tokio::test]
async fn test_registry_get_not_found() {
let registry = CustomMatcherRegistry::new();
let result = registry.get("nonexistent").await;
assert!(result.is_none());
}
#[tokio::test]
async fn test_registry_list_empty() {
let registry = CustomMatcherRegistry::new();
let list = registry.list().await;
assert!(list.is_empty());
}
#[test]
fn test_time_window_matcher_builder() {
let matcher = TimeWindowMatcher::builder()
.start_hour(8)
.end_hour(20)
.build();
assert_eq!(matcher.start_hour(), 8);
assert_eq!(matcher.end_hour(), 20);
}
#[test]
fn test_time_window_matcher_with_dependencies() {
let matcher = TimeWindowMatcher::with_dependencies(7, 19);
assert_eq!(matcher.start_hour(), 7);
assert_eq!(matcher.end_hour(), 19);
}
#[tokio::test]
async fn test_time_window_matcher_cross_midnight() {
let matcher = TimeWindowMatcher::new(12, 11);
let context = RequestContext::new();
let result = matcher.matches(&context).await.unwrap();
assert!(result);
}
#[test]
fn test_time_window_matcher_load_config_missing_start_hour() {
let mut matcher = TimeWindowMatcher::new(9, 18);
let config = serde_json::json!({ "end_hour": 20 });
let result = matcher.load_config(config);
assert!(result.is_err());
}
#[test]
fn test_time_window_matcher_load_config_missing_end_hour() {
let mut matcher = TimeWindowMatcher::new(9, 18);
let config = serde_json::json!({ "start_hour": 10 });
let result = matcher.load_config(config);
assert!(result.is_err());
}
#[test]
fn test_time_window_matcher_load_config_start_out_of_range() {
let mut matcher = TimeWindowMatcher::new(9, 18);
let config = serde_json::json!({
"start_hour": 25,
"end_hour": 20,
});
let result = matcher.load_config(config);
assert!(result.is_err());
}
#[test]
fn test_time_window_matcher_load_config_end_out_of_range() {
let mut matcher = TimeWindowMatcher::new(9, 18);
let config = serde_json::json!({
"start_hour": 10,
"end_hour": 25,
});
let result = matcher.load_config(config);
assert!(result.is_err());
}
#[test]
fn test_header_matcher_new_empty_name() {
let result = HeaderMatcher::new("", vec!["value".to_string()]);
assert!(result.is_err());
}
#[test]
fn test_header_matcher_new_invalid_name_chars() {
let result = HeaderMatcher::new("X-Header@Name", vec!["value".to_string()]);
assert!(result.is_err());
}
#[test]
fn test_header_matcher_new_too_many_values() {
let values: Vec<String> = (0..=MAX_ALLOWED_VALUES_COUNT)
.map(|i| format!("val_{}", i))
.collect();
let result = HeaderMatcher::new("X-Test", values);
assert!(result.is_err());
}
#[test]
fn test_header_matcher_new_value_too_long() {
let long_value = "a".repeat(MAX_HEADER_VALUE_LENGTH + 1);
let result = HeaderMatcher::new("X-Test", vec![long_value]);
assert!(result.is_err());
}
#[test]
fn test_header_matcher_builder() {
let matcher = HeaderMatcher::builder()
.header_name("X-Custom")
.add_allowed_value("val1")
.add_allowed_value("val2")
.case_sensitive(true)
.build()
.unwrap();
assert_eq!(matcher.header_name(), "x-custom");
assert_eq!(matcher.allowed_values().len(), 2);
}
#[test]
fn test_header_matcher_with_dependencies() {
let matcher =
HeaderMatcher::with_dependencies("X-Custom", vec!["value1".to_string()], true).unwrap();
assert_eq!(matcher.header_name(), "x-custom");
assert_eq!(matcher.allowed_values().len(), 1);
}
#[tokio::test]
async fn test_header_matcher_case_sensitive_match() {
let matcher = HeaderMatcher::new("X-Key", vec!["ExactMatch".to_string()])
.unwrap()
.with_case_sensitive(true);
let context = RequestContext::new().with_header("x-key", "ExactMatch");
let result = matcher.matches(&context).await.unwrap();
assert!(result);
}
#[test]
fn test_header_matcher_load_config_partial_header_name() {
let mut matcher = HeaderMatcher::new("X-Original", vec!["value".to_string()]).unwrap();
let config = serde_json::json!({ "header_name": "Authorization" });
assert!(matcher.load_config(config).is_ok());
assert_eq!(matcher.header_name(), "authorization");
assert_eq!(matcher.allowed_values().len(), 1);
}
#[test]
fn test_header_matcher_load_config_partial_values() {
let mut matcher = HeaderMatcher::new("X-Original", vec!["old_value".to_string()]).unwrap();
let config = serde_json::json!({ "allowed_values": ["new_value"] });
assert!(matcher.load_config(config).is_ok());
assert_eq!(matcher.header_name(), "x-original");
assert_eq!(matcher.allowed_values().len(), 1);
assert_eq!(matcher.allowed_values()[0], "new_value");
}
#[test]
fn test_header_matcher_load_config_partial_case_sensitive() {
let mut matcher = HeaderMatcher::new("X-Test", vec!["value".to_string()]).unwrap();
let config = serde_json::json!({ "case_sensitive": true });
assert!(matcher.load_config(config).is_ok());
assert!(matcher.case_sensitive);
}
#[test]
fn test_header_matcher_load_config_too_many_values() {
let mut matcher = HeaderMatcher::new("X-Test", vec!["value".to_string()]).unwrap();
let values: Vec<String> = (0..=MAX_ALLOWED_VALUES_COUNT)
.map(|i| format!("value_{}", i))
.collect();
let config = serde_json::json!({ "allowed_values": values });
let result = matcher.load_config(config);
assert!(result.is_err());
}
#[test]
fn test_header_matcher_load_config_non_string_value_rejected() {
let mut matcher = HeaderMatcher::new("X-Test", vec!["valid".to_string()]).unwrap();
let config = serde_json::json!({
"allowed_values": ["valid1", 123, "valid2"],
});
assert!(matcher.load_config(config).is_err());
assert_eq!(matcher.allowed_values().len(), 1);
assert_eq!(matcher.allowed_values()[0], "valid");
}
#[test]
fn test_header_matcher_builder_allowed_values() {
let values = vec!["v1".to_string(), "v2".to_string(), "v3".to_string()];
let matcher = HeaderMatcher::builder()
.header_name("X-Test")
.allowed_values(values)
.build()
.unwrap();
assert_eq!(matcher.allowed_values().len(), 3);
assert_eq!(matcher.allowed_values()[0], "v1");
assert_eq!(matcher.allowed_values()[2], "v3");
}
fn ctx_with_header(name: &str, value: &str) -> RequestContext {
RequestContext::new().with_header(name, value)
}
#[tokio::test]
async fn test_header_matcher_prefix_mode() {
let matcher =
HeaderMatcher::new("X-Tenant", vec!["team-a".to_string(), "team-b".to_string()])
.unwrap()
.with_match_mode(HeaderMatchMode::Prefix);
assert!(
matcher
.matches(&ctx_with_header("X-Tenant", "team-a-prod"))
.await
.unwrap()
);
assert!(
!matcher
.matches(&ctx_with_header("X-Tenant", "team-c-prod"))
.await
.unwrap()
);
assert!(!matcher.matches(&RequestContext::new()).await.unwrap());
}
#[tokio::test]
async fn test_header_matcher_exact_mode_rejects_superstring() {
let matcher = HeaderMatcher::new("X-Tenant", vec!["team-a".to_string()]).unwrap();
assert!(
!matcher
.matches(&ctx_with_header("X-Tenant", "team-a-prod"))
.await
.unwrap(),
"the default exact mode must not allow a superstring via a prefix hit"
);
assert!(
matcher
.matches(&ctx_with_header("X-Tenant", "team-a"))
.await
.unwrap()
);
}
#[tokio::test]
async fn test_header_matcher_prefix_mode_case_insensitive() {
let matcher = HeaderMatcher::new("X-Tenant", vec!["Team-A".to_string()])
.unwrap()
.with_match_mode(HeaderMatchMode::Prefix);
assert!(
matcher
.matches(&ctx_with_header("X-Tenant", "TEAM-A-prod"))
.await
.unwrap(),
"case-insensitive by default: prefix comparison must ignore case"
);
}
#[tokio::test]
async fn test_header_matcher_prefix_mode_case_sensitive() {
let matcher = HeaderMatcher::new("X-Tenant", vec!["Team-A".to_string()])
.unwrap()
.with_case_sensitive(true)
.with_match_mode(HeaderMatchMode::Prefix);
assert!(
!matcher
.matches(&ctx_with_header("X-Tenant", "team-a-prod"))
.await
.unwrap(),
"in case-sensitive mode the prefix must match character by character"
);
}
#[tokio::test]
async fn test_header_matcher_ascii_fast_path_semantics_equivalence() {
let matcher = HeaderMatcher::new("X-Tenant", vec!["team-a".to_string()])
.unwrap()
.with_match_mode(HeaderMatchMode::Prefix);
assert!(
matcher
.matches(&ctx_with_header("X-Tenant", "TEAM-A-prod"))
.await
.unwrap(),
"an ASCII uppercase header value must hit via the zero-allocation fast path"
);
assert!(
!matcher
.matches(&ctx_with_header("X-Tenant", "zzz-miss"))
.await
.unwrap()
);
let exact = HeaderMatcher::new("X-Tenant", vec!["team-a".to_string()]).unwrap();
assert!(
exact
.matches(&ctx_with_header("X-Tenant", "TEAM-A"))
.await
.unwrap(),
"an Exact ASCII uppercase header value must hit via the fast path"
);
assert!(
!exact
.matches(&ctx_with_header("X-Tenant", "team-a-prod"))
.await
.unwrap(),
"the Exact fast path must not allow a superstring via a prefix hit"
);
}
#[tokio::test]
async fn test_header_matcher_non_ascii_values_fall_back_to_unicode_fold() {
let matcher = HeaderMatcher::new(
"X-Tenant",
vec!["ſpecial".to_string(), "team-a".to_string()],
)
.unwrap()
.with_match_mode(HeaderMatchMode::Prefix);
assert_eq!(
matcher.values_ascii,
[false, true],
"the ASCII flag must align per value"
);
assert!(
matcher
.matches(&ctx_with_header("X-Tenant", "ſpecial-offer"))
.await
.unwrap()
);
assert!(
matcher
.matches(&ctx_with_header("X-Tenant", "TEAM-A-prod"))
.await
.unwrap()
);
let unicode_values = HeaderMatcher::new("X-Tenant", vec!["mä-team".to_string()])
.unwrap()
.with_match_mode(HeaderMatchMode::Prefix);
assert!(
unicode_values
.matches(&ctx_with_header("X-Tenant", "MÄ-TEAM-prod"))
.await
.unwrap(),
"a non-ASCII header value must fold via to_lowercase on the original path"
);
let lookalike = HeaderMatcher::new("X-Tenant", vec!["team-a".to_string()])
.unwrap()
.with_match_mode(HeaderMatchMode::Prefix);
assert!(
!lookalike
.matches(&ctx_with_header("X-Tenant", "team-ä-prod"))
.await
.unwrap(),
"a-umlaut != a: after Unicode folding it must still not match, no false pass"
);
}
#[test]
fn test_header_matcher_empty_values_rejected() {
assert!(HeaderMatcher::new("X-Tenant", vec![]).is_err());
assert!(
HeaderMatcher::builder()
.header_name("X-Tenant")
.build()
.is_err()
);
}
#[test]
fn test_header_matcher_default_mode_is_exact() {
let matcher = HeaderMatcher::new("X-Tenant", vec!["a".to_string()]).unwrap();
assert_eq!(matcher.match_mode(), HeaderMatchMode::Exact);
}
#[tokio::test]
async fn test_header_matcher_load_config_match_mode() {
let mut matcher = HeaderMatcher::new("X-Tenant", vec!["team".to_string()]).unwrap();
matcher
.load_config(serde_json::json!({"match_mode": "prefix"}))
.unwrap();
assert_eq!(matcher.match_mode(), HeaderMatchMode::Prefix);
assert!(
matcher
.matches(&ctx_with_header("X-Tenant", "team-42"))
.await
.unwrap(),
"load_config swapping in the prefix mode should take effect"
);
}
#[test]
fn test_header_matcher_load_config_invalid_match_mode() {
let mut matcher = HeaderMatcher::new("X-Tenant", vec!["a".to_string()]).unwrap();
let result = matcher.load_config(serde_json::json!({"match_mode": "fuzzy"}));
assert!(
result.is_err(),
"an illegal match_mode must error explicitly"
);
}
#[test]
fn test_header_matcher_builder_match_mode() {
let matcher = HeaderMatcher::builder()
.header_name("X-Tenant")
.add_allowed_value("team")
.match_mode(HeaderMatchMode::Prefix)
.build()
.unwrap();
assert_eq!(matcher.match_mode(), HeaderMatchMode::Prefix);
}
#[test]
fn test_method_matcher_new_normalizes_case() {
let matcher = MethodMatcher::new(vec!["get".to_string(), "Post".to_string()]).unwrap();
assert_eq!(matcher.methods(), ["GET", "POST"]);
}
#[test]
fn test_method_matcher_empty_rejected() {
assert!(
MethodMatcher::new(vec![]).is_err(),
"an empty method list must be rejected"
);
}
#[test]
fn test_method_matcher_too_many_rejected() {
let methods: Vec<String> = (0..=MAX_ALLOWED_VALUES_COUNT)
.map(|i| format!("M{i}"))
.collect();
assert!(
MethodMatcher::new(methods).is_err(),
"over-limit must be rejected"
);
}
#[tokio::test]
async fn test_method_matcher_matches() {
let matcher = MethodMatcher::new(vec!["GET".to_string(), "POST".to_string()]).unwrap();
assert!(
matcher
.matches(&RequestContext::new().with_method("GET"))
.await
.unwrap()
);
assert!(
matcher
.matches(&RequestContext::new().with_method("post"))
.await
.unwrap()
);
assert!(
!matcher
.matches(&RequestContext::new().with_method("DELETE"))
.await
.unwrap()
);
assert!(!matcher.matches(&RequestContext::new()).await.unwrap());
}
#[tokio::test]
async fn test_method_matcher_load_config() {
let mut matcher = MethodMatcher::new(vec!["GET".to_string()]).unwrap();
matcher
.load_config(serde_json::json!({"methods": ["PUT", "delete"]}))
.unwrap();
assert_eq!(matcher.methods(), ["PUT", "DELETE"]);
assert!(
matcher
.matches(&RequestContext::new().with_method("DELETE"))
.await
.unwrap()
);
assert!(
!matcher
.matches(&RequestContext::new().with_method("GET"))
.await
.unwrap(),
"load_config must replace the method list wholesale"
);
}
#[tokio::test]
async fn test_method_matcher_registry_roundtrip() {
let registry = CustomMatcherRegistry::new();
registry
.register(
"method".to_string(),
Box::new(MethodMatcher::new(vec!["GET".to_string()]).unwrap()),
)
.await
.unwrap();
assert!(registry.contains("method").await);
let ctx = RequestContext::new().with_method("GET");
assert!(registry.match_with("method", &ctx).await.unwrap());
registry.unregister("method").await.unwrap();
assert!(!registry.contains("method").await);
let result = registry.match_with("method", &ctx).await;
assert!(result.is_err(), "unregistering must error explicitly");
}
#[cfg(feature = "regex-matching")]
#[tokio::test]
async fn test_regex_path_matcher_matches() {
let matcher = RegexPathMatcher::new("^/api/v[0-9]+/users", false).unwrap();
assert!(
matcher
.matches(&RequestContext::new().with_path("/api/v2/users/42"))
.await
.unwrap()
);
assert!(
!matcher
.matches(&RequestContext::new().with_path("/web/users"))
.await
.unwrap()
);
}
#[cfg(feature = "regex-matching")]
#[tokio::test]
async fn test_regex_path_matcher_case_insensitive() {
let sensitive = RegexPathMatcher::new("^/Admin", false).unwrap();
let insensitive = RegexPathMatcher::new("^/Admin", true).unwrap();
let ctx = RequestContext::new().with_path("/admin/dashboard");
assert!(!sensitive.matches(&ctx).await.unwrap());
assert!(insensitive.matches(&ctx).await.unwrap());
assert_eq!(insensitive.pattern(), "(?i)^/Admin");
}
#[cfg(feature = "regex-matching")]
#[test]
fn test_regex_path_matcher_invalid_pattern_rejected() {
assert!(RegexPathMatcher::new("(unclosed", false).is_err());
assert!(
RegexPathMatcher::new("", false).is_err(),
"an empty pattern must be rejected"
);
}
#[cfg(feature = "regex-matching")]
#[tokio::test]
async fn test_regex_path_matcher_load_config() {
let mut matcher = RegexPathMatcher::new("^/old", false).unwrap();
matcher
.load_config(serde_json::json!({"pattern": "^/new", "case_insensitive": true}))
.unwrap();
assert_eq!(matcher.pattern(), "(?i)^/new");
assert!(
matcher
.matches(&RequestContext::new().with_path("/NEW/path"))
.await
.unwrap()
);
}
#[cfg(feature = "regex-matching")]
#[tokio::test]
async fn test_regex_path_matcher_registry_roundtrip() {
let registry = CustomMatcherRegistry::new();
registry
.register(
"api_paths".to_string(),
Box::new(RegexPathMatcher::new("^/api/", false).unwrap()),
)
.await
.unwrap();
let ctx = RequestContext::new().with_path("/api/health");
assert!(registry.match_with("api_paths", &ctx).await.unwrap());
registry.unregister("api_paths").await.unwrap();
assert!(registry.match_with("api_paths", &ctx).await.is_err());
}
#[tokio::test]
async fn test_registry_register_duplicate_rejected() {
let registry = CustomMatcherRegistry::new();
registry
.register(
"dup".to_string(),
Box::new(MethodMatcher::new(vec!["GET".to_string()]).unwrap()),
)
.await
.unwrap();
let second = registry
.register(
"dup".to_string(),
Box::new(MethodMatcher::new(vec!["POST".to_string()]).unwrap()),
)
.await;
assert!(
second.is_err(),
"duplicate-name registration must be rejected"
);
assert!(
registry
.match_with("dup", &RequestContext::new().with_method("GET"))
.await
.unwrap()
);
}
#[tokio::test]
async fn test_registry_unregister_missing_rejected() {
let registry = CustomMatcherRegistry::new();
let result = registry.unregister("nonexistent").await;
assert!(
result.is_err(),
"unregistering a non-existent matcher must error explicitly"
);
}
#[test]
fn test_header_matcher_empty_value_rejected() {
assert!(HeaderMatcher::new("X-Tenant", vec![String::new()]).is_err());
let mut matcher = HeaderMatcher::new("X-Tenant", vec!["a".to_string()]).unwrap();
assert!(
matcher
.load_config(serde_json::json!({"allowed_values": [""]}))
.is_err()
);
}
#[test]
fn test_method_matcher_invalid_entries_rejected() {
assert!(MethodMatcher::new(vec![String::new()]).is_err());
assert!(MethodMatcher::new(vec!["GET POST".to_string()]).is_err());
assert!(MethodMatcher::new(vec!["GËT".to_string()]).is_err());
assert!(MethodMatcher::new(vec!["CUSTOM-METHOD_1".to_string()]).is_ok());
let mut matcher = MethodMatcher::new(vec!["GET".to_string()]).unwrap();
assert!(
matcher
.load_config(serde_json::json!({"methods": ["GET", 42]}))
.is_err()
);
assert!(
matcher
.load_config(serde_json::json!({"methods": [""]}))
.is_err()
);
}
#[tokio::test]
async fn test_method_matcher_non_ascii_never_matches() {
let matcher = MethodMatcher::new(vec!["GET".to_string()]).unwrap();
assert!(
!matcher
.matches(&RequestContext::new().with_method("ſet"))
.await
.unwrap()
);
}
#[cfg(feature = "regex-matching")]
#[tokio::test]
async fn test_regex_path_matcher_ci_flag_independent_update() {
let mut matcher = RegexPathMatcher::new("^/Admin", false).unwrap();
matcher
.load_config(serde_json::json!({"case_insensitive": true}))
.unwrap();
assert!(matcher.case_insensitive());
assert_eq!(matcher.pattern(), "(?i)^/Admin");
assert!(
matcher
.matches(&RequestContext::new().with_path("/admin/x"))
.await
.unwrap()
);
matcher
.load_config(serde_json::json!({"case_insensitive": false}))
.unwrap();
assert_eq!(matcher.pattern(), "^/Admin");
assert!(
!matcher
.matches(&RequestContext::new().with_path("/admin/x"))
.await
.unwrap()
);
}
}