use crate::clock::{Clock, SystemClock};
use crate::config::{FlowControlConfig, LimiterConfig, LimiterTypeName, Matcher as ConfigMatcher};
use crate::decision_chain::{DecisionChain, DecisionNode};
use crate::error::LimiteronError;
use crate::i18n::t;
#[cfg(feature = "quota-control")]
use crate::limiters::QuotaLimiter;
use crate::limiters::{
ConcurrencyLimiter, FixedWindowLimiter, Limiter, ShardedSlidingWindowLimiter,
TokenBucketLimiter,
};
use crate::matchers::{
CompositeCondition, ConditionEvaluator, IpRange, LogicalOperator, MatchCondition,
Rule as MatcherRule,
};
use dashmap::DashMap;
use log::warn;
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
pub struct RuleBuilder;
impl RuleBuilder {
pub fn parse_duration(s: &str) -> Result<Duration, LimiteronError> {
crate::config::parse_window_size(s).map_err(LimiteronError::ConfigError)
}
pub fn build_rule_chains(
config: &FlowControlConfig,
) -> Result<DashMap<String, DecisionChain>, LimiteronError> {
Self::build_rule_chains_with_quota_storage(config, None, Arc::new(SystemClock))
}
pub fn build_rule_chains_with_quota_storage(
config: &FlowControlConfig,
#[cfg_attr(not(feature = "quota-control"), allow(unused_variables))] quota_storage: Option<
&Arc<dyn crate::storage::QuotaStorage>,
>,
clock: Arc<dyn Clock>,
) -> Result<DashMap<String, DecisionChain>, LimiteronError> {
let chains = DashMap::new();
for rule in &config.rules {
let mut nodes: Vec<DecisionNode> = Vec::new();
for (index, limiter_config) in rule.limiters.iter().enumerate() {
let (limiter, type_name): (Arc<dyn Limiter>, LimiterTypeName) = match limiter_config
{
LimiterConfig::TokenBucket {
capacity,
refill_rate,
} => (
Arc::new(TokenBucketLimiter::with_clock(
*capacity,
*refill_rate,
clock.clone(),
)),
LimiterTypeName::TokenBucket,
),
LimiterConfig::SlidingWindow {
window_size,
max_requests,
} => {
let duration = Self::parse_duration(window_size)?;
(
Arc::new(ShardedSlidingWindowLimiter::with_clock(
duration,
*max_requests,
clock.clone(),
)),
LimiterTypeName::SlidingWindow,
)
}
LimiterConfig::FixedWindow {
window_size,
max_requests,
} => {
let duration = Self::parse_duration(window_size)?;
(
Arc::new(FixedWindowLimiter::with_clock(
duration,
*max_requests,
clock.clone(),
)),
LimiterTypeName::FixedWindow,
)
}
LimiterConfig::Quota {
quota_type: _quota_type,
limit: _limit,
window: _window,
alert_threshold: _,
overdraft: _overdraft,
} => {
#[cfg(feature = "quota-control")]
{
let window_secs = Self::parse_duration(_window)?.as_secs().max(1);
let limit_val: u64 = *_limit;
let overdraft_percent: u8 = _overdraft
.as_ref()
.filter(|o| o.enabled)
.map(|o| {
let percent = o
.max_overdraft
.saturating_mul(100)
.checked_div(limit_val.max(1))
.unwrap_or(0);
percent.min(100) as u8
})
.unwrap_or(0);
let config = crate::quota::QuotaConfig {
quota_type: *_quota_type,
limit: limit_val,
window_size: window_secs,
allow_overdraft: _overdraft.as_ref().is_some_and(|o| o.enabled),
overdraft_limit_percent: overdraft_percent,
..crate::quota::QuotaConfig::default()
};
let quota_limiter = match quota_storage {
Some(st) => Arc::new(QuotaLimiter::with_storage(
config,
st.clone(),
rule.id.clone(),
)) as Arc<dyn Limiter>,
None => Arc::new(QuotaLimiter::new(config)) as Arc<dyn Limiter>,
};
(quota_limiter, LimiterTypeName::Quota)
}
#[cfg(not(feature = "quota-control"))]
{
warn!(
"QuotaLimiter requires 'quota-control' feature to be enabled, \
skipping Quota configuration"
);
continue;
}
}
LimiterConfig::Concurrency { max_concurrent } => {
(
Arc::new(ConcurrencyLimiter::new(*max_concurrent)),
LimiterTypeName::Concurrency,
)
}
LimiterConfig::LeakyBucket {
capacity,
leak_rate,
} => (
Arc::new(
crate::limiters::leaky_bucket::LeakyBucketLimiter::with_clock(
*capacity,
*leak_rate,
clock.clone(),
)?,
),
LimiterTypeName::LeakyBucket,
),
LimiterConfig::SlidingWindowLog {
window_size,
max_requests,
} => {
let duration = Self::parse_duration(window_size)?;
(
Arc::new(
crate::limiters::sliding_window_log::SlidingWindowLogLimiter::with_clock(
*max_requests,
duration,
clock.clone(),
)?,
),
LimiterTypeName::SlidingWindowLog,
)
}
LimiterConfig::PriorityQueue {
window_size,
total_per_window,
level_weights,
default_priority,
} => {
#[cfg(not(feature = "priority-queue"))]
{
let _ = (
window_size,
total_per_window,
level_weights,
default_priority,
);
return Err(LimiteronError::LimitError(t(
"limiter-priority-queue-feature-disabled",
&[],
)));
}
#[cfg(feature = "priority-queue")]
{
let window = Self::parse_duration(window_size)?;
let limiter =
crate::limiters::priority_queue::PriorityQueueLimiter::with_clock(
crate::limiters::priority_queue::PriorityQueueConfig {
window,
total_per_window: *total_per_window,
level_weights: level_weights.clone(),
default_priority: default_priority.unwrap_or_else(|| {
level_weights.len().saturating_sub(1)
}),
},
clock.clone(),
)?;
(Arc::new(limiter), LimiterTypeName::PriorityQueue)
}
}
LimiterConfig::AdmissionControl {
max_concurrent,
max_per_second,
} => {
#[cfg(not(feature = "admission-control"))]
{
let _ = (max_concurrent, max_per_second);
return Err(LimiteronError::LimitError(t(
"limiter-admission-control-feature-disabled",
&[],
)));
}
#[cfg(feature = "admission-control")]
{
let limiter =
crate::limiters::admission_control::AdmissionController::with_clock(
crate::limiters::admission_control::AdmissionControlConfig {
max_concurrent: *max_concurrent,
max_per_second: *max_per_second,
},
clock.clone(),
);
(Arc::new(limiter), LimiterTypeName::AdmissionControl)
}
}
LimiterConfig::Custom { name, config: _ } => {
#[cfg(feature = "custom-limiter")]
warn!(
"CustomLimiter '{}' requires registration via CustomLimiterRegistry, skipping",
name
);
#[cfg(not(feature = "custom-limiter"))]
warn!(
"CustomLimiter '{}' skipped - custom-limiter feature not enabled",
name
);
continue;
}
};
let node = DecisionNode::with_dependencies(
format!("{}_limiter_{}", rule.id, index),
format!("{} - {}", rule.name, type_name),
limiter,
100u16.saturating_sub(index as u16), );
nodes.push(node);
}
chains.insert(rule.id.clone(), DecisionChain::with_dependencies(nodes));
}
Ok(chains)
}
pub fn build_rules(config: &FlowControlConfig) -> Result<Vec<MatcherRule>, LimiteronError> {
let mut rules = Vec::new();
for rule_config in &config.rules {
let mut conditions: Vec<Box<dyn ConditionEvaluator>> = Vec::new();
for matcher in &rule_config.matchers {
if let Some(condition) = base_condition(matcher)? {
conditions.push(condition);
continue;
}
let ConfigMatcher::Custom { name, .. } = matcher else {
unreachable!("base_condition returns None only for Custom");
};
log::warn!(
"{}",
t("custom-matcher-not-integrated", &[("name", name.clone())])
);
let name = name.clone();
conditions.push(Box::new(MatchCondition::Custom(Arc::new(
move |_context| {
log::debug!("custom matcher '{}' is a placeholder, never matches", name);
false
},
))));
}
if let Some(rule) = assemble_rule(rule_config, conditions) {
rules.push(rule);
}
}
Ok(rules)
}
pub async fn build_rules_with_registry(
config: &FlowControlConfig,
registry: &crate::matchers::custom::CustomMatcherRegistry,
) -> Result<Vec<MatcherRule>, LimiteronError> {
use crate::matchers::RequestContext;
let mut rules = Vec::new();
for rule_config in &config.rules {
let mut conditions: Vec<Box<dyn ConditionEvaluator>> = Vec::new();
for matcher in &rule_config.matchers {
if let Some(condition) = base_condition(matcher)? {
conditions.push(condition);
continue;
}
let ConfigMatcher::Custom { name, .. } = matcher else {
unreachable!("base_condition returns None only for Custom");
};
if let Some(matcher) = registry.get(name).await {
log::info!(
"{}",
t(
"custom-matcher-resolved-from-registry",
&[("name", name.clone())],
)
);
let name = name.clone();
conditions.push(Box::new(MatchCondition::Custom(Arc::new(
move |context: &RequestContext| {
let fut = matcher.matches(context);
match drive_lightweight(fut) {
Ok(v) => v,
Err(e) => {
log::warn!(
"{}",
t(
"custom-matcher-eval-failed-no-match",
&[("name", name.clone()), ("error", e.to_string())],
)
);
false
}
}
},
))));
} else {
log::warn!(
"{}",
t("custom-matcher-not-registered", &[("name", name.clone())],)
);
let name = name.clone();
conditions.push(Box::new(MatchCondition::Custom(Arc::new(
move |_context: &RequestContext| {
log::debug!(
"custom matcher '{}' is a placeholder, never matches",
name
);
false
},
))));
}
}
if let Some(rule) = assemble_rule(rule_config, conditions) {
rules.push(rule);
}
}
Ok(rules)
}
}
fn base_condition(
matcher: &ConfigMatcher,
) -> Result<Option<Box<dyn ConditionEvaluator>>, LimiteronError> {
Ok(match matcher {
ConfigMatcher::User { user_ids } => Some(Box::new(MatchCondition::User(user_ids.clone()))),
ConfigMatcher::Ip { ip_ranges } => {
let ranges: Result<Vec<IpRange>, _> = ip_ranges.iter().map(|s| s.parse()).collect();
Some(Box::new(MatchCondition::Ip(ranges?)))
}
ConfigMatcher::Geo { countries } => Some(Box::new(MatchCondition::Geo(countries.clone()))),
ConfigMatcher::ApiVersion { versions } => {
Some(Box::new(MatchCondition::ApiVersion(versions.clone())))
}
ConfigMatcher::Device { device_types } => {
Some(Box::new(MatchCondition::Device(device_types.clone())))
}
ConfigMatcher::Custom { .. } => None,
})
}
fn assemble_rule(
rule_config: &crate::config::Rule,
mut conditions: Vec<Box<dyn ConditionEvaluator>>,
) -> Option<MatcherRule> {
let final_condition: Box<dyn ConditionEvaluator> = if conditions.len() == 1 {
conditions.pop().unwrap()
} else if conditions.is_empty() {
return None;
} else {
Box::new(CompositeCondition {
conditions,
operator: LogicalOperator::And,
})
};
Some(MatcherRule {
id: rule_config.id.clone(),
name: rule_config.name.clone(),
priority: rule_config.priority,
condition: final_condition,
enabled: true,
})
}
pub(crate) fn drive_lightweight<E>(fut: impl Future<Output = Result<bool, E>>) -> Result<bool, E> {
use std::pin::pin;
use std::task::{Context, Poll};
let waker = std::task::Waker::noop();
let mut cx = Context::from_waker(waker);
match pin!(fut).poll(&mut cx) {
Poll::Ready(out) => out,
Poll::Pending => {
log::error!("{}", t("custom-matcher-pending-contract-violation", &[]));
Ok(false)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ActionConfig, Matcher, QuotaType, Rule};
#[test]
fn test_parse_duration_milliseconds() {
assert_eq!(
RuleBuilder::parse_duration("100ms").unwrap(),
Duration::from_millis(100)
);
assert_eq!(
RuleBuilder::parse_duration("1ms").unwrap(),
Duration::from_millis(1)
);
}
#[test]
fn test_parse_duration_seconds() {
assert_eq!(
RuleBuilder::parse_duration("10s").unwrap(),
Duration::from_secs(10)
);
assert_eq!(
RuleBuilder::parse_duration("1s").unwrap(),
Duration::from_secs(1)
);
}
#[tokio::test]
async fn test_build_rules_with_registry_resolves_custom_matcher() {
use crate::config::{Action, ActionConfig, FlowControlConfig, GlobalConfig, Matcher, Rule};
use crate::matchers::RequestContext;
use crate::matchers::custom::CustomMatcherRegistry;
struct EnvGateMatcher;
#[async_trait::async_trait]
impl crate::matchers::custom::CustomMatcher for EnvGateMatcher {
fn name(&self) -> &str {
"env-gate"
}
async fn matches(&self, context: &RequestContext) -> Result<bool, LimiteronError> {
Ok(context
.get_header("X-Env")
.map(|v| v == "beta")
.unwrap_or(false))
}
fn load_config(&mut self, _config: serde_json::Value) -> Result<(), LimiteronError> {
Ok(())
}
}
let registry = CustomMatcherRegistry::new();
registry
.register("env-gate".to_string(), Box::new(EnvGateMatcher))
.await
.unwrap();
let config = FlowControlConfig {
version: "0.1.0".to_string(),
global: GlobalConfig::default(),
rules: vec![
Rule {
id: "rule-registered".to_string(),
name: "Registered custom".to_string(),
priority: 100,
matchers: vec![Matcher::Custom {
name: "env-gate".to_string(),
config: serde_json::json!({}),
}],
limiters: vec![],
action: ActionConfig {
on_exceed: Action::Reject,
ban: None,
},
},
Rule {
id: "rule-unregistered".to_string(),
name: "Unregistered custom".to_string(),
priority: 90,
matchers: vec![Matcher::Custom {
name: "no-such-matcher".to_string(),
config: serde_json::json!({}),
}],
limiters: vec![],
action: ActionConfig {
on_exceed: Action::Reject,
ban: None,
},
},
],
};
let rules = RuleBuilder::build_rules_with_registry(&config, ®istry)
.await
.unwrap();
assert_eq!(rules.len(), 2);
let by_id = |id: &str| {
rules
.iter()
.find(|r| r.id == id)
.unwrap_or_else(|| panic!("rule {id} missing"))
};
let beta_ctx = RequestContext::new().with_header("X-Env", "beta");
let prod_ctx = RequestContext::new().with_header("X-Env", "prod");
assert!(by_id("rule-registered").condition.evaluate(&beta_ctx));
assert!(!by_id("rule-registered").condition.evaluate(&prod_ctx));
assert!(!by_id("rule-unregistered").condition.evaluate(&beta_ctx));
struct PendingMatcher;
#[async_trait::async_trait]
impl crate::matchers::custom::CustomMatcher for PendingMatcher {
fn name(&self) -> &str {
"pending-matcher"
}
async fn matches(&self, _context: &RequestContext) -> Result<bool, LimiteronError> {
std::future::pending().await
}
fn load_config(&mut self, _config: serde_json::Value) -> Result<(), LimiteronError> {
Ok(())
}
}
registry
.register("pending-matcher".to_string(), Box::new(PendingMatcher))
.await
.unwrap();
let config2 = FlowControlConfig {
version: "0.1.0".to_string(),
global: GlobalConfig::default(),
rules: vec![Rule {
id: "rule-pending".to_string(),
name: "Pending custom".to_string(),
priority: 100,
matchers: vec![Matcher::Custom {
name: "pending-matcher".to_string(),
config: serde_json::json!({}),
}],
limiters: vec![],
action: ActionConfig {
on_exceed: Action::Reject,
ban: None,
},
}],
};
let rules3 = RuleBuilder::build_rules_with_registry(&config2, ®istry)
.await
.unwrap();
assert!(
!rules3[0].condition.evaluate(&beta_ctx),
"a Pending custom matcher must be treated as non-matching (no hang)"
);
}
#[test]
fn test_parse_duration_minutes() {
assert_eq!(
RuleBuilder::parse_duration("5m").unwrap(),
Duration::from_secs(300)
);
assert_eq!(
RuleBuilder::parse_duration("1m").unwrap(),
Duration::from_secs(60)
);
}
#[test]
fn test_parse_duration_hours() {
assert_eq!(
RuleBuilder::parse_duration("2h").unwrap(),
Duration::from_secs(7200)
);
assert_eq!(
RuleBuilder::parse_duration("1h").unwrap(),
Duration::from_secs(3600)
);
}
#[test]
fn test_parse_duration_with_whitespace() {
assert_eq!(
RuleBuilder::parse_duration(" 10s ").unwrap(),
Duration::from_secs(10)
);
}
#[test]
fn test_parse_duration_invalid_format() {
assert!(RuleBuilder::parse_duration("10").is_err());
assert!(RuleBuilder::parse_duration("10x").is_err());
assert!(RuleBuilder::parse_duration("").is_err());
}
#[test]
fn test_parse_duration_invalid_number() {
assert!(RuleBuilder::parse_duration("abs").is_err());
assert!(RuleBuilder::parse_duration("abcms").is_err());
}
#[test]
fn test_build_rules_empty_config() {
let config = FlowControlConfig::default();
let rules = RuleBuilder::build_rules(&config).unwrap();
assert!(rules.is_empty());
}
#[cfg(feature = "quota-control")]
struct MockSharedLedger {
consumed: std::sync::atomic::AtomicU64,
}
#[cfg(feature = "quota-control")]
#[async_trait::async_trait]
impl crate::storage::QuotaStorage for MockSharedLedger {
async fn get_quota(
&self,
_: &str,
_: &str,
) -> Result<Option<crate::storage::QuotaInfo>, crate::error::StorageError> {
Ok(None)
}
async fn consume(
&self,
_: &str,
_: &str,
cost: u64,
limit: u64,
_: Duration,
) -> Result<crate::error::ConsumeResult, crate::error::StorageError> {
use std::sync::atomic::Ordering;
let cur = self.consumed.fetch_add(cost, Ordering::SeqCst) + cost;
if cur <= limit {
Ok(crate::error::ConsumeResult::allowed(cur, limit))
} else {
self.consumed.fetch_sub(cost, Ordering::SeqCst);
Ok(crate::error::ConsumeResult::rejected(cur - cost, limit))
}
}
async fn reset(
&self,
_: &str,
_: &str,
_: u64,
_: Duration,
) -> Result<(), crate::error::StorageError> {
self.consumed.store(0, std::sync::atomic::Ordering::SeqCst);
Ok(())
}
}
#[cfg(feature = "quota-control")]
#[tokio::test]
async fn test_build_rule_chains_with_quota_storage() {
let quota_config = LimiterConfig::Quota {
quota_type: QuotaType::Count,
limit: 10,
window: "60s".to_string(),
alert_threshold: None,
overdraft: None,
};
let make_config = || FlowControlConfig {
rules: vec![Rule {
id: "quota-rule".to_string(),
name: "Quota Rule".to_string(),
priority: 1,
matchers: vec![Matcher::User {
user_ids: vec!["user1".to_string()],
}],
limiters: vec![quota_config.clone()],
action: ActionConfig::default(),
}],
..Default::default()
};
let ledger = Arc::new(MockSharedLedger {
consumed: std::sync::atomic::AtomicU64::new(0),
});
let chains_a = RuleBuilder::build_rule_chains_with_quota_storage(
&make_config(),
Some(&(ledger.clone() as Arc<dyn crate::storage::QuotaStorage>)),
Arc::new(SystemClock),
)
.unwrap();
let chains_b = RuleBuilder::build_rule_chains_with_quota_storage(
&make_config(),
Some(&(ledger.clone() as Arc<dyn crate::storage::QuotaStorage>)),
Arc::new(SystemClock),
)
.unwrap();
let node_a = chains_a.get("quota-rule").unwrap().nodes()[0]
.limiter
.clone();
let node_b = chains_b.get("quota-rule").unwrap().nodes()[0]
.limiter
.clone();
assert!(node_a.allow(6).await.unwrap());
assert!(
!node_b.allow(6).await.unwrap(),
"chain B should see chain A's consumption under the shared ledger"
);
let solo_chains = RuleBuilder::build_rule_chains(&make_config()).unwrap();
let solo_node = solo_chains.get("quota-rule").unwrap().nodes()[0]
.limiter
.clone();
assert!(solo_node.allow(10).await.unwrap());
}
#[test]
fn test_build_rule_chains_empty_config() {
let config = FlowControlConfig::default();
let chains = RuleBuilder::build_rule_chains(&config).unwrap();
assert!(chains.is_empty());
}
#[test]
fn test_parse_duration_valid() {
use std::time::Duration;
assert_eq!(
RuleBuilder::parse_duration("100ms").unwrap(),
Duration::from_millis(100)
);
assert_eq!(
RuleBuilder::parse_duration("10s").unwrap(),
Duration::from_secs(10)
);
assert_eq!(
RuleBuilder::parse_duration("5m").unwrap(),
Duration::from_secs(300)
);
assert_eq!(
RuleBuilder::parse_duration("2h").unwrap(),
Duration::from_secs(7200)
);
assert_eq!(
RuleBuilder::parse_duration("1d").unwrap(),
Duration::from_secs(86400)
);
}
#[test]
fn test_parse_duration_invalid() {
assert!(RuleBuilder::parse_duration("").is_err());
assert!(RuleBuilder::parse_duration("abc").is_err());
assert!(RuleBuilder::parse_duration("10x").is_err());
}
#[test]
fn test_build_rule_chains_with_single_rule() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "test-rule".to_string(),
name: "Test Rule".to_string(),
priority: 1,
matchers: vec![Matcher::User {
user_ids: vec!["user1".to_string()],
}],
limiters: vec![LimiterConfig::TokenBucket {
capacity: 100,
refill_rate: 10,
}],
action: ActionConfig::default(),
}],
..Default::default()
};
let chains = RuleBuilder::build_rule_chains(&config).unwrap();
assert_eq!(chains.len(), 1);
assert!(chains.contains_key("test-rule"));
}
#[test]
fn test_build_rule_chains_with_multiple_rules() {
let config = FlowControlConfig {
rules: vec![
Rule {
id: "rule1".to_string(),
name: "Rule 1".to_string(),
priority: 1,
matchers: vec![Matcher::User {
user_ids: vec!["user1".to_string()],
}],
limiters: vec![LimiterConfig::TokenBucket {
capacity: 100,
refill_rate: 10,
}],
action: ActionConfig::default(),
},
Rule {
id: "rule2".to_string(),
name: "Rule 2".to_string(),
priority: 2,
matchers: vec![Matcher::Ip {
ip_ranges: vec!["192.168.1.0/24".to_string()],
}],
limiters: vec![LimiterConfig::FixedWindow {
window_size: "1m".to_string(),
max_requests: 100,
}],
action: ActionConfig::default(),
},
],
..Default::default()
};
let chains = RuleBuilder::build_rule_chains(&config).unwrap();
assert_eq!(chains.len(), 2);
assert!(chains.contains_key("rule1"));
assert!(chains.contains_key("rule2"));
}
#[test]
fn test_build_rule_chains_empty_rules() {
let config = FlowControlConfig {
rules: vec![],
..Default::default()
};
let chains = RuleBuilder::build_rule_chains(&config).unwrap();
assert!(chains.is_empty());
}
#[test]
fn test_build_rules_single_user_matcher() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "user-rule".into(),
name: "User Rule".into(),
priority: 10,
matchers: vec![Matcher::User {
user_ids: vec!["user1".into(), "user2".into()],
}],
limiters: vec![LimiterConfig::TokenBucket {
capacity: 100,
refill_rate: 10,
}],
action: ActionConfig::default(),
}],
..Default::default()
};
let rules = RuleBuilder::build_rules(&config).unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].id, "user-rule");
assert_eq!(rules[0].name, "User Rule");
assert_eq!(rules[0].priority, 10);
assert!(rules[0].enabled);
}
#[test]
fn test_build_rules_single_ip_matcher() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "ip-rule".into(),
name: "IP Rule".into(),
priority: 20,
matchers: vec![Matcher::Ip {
ip_ranges: vec!["10.0.0.0/8".into()],
}],
limiters: vec![LimiterConfig::TokenBucket {
capacity: 100,
refill_rate: 10,
}],
action: ActionConfig::default(),
}],
..Default::default()
};
let rules = RuleBuilder::build_rules(&config).unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].id, "ip-rule");
}
#[test]
fn test_build_rules_invalid_ip_range() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "bad-ip".into(),
name: "Bad IP".into(),
priority: 1,
matchers: vec![Matcher::Ip {
ip_ranges: vec!["not-an-ip".into()],
}],
limiters: vec![LimiterConfig::TokenBucket {
capacity: 100,
refill_rate: 10,
}],
action: ActionConfig::default(),
}],
..Default::default()
};
assert!(RuleBuilder::build_rules(&config).is_err());
}
#[test]
fn test_build_rules_single_geo_matcher() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "geo-rule".into(),
name: "Geo Rule".into(),
priority: 30,
matchers: vec![Matcher::Geo {
countries: vec!["US".into(), "CN".into()],
}],
limiters: vec![LimiterConfig::TokenBucket {
capacity: 200,
refill_rate: 20,
}],
action: ActionConfig::default(),
}],
..Default::default()
};
let rules = RuleBuilder::build_rules(&config).unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].id, "geo-rule");
}
#[test]
fn test_build_rules_single_api_version_matcher() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "api-rule".into(),
name: "API Rule".into(),
priority: 40,
matchers: vec![Matcher::ApiVersion {
versions: vec!["v1".into(), "v2".into()],
}],
limiters: vec![LimiterConfig::TokenBucket {
capacity: 300,
refill_rate: 30,
}],
action: ActionConfig::default(),
}],
..Default::default()
};
let rules = RuleBuilder::build_rules(&config).unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].id, "api-rule");
}
#[test]
fn test_build_rules_single_device_matcher() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "device-rule".into(),
name: "Device Rule".into(),
priority: 50,
matchers: vec![Matcher::Device {
device_types: vec!["mobile".into(), "desktop".into()],
}],
limiters: vec![LimiterConfig::TokenBucket {
capacity: 400,
refill_rate: 40,
}],
action: ActionConfig::default(),
}],
..Default::default()
};
let rules = RuleBuilder::build_rules(&config).unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].id, "device-rule");
}
#[test]
fn test_build_rules_custom_matcher() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "custom-rule".into(),
name: "Custom Rule".into(),
priority: 60,
matchers: vec![Matcher::Custom {
name: "my-custom".into(),
config: serde_json::json!({"key": "value"}),
}],
limiters: vec![LimiterConfig::TokenBucket {
capacity: 500,
refill_rate: 50,
}],
action: ActionConfig::default(),
}],
..Default::default()
};
let rules = RuleBuilder::build_rules(&config).unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].id, "custom-rule");
}
#[test]
fn test_build_rules_multiple_matchers_composite() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "composite-rule".into(),
name: "Composite Rule".into(),
priority: 70,
matchers: vec![
Matcher::User {
user_ids: vec!["user1".into()],
},
Matcher::Geo {
countries: vec!["US".into()],
},
],
limiters: vec![LimiterConfig::TokenBucket {
capacity: 600,
refill_rate: 60,
}],
action: ActionConfig::default(),
}],
..Default::default()
};
let rules = RuleBuilder::build_rules(&config).unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].id, "composite-rule");
}
#[test]
fn test_build_rules_no_matchers_skipped() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "skip-rule".into(),
name: "Skip Rule".into(),
priority: 1,
matchers: vec![],
limiters: vec![LimiterConfig::TokenBucket {
capacity: 100,
refill_rate: 10,
}],
action: ActionConfig::default(),
}],
..Default::default()
};
let rules = RuleBuilder::build_rules(&config).unwrap();
assert!(rules.is_empty());
}
#[test]
fn test_build_rules_multiple_rules_various_matchers() {
let config = FlowControlConfig {
rules: vec![
Rule {
id: "rule-a".into(),
name: "Rule A".into(),
priority: 1,
matchers: vec![Matcher::User {
user_ids: vec!["user1".into()],
}],
limiters: vec![LimiterConfig::TokenBucket {
capacity: 100,
refill_rate: 10,
}],
action: ActionConfig::default(),
},
Rule {
id: "rule-b".into(),
name: "Rule B".into(),
priority: 2,
matchers: vec![Matcher::Device {
device_types: vec!["mobile".into()],
}],
limiters: vec![LimiterConfig::FixedWindow {
window_size: "1m".into(),
max_requests: 100,
}],
action: ActionConfig::default(),
},
],
..Default::default()
};
let rules = RuleBuilder::build_rules(&config).unwrap();
assert_eq!(rules.len(), 2);
assert_eq!(rules[0].id, "rule-a");
assert_eq!(rules[1].id, "rule-b");
assert_eq!(rules[0].priority, 1);
assert_eq!(rules[1].priority, 2);
}
#[test]
fn test_build_rule_chains_sliding_window() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "sliding-rule".into(),
name: "Sliding Window Rule".into(),
priority: 1,
matchers: vec![Matcher::User {
user_ids: vec!["user1".into()],
}],
limiters: vec![LimiterConfig::SlidingWindow {
window_size: "30s".into(),
max_requests: 100,
}],
action: ActionConfig::default(),
}],
..Default::default()
};
let chains = RuleBuilder::build_rule_chains(&config).unwrap();
assert_eq!(chains.len(), 1);
assert!(chains.contains_key("sliding-rule"));
let chain = chains.get("sliding-rule").unwrap();
assert_eq!(chain.node_count(), 1);
}
#[test]
fn test_build_rule_chains_concurrency() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "concurrency-rule".into(),
name: "Concurrency Rule".into(),
priority: 1,
matchers: vec![Matcher::User {
user_ids: vec!["user1".into()],
}],
limiters: vec![LimiterConfig::Concurrency { max_concurrent: 50 }],
action: ActionConfig::default(),
}],
..Default::default()
};
let chains = RuleBuilder::build_rule_chains(&config).unwrap();
assert_eq!(chains.len(), 1);
assert!(chains.contains_key("concurrency-rule"));
let chain = chains.get("concurrency-rule").unwrap();
assert_eq!(chain.node_count(), 1);
}
#[test]
fn test_build_rule_chains_quota_skipped() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "quota-rule".into(),
name: "Quota Rule".into(),
priority: 1,
matchers: vec![Matcher::User {
user_ids: vec!["user1".into()],
}],
limiters: vec![LimiterConfig::Quota {
quota_type: QuotaType::Count,
limit: 1000,
window: "1d".into(),
alert_threshold: Some(80),
overdraft: None,
}],
action: ActionConfig::default(),
}],
..Default::default()
};
let chains = RuleBuilder::build_rule_chains(&config).unwrap();
assert_eq!(chains.len(), 1);
assert!(chains.contains_key("quota-rule"));
let chain = chains.get("quota-rule").unwrap();
#[cfg(feature = "quota-control")]
assert_eq!(chain.node_count(), 1);
#[cfg(not(feature = "quota-control"))]
assert_eq!(chain.node_count(), 0);
}
#[test]
fn test_build_rule_chains_custom_skipped() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "custom-rule".into(),
name: "Custom Limiter Rule".into(),
priority: 1,
matchers: vec![Matcher::User {
user_ids: vec!["user1".into()],
}],
limiters: vec![LimiterConfig::Custom {
name: "my-custom-limiter".into(),
config: serde_json::json!({"key": "value"}),
}],
action: ActionConfig::default(),
}],
..Default::default()
};
let chains = RuleBuilder::build_rule_chains(&config).unwrap();
assert_eq!(chains.len(), 1);
assert!(chains.contains_key("custom-rule"));
let chain = chains.get("custom-rule").unwrap();
assert_eq!(chain.node_count(), 0);
}
#[test]
fn test_build_rule_chains_multiple_limiters_per_rule() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "multi-limiter".into(),
name: "Multi Limiter".into(),
priority: 1,
matchers: vec![Matcher::User {
user_ids: vec!["user1".into()],
}],
limiters: vec![
LimiterConfig::TokenBucket {
capacity: 100,
refill_rate: 10,
},
LimiterConfig::FixedWindow {
window_size: "1m".into(),
max_requests: 50,
},
],
action: ActionConfig::default(),
}],
..Default::default()
};
let chains = RuleBuilder::build_rule_chains(&config).unwrap();
assert_eq!(chains.len(), 1);
assert!(chains.contains_key("multi-limiter"));
let chain = chains.get("multi-limiter").unwrap();
assert_eq!(chain.node_count(), 2);
}
#[test]
fn test_build_rule_chains_invalid_window_size() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "bad-window".into(),
name: "Bad Window".into(),
priority: 1,
matchers: vec![Matcher::User {
user_ids: vec!["user1".into()],
}],
limiters: vec![LimiterConfig::SlidingWindow {
window_size: "invalid".into(),
max_requests: 100,
}],
action: ActionConfig::default(),
}],
..Default::default()
};
assert!(RuleBuilder::build_rule_chains(&config).is_err());
}
#[test]
fn test_build_rule_chains_all_limiter_types() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "all-types".into(),
name: "All Types".into(),
priority: 100,
matchers: vec![Matcher::User {
user_ids: vec!["user1".into()],
}],
limiters: vec![
LimiterConfig::TokenBucket {
capacity: 100,
refill_rate: 10,
},
LimiterConfig::SlidingWindow {
window_size: "30s".into(),
max_requests: 200,
},
LimiterConfig::FixedWindow {
window_size: "1m".into(),
max_requests: 50,
},
LimiterConfig::Concurrency { max_concurrent: 25 },
LimiterConfig::Quota {
quota_type: QuotaType::Count,
limit: 1000,
window: "1d".into(),
alert_threshold: None,
overdraft: None,
},
LimiterConfig::Custom {
name: "test".into(),
config: serde_json::json!({"key": "val"}),
},
],
action: ActionConfig::default(),
}],
..Default::default()
};
let chains = RuleBuilder::build_rule_chains(&config).unwrap();
assert_eq!(chains.len(), 1);
let chain = chains.get("all-types").unwrap();
#[cfg(feature = "quota-control")]
assert_eq!(chain.node_count(), 5);
#[cfg(not(feature = "quota-control"))]
assert_eq!(chain.node_count(), 4);
}
#[test]
fn test_build_rule_chains_with_fixed_window() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "fixed-rule".into(),
name: "Fixed Window Rule".into(),
priority: 1,
matchers: vec![Matcher::User {
user_ids: vec!["user1".into()],
}],
limiters: vec![LimiterConfig::FixedWindow {
window_size: "5m".into(),
max_requests: 200,
}],
action: ActionConfig::default(),
}],
..Default::default()
};
let chains = RuleBuilder::build_rule_chains(&config).unwrap();
assert_eq!(chains.len(), 1);
assert!(chains.contains_key("fixed-rule"));
let chain = chains.get("fixed-rule").unwrap();
assert_eq!(chain.node_count(), 1);
}
#[test]
fn test_build_rule_chains_token_bucket_boundary_values() {
let config = FlowControlConfig {
rules: vec![Rule {
id: "boundary".into(),
name: "Boundary".into(),
priority: 1,
matchers: vec![Matcher::User {
user_ids: vec!["user1".into()],
}],
limiters: vec![LimiterConfig::TokenBucket {
capacity: 1,
refill_rate: 1,
}],
action: ActionConfig::default(),
}],
..Default::default()
};
let chains = RuleBuilder::build_rule_chains(&config).unwrap();
assert_eq!(chains.len(), 1);
assert!(chains.contains_key("boundary"));
let chain = chains.get("boundary").unwrap();
assert_eq!(chain.node_count(), 1);
}
}