use crate::{Error, HttpRequest, HttpResponse};
use async_trait::async_trait;
use parking_lot::RwLock;
use std::collections::HashMap;
use std::fmt::Write as _;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};
pub struct ExecutionContext {
pub request: HttpRequest,
}
impl ExecutionContext {
pub fn new(request: HttpRequest) -> Self {
Self { request }
}
}
#[async_trait]
pub trait Interceptor: Send + Sync {
async fn intercept(
&self,
context: ExecutionContext,
next: Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>>,
) -> Result<HttpResponse, Error>;
}
pub struct LoggingInterceptor;
#[async_trait]
impl Interceptor for LoggingInterceptor {
async fn intercept(
&self,
context: ExecutionContext,
next: Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>>,
) -> Result<HttpResponse, Error> {
let start = std::time::Instant::now();
let method = context.request.method.clone();
let path = context.request.path.clone();
println!("→ {} {}", method, path);
let result = next.await;
let duration = start.elapsed();
match &result {
Ok(response) => {
println!(
"← {} {} - {} ({:?})",
method, path, response.status, duration
);
}
Err(e) => {
println!("← {} {} - Error: {} ({:?})", method, path, e, duration);
}
}
result
}
}
pub struct TransformInterceptor<F>
where
F: Fn(HttpResponse) -> HttpResponse + Send + Sync,
{
transform: F,
}
impl<F> TransformInterceptor<F>
where
F: Fn(HttpResponse) -> HttpResponse + Send + Sync,
{
pub fn new(transform: F) -> Self {
Self { transform }
}
}
#[async_trait]
impl<F> Interceptor for TransformInterceptor<F>
where
F: Fn(HttpResponse) -> HttpResponse + Send + Sync,
{
async fn intercept(
&self,
_context: ExecutionContext,
next: Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>>,
) -> Result<HttpResponse, Error> {
let response = next.await?;
Ok((self.transform)(response))
}
}
pub struct CacheInterceptor {
pub ttl_seconds: u64,
store: Arc<RwLock<HashMap<String, (Instant, HttpResponse)>>>,
}
impl CacheInterceptor {
pub fn new(ttl_seconds: u64) -> Self {
Self {
ttl_seconds,
store: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn len(&self) -> usize {
self.store.read().len()
}
pub fn is_empty(&self) -> bool {
self.store.read().is_empty()
}
}
fn clone_response(response: &HttpResponse) -> HttpResponse {
let mut copy = HttpResponse::from_parts(
response.status,
response.headers.to_hashmap(),
response.body_ref().to_vec(),
);
copy.cookies = response.cookies.clone();
copy
}
fn cache_key(request: &HttpRequest) -> String {
let method = request.method.as_str();
let path = request.path.as_str();
let mut capacity = method.len() + 1 + 20 + 1 + path.len() + 1;
for (k, v) in request.query_params.iter() {
capacity += 20 + 1 + k.len() + 1 + 20 + 1 + v.len() + 1;
}
let mut key = String::with_capacity(capacity);
key.push_str(method);
key.push(':');
let _ = write!(key, "{}", path.len());
key.push(':');
key.push_str(path);
if request.query_params.is_empty() {
return key;
}
let mut params: Vec<(&String, &String)> = request.query_params.iter().collect();
params.sort_by(|a, b| a.0.cmp(b.0));
key.push('?');
for (k, v) in params {
let _ = write!(key, "{}", k.len());
key.push(':');
key.push_str(k);
key.push('=');
let _ = write!(key, "{}", v.len());
key.push(':');
key.push_str(v);
key.push('&');
}
key
}
fn is_cacheable_method(method: &str) -> bool {
method.eq_ignore_ascii_case("GET") || method.eq_ignore_ascii_case("HEAD")
}
fn must_not_cache(response: &HttpResponse) -> bool {
if !response.cookies.is_empty() {
return true;
}
let mut cache_control: Option<&str> = None;
for (name, value) in response.headers.iter() {
if name.eq_ignore_ascii_case("set-cookie") {
return true;
}
if name.eq_ignore_ascii_case("vary") && !value.trim().is_empty() {
return true;
}
if name.eq_ignore_ascii_case("content-encoding") && !value.trim().is_empty() {
return true;
}
if name.eq_ignore_ascii_case("cache-control") {
cache_control = Some(value.as_str());
}
}
if let Some(cc) = cache_control {
return cc.split(',').any(|directive| {
let directive = directive.trim();
directive.eq_ignore_ascii_case("private") || directive.eq_ignore_ascii_case("no-store")
});
}
false
}
#[async_trait]
impl Interceptor for CacheInterceptor {
async fn intercept(
&self,
context: ExecutionContext,
next: Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>>,
) -> Result<HttpResponse, Error> {
let ttl = Duration::from_secs(self.ttl_seconds);
let cache_key =
is_cacheable_method(&context.request.method).then(|| cache_key(&context.request));
if let Some(key) = cache_key.as_deref() {
let store = self.store.read();
if let Some((stored_at, cached)) = store.get(key)
&& stored_at.elapsed() < ttl
{
return Ok(clone_response(cached));
}
}
let response = next.await?;
if let Some(key) = cache_key
&& self.ttl_seconds > 0
&& (200..300).contains(&response.status)
&& !must_not_cache(&response)
{
let mut store = self.store.write();
store.retain(|_, (stored_at, _)| stored_at.elapsed() < ttl);
store.insert(key, (Instant::now(), clone_response(&response)));
}
Ok(response)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_logging_interceptor_creation() {
let _interceptor = LoggingInterceptor;
}
#[test]
fn test_cache_interceptor_creation() {
let interceptor = CacheInterceptor::new(60);
assert_eq!(interceptor.ttl_seconds, 60);
}
#[test]
fn test_cache_interceptor_different_ttls() {
let i1 = CacheInterceptor::new(30);
let i2 = CacheInterceptor::new(120);
let i3 = CacheInterceptor::new(3600);
assert_eq!(i1.ttl_seconds, 30);
assert_eq!(i2.ttl_seconds, 120);
assert_eq!(i3.ttl_seconds, 3600);
}
#[test]
fn test_transform_interceptor_creation() {
let _interceptor = TransformInterceptor::new(|res| res);
}
#[test]
fn test_execution_context_creation() {
let request = crate::HttpRequest::new("GET".to_string(), "/test".to_string());
let context = ExecutionContext::new(request.clone());
assert_eq!(context.request.method, "GET");
assert_eq!(context.request.path, "/test");
}
#[test]
fn test_execution_context_with_metadata() {
let mut request = crate::HttpRequest::new("POST".to_string(), "/api/users".to_string());
request.body = vec![1, 2, 3];
let context = ExecutionContext::new(request.clone());
assert_eq!(context.request.body.len(), 3);
}
#[test]
fn test_cache_interceptor_zero_ttl() {
let interceptor = CacheInterceptor::new(0);
assert_eq!(interceptor.ttl_seconds, 0);
}
#[test]
fn test_cache_interceptor_long_ttl() {
let one_day = 86400;
let interceptor = CacheInterceptor::new(one_day);
assert_eq!(interceptor.ttl_seconds, one_day);
}
use std::sync::atomic::{AtomicUsize, Ordering};
fn counting_next(
calls: Arc<AtomicUsize>,
status: u16,
body: &'static [u8],
) -> Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>> {
Box::pin(async move {
calls.fetch_add(1, Ordering::SeqCst);
let mut resp = HttpResponse::new(status);
resp.body = body.to_vec();
Ok(resp)
})
}
#[tokio::test]
async fn test_cache_interceptor_caches_within_ttl() {
let interceptor = CacheInterceptor::new(60);
let calls = Arc::new(AtomicUsize::new(0));
let ctx = ExecutionContext::new(HttpRequest::new("GET".into(), "/cached".into()));
let first = interceptor
.intercept(ctx, counting_next(calls.clone(), 200, b"payload"))
.await
.unwrap();
assert_eq!(first.body_ref(), b"payload");
let ctx = ExecutionContext::new(HttpRequest::new("GET".into(), "/cached".into()));
let second = interceptor
.intercept(ctx, counting_next(calls.clone(), 200, b"payload"))
.await
.unwrap();
assert_eq!(second.body_ref(), b"payload");
assert_eq!(second.status, 200);
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(interceptor.len(), 1);
}
#[tokio::test]
async fn test_cache_interceptor_zero_ttl_never_caches() {
let interceptor = CacheInterceptor::new(0);
let calls = Arc::new(AtomicUsize::new(0));
for _ in 0..3 {
let ctx = ExecutionContext::new(HttpRequest::new("GET".into(), "/x".into()));
interceptor
.intercept(ctx, counting_next(calls.clone(), 200, b"body"))
.await
.unwrap();
}
assert_eq!(calls.load(Ordering::SeqCst), 3);
assert!(interceptor.is_empty());
}
#[tokio::test]
async fn test_cache_interceptor_distinct_keys() {
let interceptor = CacheInterceptor::new(60);
let calls = Arc::new(AtomicUsize::new(0));
let ctx = ExecutionContext::new(HttpRequest::new("GET".into(), "/a".into()));
interceptor
.intercept(ctx, counting_next(calls.clone(), 200, b"a"))
.await
.unwrap();
let ctx = ExecutionContext::new(HttpRequest::new("GET".into(), "/b".into()));
interceptor
.intercept(ctx, counting_next(calls.clone(), 200, b"b"))
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 2);
assert_eq!(interceptor.len(), 2);
}
#[tokio::test]
async fn test_cache_interceptor_skips_non_success() {
let interceptor = CacheInterceptor::new(60);
let calls = Arc::new(AtomicUsize::new(0));
for _ in 0..2 {
let ctx = ExecutionContext::new(HttpRequest::new("GET".into(), "/err".into()));
interceptor
.intercept(ctx, counting_next(calls.clone(), 500, b"boom"))
.await
.unwrap();
}
assert_eq!(calls.load(Ordering::SeqCst), 2);
assert!(interceptor.is_empty());
}
#[tokio::test]
async fn test_cache_interceptor_query_params_distinct() {
let interceptor = CacheInterceptor::new(60);
let calls = Arc::new(AtomicUsize::new(0));
let mut req_cats = HttpRequest::new("GET".into(), "/search".into());
req_cats.query_params.insert("q".into(), "cats".into());
let first = interceptor
.intercept(
ExecutionContext::new(req_cats),
counting_next(calls.clone(), 200, b"cats-result"),
)
.await
.unwrap();
assert_eq!(first.body_ref(), b"cats-result");
let mut req_dogs = HttpRequest::new("GET".into(), "/search".into());
req_dogs.query_params.insert("q".into(), "dogs".into());
let second = interceptor
.intercept(
ExecutionContext::new(req_dogs),
counting_next(calls.clone(), 200, b"dogs-result"),
)
.await
.unwrap();
assert_eq!(second.body_ref(), b"dogs-result");
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
fn cookie_next(
calls: Arc<AtomicUsize>,
) -> Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>> {
Box::pin(async move {
let n = calls.fetch_add(1, Ordering::SeqCst);
let mut resp = HttpResponse::ok();
resp.body = format!("user-{n}").into_bytes();
resp.cookies.push(format!("session=secret-{n}; HttpOnly"));
Ok(resp)
})
}
#[tokio::test]
async fn test_cache_interceptor_refuses_set_cookie() {
let interceptor = CacheInterceptor::new(60);
let calls = Arc::new(AtomicUsize::new(0));
let first = interceptor
.intercept(
ExecutionContext::new(HttpRequest::new("GET".into(), "/me".into())),
cookie_next(calls.clone()),
)
.await
.unwrap();
assert_eq!(first.body_ref(), b"user-0");
assert_eq!(
first.cookies,
vec!["session=secret-0; HttpOnly".to_string()]
);
let second = interceptor
.intercept(
ExecutionContext::new(HttpRequest::new("GET".into(), "/me".into())),
cookie_next(calls.clone()),
)
.await
.unwrap();
assert_eq!(second.body_ref(), b"user-1");
assert_eq!(
second.cookies,
vec!["session=secret-1; HttpOnly".to_string()]
);
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"a Set-Cookie response must be re-fetched, never cached"
);
assert!(interceptor.is_empty());
}
#[tokio::test]
async fn test_cache_interceptor_skips_unsafe_methods() {
let interceptor = CacheInterceptor::new(60);
let calls = Arc::new(AtomicUsize::new(0));
for _ in 0..2 {
let ctx = ExecutionContext::new(HttpRequest::new("POST".into(), "/submit".into()));
interceptor
.intercept(ctx, counting_next(calls.clone(), 200, b"ok"))
.await
.unwrap();
}
assert_eq!(calls.load(Ordering::SeqCst), 2);
assert!(interceptor.is_empty(), "POST responses must not be cached");
}
fn header_next(
calls: Arc<AtomicUsize>,
header_name: &'static str,
header_value: &'static str,
) -> Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>> {
Box::pin(async move {
let n = calls.fetch_add(1, Ordering::SeqCst);
let mut resp = HttpResponse::ok();
resp.body = format!("body-{n}").into_bytes();
resp.headers
.insert(header_name.to_string(), header_value.to_string());
Ok(resp)
})
}
#[tokio::test]
async fn test_cache_interceptor_refuses_lowercase_cache_control_private() {
let interceptor = CacheInterceptor::new(60);
let calls = Arc::new(AtomicUsize::new(0));
for _ in 0..2 {
let ctx = ExecutionContext::new(HttpRequest::new("GET".into(), "/private".into()));
interceptor
.intercept(ctx, header_next(calls.clone(), "cache-control", "private"))
.await
.unwrap();
}
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"a lowercased Cache-Control: private response must never be cached"
);
assert!(interceptor.is_empty());
}
#[tokio::test]
async fn test_cache_interceptor_refuses_lowercase_cache_control_no_store() {
let interceptor = CacheInterceptor::new(60);
let calls = Arc::new(AtomicUsize::new(0));
for _ in 0..2 {
let ctx = ExecutionContext::new(HttpRequest::new("GET".into(), "/no-store".into()));
interceptor
.intercept(
ctx,
header_next(calls.clone(), "cache-control", "No-Store, max-age=0"),
)
.await
.unwrap();
}
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"a lowercased Cache-Control: no-store response must never be cached"
);
assert!(interceptor.is_empty());
}
#[tokio::test]
async fn test_cache_interceptor_refuses_lowercase_set_cookie_header() {
let interceptor = CacheInterceptor::new(60);
let calls = Arc::new(AtomicUsize::new(0));
for _ in 0..2 {
let ctx = ExecutionContext::new(HttpRequest::new("GET".into(), "/lc-cookie".into()));
interceptor
.intercept(
ctx,
header_next(calls.clone(), "set-cookie", "session=abc; HttpOnly"),
)
.await
.unwrap();
}
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"a lowercased set-cookie header must never be cached"
);
assert!(interceptor.is_empty());
}
#[tokio::test]
async fn test_cache_interceptor_query_delimiter_injection_distinct() {
let interceptor = CacheInterceptor::new(60);
let calls = Arc::new(AtomicUsize::new(0));
let mut req_encoded = HttpRequest::new("GET".into(), "/inject".into());
req_encoded.query_params.insert("a".into(), "1&b=2".into());
let first = interceptor
.intercept(
ExecutionContext::new(req_encoded),
counting_next(calls.clone(), 200, b"encoded-result"),
)
.await
.unwrap();
assert_eq!(first.body_ref(), b"encoded-result");
let mut req_plain = HttpRequest::new("GET".into(), "/inject".into());
req_plain.query_params.insert("a".into(), "1".into());
req_plain.query_params.insert("b".into(), "2".into());
let second = interceptor
.intercept(
ExecutionContext::new(req_plain),
counting_next(calls.clone(), 200, b"plain-result"),
)
.await
.unwrap();
assert_eq!(second.body_ref(), b"plain-result");
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"delimiter-colliding decoded query params must not share a cache entry"
);
}
#[tokio::test]
async fn test_cache_interceptor_refuses_content_encoding() {
let interceptor = CacheInterceptor::new(60);
let calls = Arc::new(AtomicUsize::new(0));
for _ in 0..2 {
let ctx = ExecutionContext::new(HttpRequest::new("GET".into(), "/gz".into()));
interceptor
.intercept(ctx, header_next(calls.clone(), "Content-Encoding", "gzip"))
.await
.unwrap();
}
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"a Content-Encoding response must never be cached"
);
assert!(interceptor.is_empty());
}
#[tokio::test]
async fn test_cache_interceptor_refuses_vary() {
let interceptor = CacheInterceptor::new(60);
let calls = Arc::new(AtomicUsize::new(0));
for _ in 0..2 {
let ctx = ExecutionContext::new(HttpRequest::new("GET".into(), "/vary".into()));
interceptor
.intercept(ctx, header_next(calls.clone(), "Vary", "Accept-Encoding"))
.await
.unwrap();
}
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"a Vary response must never be cached"
);
assert!(interceptor.is_empty());
}
#[tokio::test]
async fn test_cache_interceptor_path_query_boundary_distinct() {
let interceptor = CacheInterceptor::new(60);
let calls = Arc::new(AtomicUsize::new(0));
let req_literal_path = HttpRequest::new("GET".into(), "/a?1:b=1:c&".into());
let first = interceptor
.intercept(
ExecutionContext::new(req_literal_path),
counting_next(calls.clone(), 200, b"path-result"),
)
.await
.unwrap();
assert_eq!(first.body_ref(), b"path-result");
let mut req_path_plus_query = HttpRequest::new("GET".into(), "/a".into());
req_path_plus_query
.query_params
.insert("b".into(), "c".into());
let second = interceptor
.intercept(
ExecutionContext::new(req_path_plus_query),
counting_next(calls.clone(), 200, b"query-result"),
)
.await
.unwrap();
assert_eq!(second.body_ref(), b"query-result");
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"a path containing a literal '?' must not collide with a distinct path+query"
);
assert_eq!(interceptor.len(), 2);
}
}