2025-03-19 00:44:27 +01:00
|
|
|
use axum::{
|
|
|
|
|
body::Body,
|
|
|
|
|
http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode},
|
|
|
|
|
middleware::Next,
|
|
|
|
|
};
|
|
|
|
|
use std::collections::hash_map::DefaultHasher;
|
|
|
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
|
use chrono::{DateTime, Utc};
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use tower::{Layer, Service};
|
|
|
|
|
use std::task::{Context, Poll};
|
|
|
|
|
use std::pin::Pin;
|
|
|
|
|
use std::future::Future;
|
|
|
|
|
use bytes::Bytes;
|
|
|
|
|
use tracing::{debug, info};
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
const MAX_CACHE_ENTRIES: usize = 1000; // Maximum number of cache entries
|
|
|
|
|
const DEFAULT_MAX_AGE: u64 = 60; // Default time-to-live in seconds
|
2025-03-19 00:44:27 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Type definitions for clarity
|
2025-03-19 00:44:27 +01:00
|
|
|
type CacheKey = String;
|
|
|
|
|
type EntityTag = String;
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// A cached value
|
2025-03-19 00:44:27 +01:00
|
|
|
#[derive(Clone)]
|
|
|
|
|
struct CacheEntry {
|
2026-02-12 09:41:25 +01:00
|
|
|
/// The ETag calculated for this value
|
2025-03-19 00:44:27 +01:00
|
|
|
etag: EntityTag,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// The serialized data in bytes
|
2025-03-19 00:44:27 +01:00
|
|
|
data: Option<Bytes>,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// The original headers
|
2025-03-19 00:44:27 +01:00
|
|
|
headers: HeaderMap,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Timestamp of when it was stored
|
2025-03-19 00:44:27 +01:00
|
|
|
timestamp: SystemTime,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Time-to-live in seconds
|
2025-03-19 00:44:27 +01:00
|
|
|
max_age: u64,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Cache for HTTP responses with ETag support
|
2025-03-19 00:44:27 +01:00
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct HttpCache {
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Cache entry storage
|
2025-03-19 00:44:27 +01:00
|
|
|
cache: Arc<Mutex<HashMap<CacheKey, CacheEntry>>>,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Default time-to-live for entries
|
2025-03-19 00:44:27 +01:00
|
|
|
default_max_age: u64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl HttpCache {
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Creates a new cache instance
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn new() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
cache: Arc::new(Mutex::new(HashMap::with_capacity(100))),
|
|
|
|
|
default_max_age: DEFAULT_MAX_AGE,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Creates a new instance with a specified time-to-live
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn with_max_age(max_age: u64) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
cache: Arc::new(Mutex::new(HashMap::with_capacity(100))),
|
|
|
|
|
default_max_age: max_age,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Gets cache statistics
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn stats(&self) -> (usize, usize) {
|
|
|
|
|
let lock = self.cache.lock().unwrap();
|
|
|
|
|
let total = lock.len();
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Count valid entries
|
2025-03-19 00:44:27 +01:00
|
|
|
let _now = SystemTime::now();
|
|
|
|
|
let valid = lock.values().filter(|entry| {
|
|
|
|
|
match entry.timestamp.elapsed() {
|
|
|
|
|
Ok(elapsed) => elapsed.as_secs() < entry.max_age,
|
|
|
|
|
Err(_) => false,
|
|
|
|
|
}
|
|
|
|
|
}).count();
|
|
|
|
|
|
|
|
|
|
(total, valid)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Cleans up expired entries
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn cleanup(&self) -> usize {
|
|
|
|
|
let mut lock = self.cache.lock().unwrap();
|
|
|
|
|
let initial_count = lock.len();
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Remove expired entries
|
2025-03-19 00:44:27 +01:00
|
|
|
let _now = SystemTime::now();
|
|
|
|
|
lock.retain(|_, entry| {
|
|
|
|
|
match entry.timestamp.elapsed() {
|
|
|
|
|
Ok(elapsed) => elapsed.as_secs() < entry.max_age,
|
|
|
|
|
Err(_) => false,
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let removed = initial_count - lock.len();
|
|
|
|
|
debug!("HttpCache cleanup: removed {} expired entries", removed);
|
|
|
|
|
|
|
|
|
|
removed
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Sets an entry in the cache
|
2025-03-19 00:44:27 +01:00
|
|
|
fn set(&self, key: &str, etag: EntityTag, data: Option<Bytes>, headers: HeaderMap, max_age: Option<u64>) {
|
|
|
|
|
let mut lock = self.cache.lock().unwrap();
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Apply eviction policy if the cache is full
|
2025-03-19 00:44:27 +01:00
|
|
|
if lock.len() >= MAX_CACHE_ENTRIES {
|
|
|
|
|
debug!("Cache full, removing oldest entries");
|
2026-02-12 09:41:25 +01:00
|
|
|
// Remove the oldest 10% of entries
|
2025-03-19 00:44:27 +01:00
|
|
|
self.evict_oldest(&mut lock, MAX_CACHE_ENTRIES / 10);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Store the new entry
|
2025-03-19 00:44:27 +01:00
|
|
|
lock.insert(key.to_string(), CacheEntry {
|
|
|
|
|
etag,
|
|
|
|
|
data,
|
|
|
|
|
headers,
|
|
|
|
|
timestamp: SystemTime::now(),
|
|
|
|
|
max_age: max_age.unwrap_or(self.default_max_age),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Removes the oldest entries from the cache
|
2025-03-19 00:44:27 +01:00
|
|
|
fn evict_oldest(&self, cache: &mut HashMap<CacheKey, CacheEntry>, count: usize) {
|
2026-02-12 09:41:25 +01:00
|
|
|
// Sort by timestamp
|
2025-03-19 00:44:27 +01:00
|
|
|
let mut entries: Vec<(CacheKey, SystemTime)> = cache
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(key, entry)| (key.clone(), entry.timestamp))
|
|
|
|
|
.collect();
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Sort by timestamp (oldest first)
|
2025-03-19 00:44:27 +01:00
|
|
|
entries.sort_by(|a, b| a.1.cmp(&b.1));
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Remove the oldest entries
|
2025-03-19 00:44:27 +01:00
|
|
|
for (key, _) in entries.iter().take(count) {
|
|
|
|
|
cache.remove(key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Gets an entry from the cache
|
2025-03-19 00:44:27 +01:00
|
|
|
fn get(&self, key: &str) -> Option<CacheEntry> {
|
|
|
|
|
let lock = self.cache.lock().unwrap();
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Look up the entry
|
2025-03-19 00:44:27 +01:00
|
|
|
if let Some(entry) = lock.get(key) {
|
2026-02-12 09:41:25 +01:00
|
|
|
// Check if it has expired
|
2025-03-19 00:44:27 +01:00
|
|
|
match entry.timestamp.elapsed() {
|
|
|
|
|
Ok(elapsed) if elapsed.as_secs() < entry.max_age => {
|
|
|
|
|
// Entry is still valid
|
|
|
|
|
return Some(entry.clone());
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
// Entry has expired
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Generates a simple ETag for a block of bytes
|
2025-03-19 00:44:27 +01:00
|
|
|
fn calculate_etag_for_bytes(&self, bytes: &[u8]) -> EntityTag {
|
2026-02-12 09:41:25 +01:00
|
|
|
// Calculate hash
|
2025-03-19 00:44:27 +01:00
|
|
|
let mut hasher = DefaultHasher::new();
|
|
|
|
|
bytes.hash(&mut hasher);
|
|
|
|
|
let hash = hasher.finish();
|
|
|
|
|
|
|
|
|
|
format!("\"{}\"", hash)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// HTTP cache middleware
|
2025-03-19 00:44:27 +01:00
|
|
|
pub async fn cache_middleware<T>(
|
|
|
|
|
cache: HttpCache,
|
|
|
|
|
cache_key: &str,
|
|
|
|
|
max_age: Option<u64>,
|
|
|
|
|
req: Request<Body>,
|
|
|
|
|
next: Next,
|
|
|
|
|
) -> Result<Response<Body>, (StatusCode, String)>
|
|
|
|
|
where
|
|
|
|
|
T: Serialize
|
|
|
|
|
{
|
2026-02-12 09:41:25 +01:00
|
|
|
// Only apply cache for GET requests
|
2025-03-19 00:44:27 +01:00
|
|
|
if req.method() != Method::GET {
|
|
|
|
|
return Ok(next.run(req).await);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Check if the response is cached
|
2025-03-19 00:44:27 +01:00
|
|
|
let if_none_match = req.headers()
|
|
|
|
|
.get("if-none-match")
|
|
|
|
|
.and_then(|v| v.to_str().ok());
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// If there is a cache entry
|
2025-03-19 00:44:27 +01:00
|
|
|
if let Some(cache_entry) = cache.get(cache_key) {
|
2026-02-12 09:41:25 +01:00
|
|
|
// Check if the client already has the updated version
|
2025-03-19 00:44:27 +01:00
|
|
|
if let Some(client_etag) = if_none_match {
|
|
|
|
|
if client_etag == cache_entry.etag {
|
2026-02-12 09:41:25 +01:00
|
|
|
// The client has the most recent version, send 304 Not Modified
|
2025-03-19 00:44:27 +01:00
|
|
|
debug!("Cache hit (304) for key: {}", cache_key);
|
|
|
|
|
return Ok(create_not_modified_response(&cache_entry));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// The client needs the updated version
|
2025-03-19 00:44:27 +01:00
|
|
|
if let Some(data) = &cache_entry.data {
|
|
|
|
|
debug!("Cache hit (200) for key: {}", cache_key);
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Create response with cached data
|
2025-03-19 00:44:27 +01:00
|
|
|
let mut response = Response::new(Body::from(data.clone()));
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Copy original headers
|
2025-03-19 00:44:27 +01:00
|
|
|
for (key, value) in &cache_entry.headers {
|
|
|
|
|
if !key.as_str().eq_ignore_ascii_case("transfer-encoding") {
|
|
|
|
|
response.headers_mut().insert(key.clone(), value.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Add cache headers
|
2025-03-19 00:44:27 +01:00
|
|
|
set_cache_headers(&mut response, &cache_entry.etag, max_age.unwrap_or(cache_entry.max_age));
|
|
|
|
|
|
|
|
|
|
return Ok(response);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Not cached or expired, continue with the middleware
|
2025-03-19 00:44:27 +01:00
|
|
|
debug!("Cache miss for key: {}", cache_key);
|
|
|
|
|
let response = next.run(req).await;
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Don't cache errors
|
2025-03-19 00:44:27 +01:00
|
|
|
if !response.status().is_success() {
|
|
|
|
|
return Ok(response);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Convert the response to calculate the ETag
|
2025-03-19 00:44:27 +01:00
|
|
|
let (parts, _body) = response.into_parts();
|
|
|
|
|
let bytes = axum::body::to_bytes(_body, 1024 * 1024 * 10).await.unwrap_or_default();
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Calculate ETag
|
2025-03-19 00:44:27 +01:00
|
|
|
let etag = cache.calculate_etag_for_bytes(&bytes);
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Save to cache
|
2025-03-19 00:44:27 +01:00
|
|
|
cache.set(
|
|
|
|
|
cache_key,
|
|
|
|
|
etag.clone(),
|
|
|
|
|
Some(bytes.clone()),
|
|
|
|
|
parts.headers.clone(),
|
|
|
|
|
max_age
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Create the response with ETag
|
2025-03-19 00:44:27 +01:00
|
|
|
let mut response = Response::from_parts(parts, Body::from(bytes));
|
|
|
|
|
set_cache_headers(&mut response, &etag, max_age.unwrap_or(cache.default_max_age));
|
|
|
|
|
|
|
|
|
|
Ok(response)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Creates a 304 Not Modified response
|
2025-03-19 00:44:27 +01:00
|
|
|
fn create_not_modified_response(entry: &CacheEntry) -> Response<Body> {
|
|
|
|
|
let mut response = Response::builder()
|
|
|
|
|
.status(StatusCode::NOT_MODIFIED)
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Copy cache headers
|
2025-03-19 00:44:27 +01:00
|
|
|
if let Some(cache_control) = entry.headers.get("cache-control") {
|
|
|
|
|
response.headers_mut().insert("cache-control", cache_control.clone());
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Add ETag
|
2025-03-19 00:44:27 +01:00
|
|
|
response.headers_mut().insert(
|
|
|
|
|
"etag",
|
|
|
|
|
HeaderValue::from_str(&entry.etag).unwrap_or(HeaderValue::from_static(""))
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
response
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Configures cache headers for a response
|
2025-03-19 00:44:27 +01:00
|
|
|
fn set_cache_headers(response: &mut Response<Body>, etag: &str, max_age: u64) {
|
2026-02-12 09:41:25 +01:00
|
|
|
// Add ETag
|
2025-03-19 00:44:27 +01:00
|
|
|
response.headers_mut().insert(
|
|
|
|
|
"etag",
|
|
|
|
|
HeaderValue::from_str(etag).unwrap_or(HeaderValue::from_static(""))
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Configure Cache-Control
|
2025-03-19 00:44:27 +01:00
|
|
|
let cache_control = format!("public, max-age={}", max_age);
|
|
|
|
|
response.headers_mut().insert(
|
|
|
|
|
"cache-control",
|
|
|
|
|
HeaderValue::from_str(&cache_control).unwrap_or(HeaderValue::from_static(""))
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Add Last-Modified header
|
2025-03-19 00:44:27 +01:00
|
|
|
let now: DateTime<Utc> = Utc::now();
|
|
|
|
|
let last_modified = now.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
|
|
|
|
|
response.headers_mut().insert(
|
|
|
|
|
"last-modified",
|
|
|
|
|
HeaderValue::from_str(&last_modified).unwrap_or(HeaderValue::from_static(""))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Layer for applying cache middleware
|
2025-03-19 00:44:27 +01:00
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct HttpCacheLayer {
|
|
|
|
|
cache: HttpCache,
|
|
|
|
|
max_age: Option<u64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl HttpCacheLayer {
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Creates a new cache layer
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn new(cache: HttpCache) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
cache,
|
|
|
|
|
max_age: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Sets the maximum time-to-live
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn with_max_age(mut self, max_age: u64) -> Self {
|
|
|
|
|
self.max_age = Some(max_age);
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<S> Layer<S> for HttpCacheLayer {
|
|
|
|
|
type Service = HttpCacheService<S>;
|
|
|
|
|
|
|
|
|
|
fn layer(&self, service: S) -> Self::Service {
|
|
|
|
|
HttpCacheService {
|
|
|
|
|
inner: service,
|
|
|
|
|
cache: self.cache.clone(),
|
|
|
|
|
max_age: self.max_age,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Service that implements cache logic
|
2025-03-19 00:44:27 +01:00
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct HttpCacheService<S> {
|
|
|
|
|
inner: S,
|
|
|
|
|
cache: HttpCache,
|
|
|
|
|
max_age: Option<u64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for HttpCacheService<S>
|
|
|
|
|
where
|
|
|
|
|
S: Service<Request<ReqBody>, Response = Response<ResBody>>,
|
|
|
|
|
S::Future: Send + 'static,
|
|
|
|
|
S::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
|
|
|
|
|
ReqBody: Send + 'static,
|
|
|
|
|
ResBody: http_body::Body + Send + 'static,
|
|
|
|
|
ResBody::Data: Send + 'static,
|
|
|
|
|
ResBody::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
|
|
|
|
|
{
|
|
|
|
|
type Response = Response<Body>;
|
|
|
|
|
type Error = Box<dyn std::error::Error + Send + Sync>;
|
|
|
|
|
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
|
|
|
|
|
|
|
|
|
|
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
|
|
|
self.inner.poll_ready(cx).map_err(|e| e.into())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
|
2026-02-12 09:41:25 +01:00
|
|
|
// Generate cache key
|
2025-03-19 00:44:27 +01:00
|
|
|
let cache_key = req.uri().path().to_string();
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Only apply cache for GET requests
|
2025-03-19 00:44:27 +01:00
|
|
|
if req.method() != Method::GET {
|
|
|
|
|
let future = self.inner.call(req);
|
|
|
|
|
return Box::pin(async move {
|
|
|
|
|
let response = future.await.map_err(|e| e.into())?;
|
2026-02-08 13:40:23 +01:00
|
|
|
Ok(response_map_body(response).await)
|
2025-03-19 00:44:27 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Get client ETag
|
2025-03-19 00:44:27 +01:00
|
|
|
let if_none_match = req.headers()
|
|
|
|
|
.get("if-none-match")
|
|
|
|
|
.and_then(|v| v.to_str().ok());
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Check if there is a cache entry
|
2025-03-19 00:44:27 +01:00
|
|
|
let cache_clone = self.cache.clone();
|
|
|
|
|
let max_age = self.max_age;
|
|
|
|
|
let entry = cache_clone.get(&cache_key);
|
|
|
|
|
|
|
|
|
|
match entry {
|
|
|
|
|
Some(cache_entry) if if_none_match == Some(&cache_entry.etag) => {
|
2026-02-12 09:41:25 +01:00
|
|
|
// The client has the correct version, send 304
|
2025-03-19 00:44:27 +01:00
|
|
|
debug!("Cache HIT (304): {}", cache_key);
|
|
|
|
|
let response = create_not_modified_response(&cache_entry);
|
|
|
|
|
return Box::pin(async move { Ok(response) });
|
|
|
|
|
},
|
|
|
|
|
Some(cache_entry) if cache_entry.data.is_some() => {
|
2026-02-12 09:41:25 +01:00
|
|
|
// The client needs the updated version
|
2025-03-19 00:44:27 +01:00
|
|
|
debug!("Cache HIT (200): {}", cache_key);
|
|
|
|
|
let mut response = Response::new(Body::from(cache_entry.data.clone().unwrap()));
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Copy original headers
|
2025-03-19 00:44:27 +01:00
|
|
|
for (key, value) in &cache_entry.headers {
|
|
|
|
|
if !key.as_str().eq_ignore_ascii_case("transfer-encoding") {
|
|
|
|
|
response.headers_mut().insert(key.clone(), value.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Add cache headers
|
2025-03-19 00:44:27 +01:00
|
|
|
set_cache_headers(&mut response, &cache_entry.etag, max_age.unwrap_or(cache_entry.max_age));
|
|
|
|
|
|
|
|
|
|
return Box::pin(async move { Ok(response) });
|
|
|
|
|
},
|
|
|
|
|
_ => {
|
2026-02-12 09:41:25 +01:00
|
|
|
// Not cached or expired
|
2025-03-19 00:44:27 +01:00
|
|
|
debug!("Cache MISS: {}", cache_key);
|
|
|
|
|
let future = self.inner.call(req);
|
|
|
|
|
let cache_clone = self.cache.clone();
|
|
|
|
|
let max_age = self.max_age;
|
|
|
|
|
let cache_key = cache_key.clone();
|
|
|
|
|
|
|
|
|
|
return Box::pin(async move {
|
|
|
|
|
let response = future.await.map_err(|e| e.into())?;
|
2026-02-08 13:40:23 +01:00
|
|
|
let response = response_map_body(response).await;
|
2025-03-19 00:44:27 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Don't cache errors
|
2025-03-19 00:44:27 +01:00
|
|
|
if !response.status().is_success() {
|
|
|
|
|
return Ok(response);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Get the body and calculate ETag
|
2025-03-19 00:44:27 +01:00
|
|
|
let (parts, body) = response.into_parts();
|
|
|
|
|
let bytes = axum::body::to_bytes(body, 1024 * 1024 * 10).await?;
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Calculate ETag
|
2025-03-19 00:44:27 +01:00
|
|
|
let etag = cache_clone.calculate_etag_for_bytes(&bytes);
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Save to cache
|
2025-03-19 00:44:27 +01:00
|
|
|
cache_clone.set(
|
|
|
|
|
&cache_key,
|
|
|
|
|
etag.clone(),
|
|
|
|
|
Some(bytes.clone()),
|
|
|
|
|
parts.headers.clone(),
|
|
|
|
|
max_age
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Create the response with ETag
|
2025-03-19 00:44:27 +01:00
|
|
|
let mut response = Response::from_parts(parts, Body::from(bytes));
|
|
|
|
|
set_cache_headers(&mut response, &etag, max_age.unwrap_or(cache_clone.default_max_age));
|
|
|
|
|
|
|
|
|
|
Ok(response)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Helper function to convert any body into Body preserving its content.
|
|
|
|
|
// Previously this function discarded the body with Body::empty(), causing
|
|
|
|
|
// data loss in non-cached responses.
|
2026-02-08 13:40:23 +01:00
|
|
|
async fn response_map_body<B>(response: Response<B>) -> Response<Body>
|
2025-03-19 00:44:27 +01:00
|
|
|
where
|
|
|
|
|
B: http_body::Body + Send + 'static,
|
|
|
|
|
B::Data: Send + 'static,
|
|
|
|
|
B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
|
|
|
|
|
{
|
2026-02-08 13:40:23 +01:00
|
|
|
use http_body_util::BodyExt;
|
|
|
|
|
|
|
|
|
|
let (parts, body) = response.into_parts();
|
|
|
|
|
|
|
|
|
|
// Collect the full body into Bytes, preserving all response data
|
|
|
|
|
let collected = body
|
|
|
|
|
.collect()
|
|
|
|
|
.await
|
|
|
|
|
.map(|c| c.to_bytes())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
Response::from_parts(parts, Body::from(collected))
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Starts a periodic cleanup task for the cache
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn start_cache_cleanup_task(cache: HttpCache) {
|
|
|
|
|
tokio::spawn(async move {
|
2026-02-12 09:41:25 +01:00
|
|
|
let mut interval = tokio::time::interval(Duration::from_secs(300)); // Every 5 minutes
|
2025-03-19 00:44:27 +01:00
|
|
|
|
|
|
|
|
loop {
|
|
|
|
|
interval.tick().await;
|
|
|
|
|
let removed = cache.cleanup();
|
|
|
|
|
let (total, valid) = cache.stats();
|
|
|
|
|
|
|
|
|
|
info!("HTTP Cache cleanup: removed {}, current: {}/{}", removed, valid, total);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
2026-02-08 13:40:23 +01:00
|
|
|
#[derive(Debug, Serialize, Deserialize, Hash)]
|
2025-03-19 00:44:27 +01:00
|
|
|
struct TestData {
|
|
|
|
|
id: u32,
|
|
|
|
|
name: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_etag_generation() {
|
|
|
|
|
let cache = HttpCache::new();
|
|
|
|
|
|
2026-02-08 13:40:23 +01:00
|
|
|
let data1 = serde_json::to_vec(&TestData { id: 1, name: "Test".to_string() }).unwrap();
|
|
|
|
|
let data2 = serde_json::to_vec(&TestData { id: 1, name: "Test".to_string() }).unwrap();
|
|
|
|
|
let data3 = serde_json::to_vec(&TestData { id: 2, name: "Test".to_string() }).unwrap();
|
2025-03-19 00:44:27 +01:00
|
|
|
|
2026-02-08 13:40:23 +01:00
|
|
|
let etag1 = cache.calculate_etag_for_bytes(&data1);
|
|
|
|
|
let etag2 = cache.calculate_etag_for_bytes(&data2);
|
|
|
|
|
let etag3 = cache.calculate_etag_for_bytes(&data3);
|
2025-03-19 00:44:27 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Same data should generate the same ETag
|
2025-03-19 00:44:27 +01:00
|
|
|
assert_eq!(etag1, etag2);
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Different data should generate different ETags
|
2025-03-19 00:44:27 +01:00
|
|
|
assert_ne!(etag1, etag3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_cache_hit_miss() {
|
|
|
|
|
let cache = HttpCache::new();
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Create test data directly as Bytes
|
2026-02-08 13:40:23 +01:00
|
|
|
let bytes1 = Bytes::from(r#"{"id":1,"name":"Test"}"#);
|
|
|
|
|
let headers1 = HeaderMap::new();
|
2025-03-19 00:44:27 +01:00
|
|
|
|
|
|
|
|
let etag1 = cache.calculate_etag_for_bytes(&bytes1);
|
2026-02-08 13:40:23 +01:00
|
|
|
cache.set("test", etag1.clone(), Some(bytes1.clone()), headers1, None);
|
2025-03-19 00:44:27 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Verify cache hit
|
2025-03-19 00:44:27 +01:00
|
|
|
let entry = cache.get("test").unwrap();
|
|
|
|
|
assert_eq!(entry.etag, etag1);
|
|
|
|
|
assert_eq!(entry.data.unwrap(), bytes1);
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Verify cache miss
|
2025-03-19 00:44:27 +01:00
|
|
|
assert!(cache.get("nonexistent").is_none());
|
|
|
|
|
}
|
|
|
|
|
}
|