1use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub enum RateLimitStrategy {
22 #[serde(rename = "token_bucket")]
23 TokenBucket,
24 #[serde(rename = "sliding_window")]
25 SlidingWindow,
26 #[serde(rename = "fixed_window")]
27 FixedWindow,
28 #[serde(rename = "leaky_bucket")]
29 LeakyBucket,
30}
31
32impl RateLimitStrategy {
33 #[must_use]
34 pub fn as_str(&self) -> &'static str {
35 match self {
36 Self::TokenBucket => "token_bucket",
37 Self::SlidingWindow => "sliding_window",
38 Self::FixedWindow => "fixed_window",
39 Self::LeakyBucket => "leaky_bucket",
40 }
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct RateLimitPolicy {
47 pub policy_id: String,
48 pub name: String,
49 pub strategy: RateLimitStrategy,
50 pub requests_per_second: u32,
51 pub burst_size: u32,
52 pub window_size_secs: u32,
53 pub enabled: bool,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct RateLimitStatus {
59 pub client_id: String,
60 pub policy_id: String,
61 pub requests_allowed: u32,
62 pub requests_used: u32,
63 pub requests_remaining: u32,
64 pub reset_time_secs: u64,
65 pub quota_exceeded: bool,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct ApiQuota {
75 pub quota_id: String,
76 pub client_id: String,
77 pub requests_per_day: u64,
78 pub requests_used_today: u64,
79 pub scan_credits: u64,
80 pub scan_credits_used: u64,
81 pub api_calls_this_hour: u64,
82 pub last_reset_time: u64,
83}
84
85impl ApiQuota {
86 #[must_use]
87 pub fn requests_remaining(&self) -> u64 {
88 self.requests_per_day
89 .saturating_sub(self.requests_used_today)
90 }
91
92 #[must_use]
93 pub fn credits_remaining(&self) -> u64 {
94 self.scan_credits.saturating_sub(self.scan_credits_used)
95 }
96
97 #[must_use]
98 pub fn quota_exceeded(&self) -> bool {
99 self.requests_used_today >= self.requests_per_day
100 || self.scan_credits_used >= self.scan_credits
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct RouteConfig {
107 pub route_id: String,
108 pub path: String,
109 pub method: String,
110 pub rate_limit_policy_id: Option<String>,
111 pub requires_auth: bool,
112 pub timeout_secs: u32,
113 pub allowed_roles: Vec<String>,
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 fn explicit_quota() -> ApiQuota {
121 ApiQuota {
122 quota_id: "host-quota-1".to_owned(),
123 client_id: "client-1".to_owned(),
124 requests_per_day: 8,
125 requests_used_today: 3,
126 scan_credits: 5,
127 scan_credits_used: 2,
128 api_calls_this_hour: 1,
129 last_reset_time: 1_700_000_000,
130 }
131 }
132
133 #[test]
134 fn quota_math_saturates_when_host_usage_exceeds_limits() {
135 let mut quota = explicit_quota();
136 quota.requests_used_today = u64::MAX;
137 quota.scan_credits_used = u64::MAX;
138
139 assert_eq!(quota.requests_remaining(), 0);
140 assert_eq!(quota.credits_remaining(), 0);
141 assert!(quota.quota_exceeded());
142 }
143
144 #[test]
145 fn route_record_preserves_auth_and_policy_requirements() {
146 let route = RouteConfig {
147 route_id: "admin".to_owned(),
148 path: "/admin".to_owned(),
149 method: "POST".to_owned(),
150 rate_limit_policy_id: Some("strict".to_owned()),
151 requires_auth: true,
152 timeout_secs: 30,
153 allowed_roles: vec!["admin".to_owned()],
154 };
155
156 let encoded = serde_json::to_value(&route).unwrap();
157 assert_eq!(encoded["requires_auth"], true);
158 assert_eq!(encoded["rate_limit_policy_id"], "strict");
159 assert_eq!(encoded["allowed_roles"][0], "admin");
160 }
161
162 #[test]
163 fn rate_limit_status_is_scoped_to_a_policy() {
164 let status = RateLimitStatus {
165 client_id: "client-1".to_owned(),
166 policy_id: "strict".to_owned(),
167 requests_allowed: 10,
168 requests_used: 10,
169 requests_remaining: 0,
170 reset_time_secs: 60,
171 quota_exceeded: true,
172 };
173
174 assert_eq!(status.policy_id, "strict");
175 assert!(status.quota_exceeded);
176 }
177
178 #[test]
179 fn strategy_names_are_stable_data_values() {
180 assert_eq!(RateLimitStrategy::TokenBucket.as_str(), "token_bucket");
181 assert_eq!(RateLimitStrategy::SlidingWindow.as_str(), "sliding_window");
182 assert_eq!(RateLimitStrategy::FixedWindow.as_str(), "fixed_window");
183 assert_eq!(RateLimitStrategy::LeakyBucket.as_str(), "leaky_bucket");
184 }
185}