use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc;
use std::time::Duration;
use apiplant_core::{App, CrudAction, RateLimitRule};
use ntex::http::header::{HeaderName, HeaderValue};
use ntex::http::Method;
use ntex::service::{Middleware, Service, ServiceCtx};
use ntex::web;
use ntex_ratelimiter::{RateLimitResult, RateLimiter, RateLimiterConfig};
use crate::functions::FunctionRegistry;
use crate::response::error;
const HEADER_LIMIT: &str = "x-ratelimit-limit";
const HEADER_REMAINING: &str = "x-ratelimit-remaining";
const HEADER_RESET: &str = "x-ratelimit-reset";
const HEADER_RETRY_AFTER: &str = "retry-after";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum RouteKey {
Resource(String, CrudAction),
Function(String),
}
pub struct RateLimitPolicy {
global: Option<Arc<RateLimiter>>,
routes: HashMap<RouteKey, Option<Arc<RateLimiter>>>,
base_path: String,
trust_proxy_headers: bool,
}
impl RateLimitPolicy {
pub fn build(app: &App, functions: &FunctionRegistry) -> RateLimitPolicy {
let config = &app.config.rate_limit;
let mut policy = RateLimitPolicy {
global: None,
routes: HashMap::new(),
base_path: app.config.server.base_path.clone(),
trust_proxy_headers: config.trust_proxy_headers,
};
if !config.enabled {
return policy;
}
let make = |rule: RateLimitRule| -> Option<Arc<RateLimiter>> {
let (requests, window_secs) = rule.limit()?;
Some(RateLimiter::with_config(RateLimiterConfig {
capacity: requests as usize,
window: window_secs,
cleanup_interval: Duration::from_secs(config.cleanup_interval_secs),
stale_threshold: Duration::from_secs(config.stale_after_secs),
}))
};
policy.global = make(config.default);
for (name, resource) in &app.resources {
for action in CrudAction::ALL {
let rule = resource.rate_limit.for_action(action);
if rule == RateLimitRule::Inherit {
continue;
}
policy
.routes
.insert(RouteKey::Resource(name.clone(), action), make(rule));
}
}
for function in functions.iter() {
let rule = function.rate_limit;
if rule == RateLimitRule::Inherit {
continue;
}
policy.routes.insert(
RouteKey::Function(function.manifest.name.to_string()),
make(rule),
);
}
policy
}
pub fn none() -> RateLimitPolicy {
RateLimitPolicy {
global: None,
routes: HashMap::new(),
base_path: String::new(),
trust_proxy_headers: false,
}
}
pub fn is_active(&self) -> bool {
self.global.is_some() || self.routes.values().any(Option::is_some)
}
pub fn overrides(&self) -> usize {
self.routes.len()
}
fn limiter(&self, path: &str, method: &Method) -> Option<&Arc<RateLimiter>> {
if self.routes.is_empty() {
return self.global.as_ref();
}
match self.key(path, method) {
Some(key) => match self.routes.get(&key) {
Some(limiter) => limiter.as_ref(),
None => self.global.as_ref(),
},
None => self.global.as_ref(),
}
}
fn key(&self, path: &str, method: &Method) -> Option<RouteKey> {
let path = path.strip_prefix(&self.base_path).unwrap_or(path);
let mut segments = path.split('/').filter(|s| !s.is_empty());
let first = segments.next()?;
if first == "functions" {
let name = segments.next()?;
return match segments.next() {
None => Some(RouteKey::Function(name.to_string())),
Some("stream") if segments.next().is_none() => {
Some(RouteKey::Function(name.to_string()))
}
_ => None,
};
}
let action = match (segments.next(), segments.next(), segments.next()) {
(None, _, _) => match *method {
Method::GET => CrudAction::List,
Method::POST => CrudAction::Create,
_ => return None,
},
(Some(_), None, _) => match *method {
Method::GET => CrudAction::Read,
Method::PATCH | Method::PUT => CrudAction::Update,
Method::DELETE => CrudAction::Delete,
_ => return None,
},
(Some(_), Some(child), None) if method == Method::GET => {
return Some(RouteKey::Resource(child.to_string(), CrudAction::List))
}
_ => return None,
};
Some(RouteKey::Resource(first.to_string(), action))
}
}
impl std::fmt::Debug for RateLimitPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RateLimitPolicy")
.field("global", &self.global.is_some())
.field("overrides", &self.routes.len())
.field("trust_proxy_headers", &self.trust_proxy_headers)
.finish()
}
}
pub struct RateLimit {
policy: Arc<RateLimitPolicy>,
}
impl RateLimit {
pub fn new(policy: Arc<RateLimitPolicy>) -> RateLimit {
RateLimit { policy }
}
}
impl<S> Middleware<S> for RateLimit {
type Service = RateLimitService<S>;
fn create(&self, service: S) -> Self::Service {
RateLimitService {
service,
policy: Arc::clone(&self.policy),
}
}
}
pub struct RateLimitService<S> {
service: S,
policy: Arc<RateLimitPolicy>,
}
impl<S, Err> Service<web::WebRequest<Err>> for RateLimitService<S>
where
S: Service<web::WebRequest<Err>, Response = web::WebResponse, Error = web::Error>,
Err: web::ErrorRenderer,
{
type Response = web::WebResponse;
type Error = web::Error;
ntex::forward_ready!(service);
async fn call(
&self,
req: web::WebRequest<Err>,
ctx: ServiceCtx<'_, Self>,
) -> Result<Self::Response, Self::Error> {
let Some(limiter) = self.policy.limiter(req.path(), req.method()) else {
return ctx.call(&self.service, req).await;
};
let result = limiter.check_rate_limit(client_ip(&req, self.policy.trust_proxy_headers));
if !result.allowed {
let mut response = error(
429,
"rate limit exceeded — too many requests, try again shortly",
);
let after = result.reset.saturating_sub(now_secs()).max(1);
set(response.headers_mut(), HEADER_RETRY_AFTER, after);
headers(response.headers_mut(), &result);
return Ok(req.into_response(response));
}
let mut response = ctx.call(&self.service, req).await?;
headers(response.headers_mut(), &result);
Ok(response)
}
}
fn client_ip<Err>(req: &web::WebRequest<Err>, trust_proxy_headers: bool) -> IpAddr {
if trust_proxy_headers {
let forwarded = req
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.or_else(|| req.headers().get("x-real-ip").and_then(|v| v.to_str().ok()));
if let Some(ip) = forwarded
.and_then(|v| v.trim().parse::<IpAddr>().ok())
.filter(|ip| !ip.is_unspecified())
{
return ip;
}
}
req.peer_addr()
.map(|peer| peer.ip())
.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn headers(map: &mut ntex::http::HeaderMap, result: &RateLimitResult) {
set(map, HEADER_LIMIT, result.limit as u64);
set(map, HEADER_REMAINING, result.remaining as u64);
set(map, HEADER_RESET, result.reset);
}
fn set(map: &mut ntex::http::HeaderMap, name: &'static str, value: u64) {
if let Ok(value) = HeaderValue::from_str(&value.to_string()) {
map.insert(HeaderName::from_static(name), value);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn policy(base_path: &str, routes: &[(RouteKey, bool)]) -> RateLimitPolicy {
RateLimitPolicy {
global: Some(RateLimiter::new(10, 60)),
routes: routes
.iter()
.map(|(key, limited)| (key.clone(), limited.then(|| RateLimiter::new(1, 60))))
.collect(),
base_path: base_path.to_string(),
trust_proxy_headers: false,
}
}
fn key(path: &str, method: Method) -> Option<RouteKey> {
policy("", &[]).key(path, &method)
}
#[ntex::test]
async fn a_path_and_a_method_name_the_action_the_router_will_run() {
use CrudAction::*;
let resource = |name: &str, action| Some(RouteKey::Resource(name.to_string(), action));
assert_eq!(key("/products", Method::GET), resource("products", List));
assert_eq!(key("/products", Method::POST), resource("products", Create));
assert_eq!(key("/products/7", Method::GET), resource("products", Read));
assert_eq!(
key("/products/7", Method::PATCH),
resource("products", Update)
);
assert_eq!(
key("/products/7", Method::PUT),
resource("products", Update)
);
assert_eq!(
key("/products/7", Method::DELETE),
resource("products", Delete)
);
assert_eq!(key("/orders/7/lines", Method::GET), resource("lines", List));
}
#[ntex::test]
async fn both_endpoints_of_a_function_share_one_key() {
let expected = Some(RouteKey::Function("summarise".to_string()));
assert_eq!(key("/functions/summarise", Method::POST), expected);
assert_eq!(key("/functions/summarise/stream", Method::POST), expected);
assert_eq!(key("/functions/summarise/stream/more", Method::POST), None);
}
#[ntex::test]
async fn the_base_path_is_stripped_before_a_path_is_matched() {
let policy = policy("/api", &[]);
assert_eq!(
policy.key("/api/products", &Method::GET),
Some(RouteKey::Resource("products".to_string(), CrudAction::List))
);
}
#[ntex::test]
async fn a_path_no_crud_route_answers_falls_to_the_global_rule() {
assert_eq!(key("/products", Method::HEAD), None);
assert_eq!(key("/", Method::GET), None);
assert_eq!(key("/a/b/c/d", Method::GET), None);
}
#[ntex::test]
async fn an_override_decides_for_its_own_endpoint_and_leaves_the_rest_global() {
let limited = RouteKey::Resource("products".to_string(), CrudAction::Create);
let exempt = RouteKey::Resource("products".to_string(), CrudAction::List);
let policy = policy("", &[(limited, true), (exempt, false)]);
let own = policy.limiter("/products", &Method::POST).unwrap();
assert_eq!(own.stats().capacity, 1);
assert!(policy.limiter("/products", &Method::GET).is_none());
assert_eq!(
policy
.limiter("/products/7", &Method::GET)
.unwrap()
.stats()
.capacity,
10
);
}
#[ntex::test]
async fn an_empty_policy_is_inactive_and_a_global_rule_makes_it_active() {
assert!(!RateLimitPolicy::none().is_active());
assert!(policy("", &[]).is_active());
let off_everywhere = RateLimitPolicy {
global: None,
routes: [(
RouteKey::Resource("products".to_string(), CrudAction::List),
None,
)]
.into_iter()
.collect(),
base_path: String::new(),
trust_proxy_headers: false,
};
assert!(!off_everywhere.is_active());
}
}