#![cfg_attr(test, allow(clippy::unwrap_used))]
#![doc(html_favicon_url = "https://salvo.rs/favicon-32x32.png")]
#![doc(html_logo_url = "https://salvo.rs/images/logo.svg")]
#![cfg_attr(docsrs, feature(doc_cfg))]
use std::borrow::Borrow;
use std::collections::{HashMap, VecDeque};
use std::error::Error as StdError;
use std::fmt::{self, Debug, Formatter};
use std::hash::Hash;
use std::sync::{Arc, Mutex, MutexGuard};
use bytes::Bytes;
use salvo_core::handler::Skipper;
use salvo_core::http::header::{AUTHORIZATION, CACHE_CONTROL, COOKIE, SET_COOKIE, VARY};
use salvo_core::http::{HeaderMap, ResBody, StatusCode};
use salvo_core::{Depot, Error, FlowCtrl, Handler, Request, Response, async_trait, cfg_feature};
use tokio::sync::Notify;
mod skipper;
pub use skipper::MethodSkipper;
cfg_feature! {
#![feature = "moka-store"]
pub mod moka_store;
pub use moka_store::{MokaStore};
}
pub trait CacheIssuer: Send + Sync + 'static {
type Key: Hash + Eq + Send + Sync + 'static;
fn issue(
&self,
req: &mut Request,
depot: &Depot,
) -> impl Future<Output = Option<Self::Key>> + Send;
}
impl<F, K> CacheIssuer for F
where
F: Fn(&mut Request, &Depot) -> Option<K> + Send + Sync + 'static,
K: Hash + Eq + Send + Sync + 'static,
{
type Key = K;
async fn issue(&self, req: &mut Request, depot: &Depot) -> Option<Self::Key> {
self(req, depot)
}
}
#[derive(Clone, Debug)]
pub struct RequestIssuer {
use_scheme: bool,
use_authority: bool,
use_path: bool,
use_query: bool,
use_method: bool,
}
impl Default for RequestIssuer {
fn default() -> Self {
Self::new()
}
}
impl RequestIssuer {
#[must_use]
pub fn new() -> Self {
Self {
use_scheme: true,
use_authority: true,
use_path: true,
use_query: true,
use_method: true,
}
}
#[must_use]
pub fn use_scheme(mut self, value: bool) -> Self {
self.use_scheme = value;
self
}
#[must_use]
pub fn use_authority(mut self, value: bool) -> Self {
self.use_authority = value;
self
}
#[must_use]
pub fn use_path(mut self, value: bool) -> Self {
self.use_path = value;
self
}
#[must_use]
pub fn use_query(mut self, value: bool) -> Self {
self.use_query = value;
self
}
#[must_use]
pub fn use_method(mut self, value: bool) -> Self {
self.use_method = value;
self
}
}
impl CacheIssuer for RequestIssuer {
type Key = String;
async fn issue(&self, req: &mut Request, _depot: &Depot) -> Option<Self::Key> {
let mut key = String::with_capacity(req.uri().path().len() + 16);
if self.use_scheme
&& let Some(scheme) = req.uri().scheme_str()
{
key.push_str(scheme);
key.push_str("://");
}
if self.use_authority
&& let Some(authority) = req.uri().authority()
{
key.push_str(authority.as_str());
}
if self.use_path {
key.push_str(req.uri().path());
}
if self.use_query
&& let Some(query) = req.uri().query()
{
key.push('?');
key.push_str(query);
}
if self.use_method {
key.push('|');
key.push_str(req.method().as_str());
}
Some(key)
}
}
pub trait CacheStore: Send + Sync + 'static {
type Error: StdError + Sync + Send + 'static;
type Key: Hash + Eq + Send + Clone + 'static;
fn load_entry<Q>(&self, key: &Q) -> impl Future<Output = Option<CachedEntry>> + Send
where
Self::Key: Borrow<Q>,
Q: Hash + Eq + Sync;
fn save_entry(
&self,
key: Self::Key,
data: CachedEntry,
) -> impl Future<Output = Result<(), Self::Error>> + Send;
}
fn mutex_lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
pub const DEFAULT_MAX_IN_FLIGHT: usize = 1024;
struct InFlight<K> {
entries: Mutex<HashMap<K, Arc<Flight>>>,
max_entries: usize,
}
impl<K> InFlight<K> {
fn new(max_entries: usize) -> Self {
Self {
entries: Mutex::new(HashMap::new()),
max_entries,
}
}
}
impl<K> Default for InFlight<K> {
fn default() -> Self {
Self::new(DEFAULT_MAX_IN_FLIGHT)
}
}
impl<K> InFlight<K>
where
K: Hash + Eq + Clone,
{
fn enter(self: &Arc<Self>, key: K) -> FlightPermit<K> {
let mut entries = mutex_lock(&self.entries);
if let Some(flight) = entries.get(&key) {
return FlightPermit::Follower(flight.clone());
}
if entries.len() >= self.max_entries {
return FlightPermit::Bypass;
}
let flight = Arc::new(Flight::default());
entries.insert(key.clone(), flight.clone());
FlightPermit::Leader(FlightGuard {
key: Some(key),
flight,
in_flight: self.clone(),
})
}
}
impl<K> InFlight<K>
where
K: Hash + Eq,
{
fn remove(&self, key: &K, flight: &Arc<Flight>) {
let mut entries = mutex_lock(&self.entries);
if entries
.get(key)
.is_some_and(|current| Arc::ptr_eq(current, flight))
{
entries.remove(key);
}
}
}
enum FlightPermit<K>
where
K: Hash + Eq,
{
Leader(FlightGuard<K>),
Follower(Arc<Flight>),
Bypass,
}
struct FlightGuard<K>
where
K: Hash + Eq,
{
key: Option<K>,
flight: Arc<Flight>,
in_flight: Arc<InFlight<K>>,
}
impl<K> FlightGuard<K>
where
K: Hash + Eq,
{
fn finish(mut self, entry: Option<CachedEntry>) {
self.complete(entry);
}
fn complete(&mut self, entry: Option<CachedEntry>) {
if let Some(key) = self.key.take() {
self.flight.finish(entry);
self.in_flight.remove(&key, &self.flight);
}
}
}
impl<K> Drop for FlightGuard<K>
where
K: Hash + Eq,
{
fn drop(&mut self) {
self.complete(None);
}
}
#[derive(Default)]
struct Flight {
state: Mutex<FlightState>,
notify: Notify,
}
#[derive(Default)]
struct FlightState {
done: bool,
entry: Option<CachedEntry>,
}
impl Flight {
async fn wait(&self) {
loop {
let notified = self.notify.notified();
if mutex_lock(&self.state).done {
return;
}
notified.await;
}
}
fn finish(&self, entry: Option<CachedEntry>) {
*mutex_lock(&self.state) = FlightState { done: true, entry };
self.notify.notify_waiters();
}
fn entry(&self) -> Option<CachedEntry> {
mutex_lock(&self.state).entry.clone()
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum CachedBody {
None,
Once(Bytes),
Chunks(VecDeque<Bytes>),
}
impl TryFrom<&ResBody> for CachedBody {
type Error = Error;
fn try_from(body: &ResBody) -> Result<Self, Self::Error> {
match body {
ResBody::None => Ok(Self::None),
ResBody::Once(bytes) => Ok(Self::Once(bytes.to_owned())),
ResBody::Chunks(chunks) => Ok(Self::Chunks(chunks.to_owned())),
_ => Err(Error::other("unsupported body type")),
}
}
}
impl From<CachedBody> for ResBody {
fn from(body: CachedBody) -> Self {
match body {
CachedBody::None => Self::None,
CachedBody::Once(bytes) => Self::Once(bytes),
CachedBody::Chunks(chunks) => Self::Chunks(chunks),
}
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct CachedEntry {
pub status: Option<StatusCode>,
pub headers: HeaderMap,
pub body: CachedBody,
}
impl CachedEntry {
pub fn new(status: Option<StatusCode>, headers: HeaderMap, body: CachedBody) -> Self {
Self {
status,
headers,
body,
}
}
pub fn status(&self) -> Option<StatusCode> {
self.status
}
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
pub fn body(&self) -> &CachedBody {
&self.body
}
}
#[non_exhaustive]
pub struct Cache<S, I>
where
S: CacheStore,
{
pub store: S,
pub issuer: I,
pub skipper: Box<dyn Skipper>,
cache_private: bool,
in_flight: Arc<InFlight<S::Key>>,
}
impl<S, I> Debug for Cache<S, I>
where
S: CacheStore + Debug,
I: Debug,
{
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("Cache")
.field("store", &self.store)
.field("issuer", &self.issuer)
.finish()
}
}
impl<S, I> Cache<S, I>
where
S: CacheStore,
{
#[inline]
#[must_use]
pub fn new(store: S, issuer: I) -> Self {
let skipper = MethodSkipper::new().skip_all().skip_get(false);
Self {
store,
issuer,
skipper: Box::new(skipper),
cache_private: false,
in_flight: Arc::new(InFlight::default()),
}
}
#[inline]
#[must_use]
pub fn skipper(mut self, skipper: impl Skipper) -> Self {
self.skipper = Box::new(skipper);
self
}
#[inline]
#[must_use]
pub fn cache_private(mut self, cache_private: bool) -> Self {
self.cache_private = cache_private;
self
}
#[inline]
#[must_use]
pub fn max_in_flight(mut self, max_in_flight: usize) -> Self {
self.in_flight = Arc::new(InFlight::new(max_in_flight));
self
}
}
async fn call_next_and_cache<S>(
store: &S,
key: S::Key,
cache_private: bool,
req: &mut Request,
depot: &mut Depot,
res: &mut Response,
ctrl: &mut FlowCtrl,
) -> Option<CachedEntry>
where
S: CacheStore,
{
let headers_before = res.headers().clone();
ctrl.call_next(req, depot, res).await;
let cached_data = cached_response(res, &headers_before, cache_private)?;
if let Err(e) = store.save_entry(key, cached_data.clone()).await {
tracing::error!(error = ?e, "cache failed");
}
Some(cached_data)
}
fn cached_response(
res: &Response,
headers_before: &HeaderMap,
cache_private: bool,
) -> Option<CachedEntry> {
if res.body.is_stream() || res.body.is_error() {
return None;
}
let effective_status = res.status_code.unwrap_or(if res.body.is_none() {
StatusCode::NOT_FOUND
} else {
StatusCode::OK
});
if !effective_status.is_success() {
return None;
}
if response_disallows_caching(res.headers()) {
return None;
}
if !cache_private && response_has_private_cache_headers(res.headers()) {
return None;
}
if headers_before
.keys()
.any(|name| !res.headers().contains_key(name))
{
return None;
}
let headers = handler_response_headers(headers_before, res.headers());
let body = match TryInto::<CachedBody>::try_into(&res.body) {
Ok(body) => body,
Err(e) => {
tracing::error!(error = ?e, "cache failed");
return None;
}
};
Some(CachedEntry::new(res.status_code, headers, body))
}
fn handler_response_headers(before: &HeaderMap, after: &HeaderMap) -> HeaderMap {
let mut headers = HeaderMap::new();
for name in after.keys() {
let after_values = after.get_all(name).iter().collect::<Vec<_>>();
let before_values = before.get_all(name).iter().collect::<Vec<_>>();
if after_values != before_values {
for value in after_values {
headers.append(name.clone(), value.clone());
}
}
}
headers
}
fn request_has_private_cache_headers(req: &Request) -> bool {
req.headers().contains_key(AUTHORIZATION) || req.headers().contains_key(COOKIE)
}
fn response_has_private_cache_headers(headers: &HeaderMap) -> bool {
headers.contains_key(SET_COOKIE) || cache_control_contains(headers, "private")
}
fn response_disallows_caching(headers: &HeaderMap) -> bool {
cache_control_contains(headers, "no-store")
|| cache_control_contains(headers, "no-cache")
|| headers.contains_key(VARY)
}
fn cache_control_contains(headers: &HeaderMap, directive: &str) -> bool {
headers.get_all(CACHE_CONTROL).iter().any(|value| {
value.to_str().ok().is_some_and(|value| {
value.split(',').any(|part| {
let part = part.trim();
part.eq_ignore_ascii_case(directive)
|| part
.split_once('=')
.is_some_and(|(name, _)| name.trim().eq_ignore_ascii_case(directive))
})
})
})
}
#[async_trait]
impl<S, I> Handler for Cache<S, I>
where
S: CacheStore<Key = I::Key>,
I: CacheIssuer,
I::Key: Clone,
{
async fn handle(
&self,
req: &mut Request,
depot: &mut Depot,
res: &mut Response,
ctrl: &mut FlowCtrl,
) {
if self.skipper.skipped(req, depot)
|| (!self.cache_private && request_has_private_cache_headers(req))
{
ctrl.call_next(req, depot, res).await;
return;
}
let Some(key) = self.issuer.issue(req, depot).await else {
ctrl.call_next(req, depot, res).await;
return;
};
let Some(cache) = self.store.load_entry(&key).await else {
match self.in_flight.enter(key.clone()) {
FlightPermit::Leader(guard) => {
let cached_data = call_next_and_cache(
&self.store,
key,
self.cache_private,
req,
depot,
res,
ctrl,
)
.await;
guard.finish(cached_data);
}
FlightPermit::Follower(flight) => {
flight.wait().await;
if let Some(cache) = flight.entry() {
respond_from_cache(res, cache);
ctrl.skip_rest();
} else {
call_next_and_cache(
&self.store,
key,
self.cache_private,
req,
depot,
res,
ctrl,
)
.await;
}
}
FlightPermit::Bypass => {
call_next_and_cache(
&self.store,
key,
self.cache_private,
req,
depot,
res,
ctrl,
)
.await;
}
}
return;
};
respond_from_cache(res, cache);
ctrl.skip_rest();
}
}
fn respond_from_cache(res: &mut Response, cache: CachedEntry) {
let CachedEntry {
status,
headers,
body,
} = cache;
if let Some(status) = status {
res.status_code(status);
}
let res_headers = res.headers_mut();
for name in headers.keys() {
res_headers.remove(name);
for value in headers.get_all(name) {
res_headers.append(name.clone(), value.clone());
}
}
*res.body_mut() = body.into();
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use bytes::Bytes;
use salvo_core::http::HeaderMap;
use salvo_core::prelude::*;
use salvo_core::test::{ResponseExt, TestClient};
use time::OffsetDateTime;
use super::*;
#[handler]
async fn cached() -> String {
format!(
"Hello World, my birth time is {}",
OffsetDateTime::now_utc()
)
}
#[derive(Debug)]
struct SlowCached {
calls: Arc<AtomicUsize>,
}
#[async_trait]
impl Handler for SlowCached {
async fn handle(
&self,
_req: &mut Request,
_depot: &mut Depot,
res: &mut Response,
_ctrl: &mut FlowCtrl,
) {
let call = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
res.render(format!("backend call {call}"));
}
}
#[tokio::test]
async fn test_cache() {
let cache = Cache::new(
MokaStore::builder()
.time_to_live(std::time::Duration::from_secs(5))
.build(),
RequestIssuer::default(),
);
let router = Router::new().hoop(cache).goal(cached);
let service = Service::new(router);
let mut res = TestClient::get("http://127.0.0.1:5801")
.send(&service)
.await;
assert_eq!(res.status_code.unwrap(), StatusCode::OK);
let content0 = res.take_string().await.unwrap();
let mut res = TestClient::get("http://127.0.0.1:5801")
.send(&service)
.await;
assert_eq!(res.status_code.unwrap(), StatusCode::OK);
let content1 = res.take_string().await.unwrap();
assert_eq!(content0, content1);
tokio::time::sleep(tokio::time::Duration::from_secs(6)).await;
let mut res = TestClient::post("http://127.0.0.1:5801")
.send(&service)
.await;
let content2 = res.take_string().await.unwrap();
assert_ne!(content0, content2);
}
#[handler]
async fn server_error(res: &mut Response) {
res.status_code(StatusCode::INTERNAL_SERVER_ERROR);
res.render(format!("error at {}", OffsetDateTime::now_utc()));
}
#[tokio::test]
async fn test_cache_skips_non_success_status() {
let cache = Cache::new(
MokaStore::builder()
.time_to_live(std::time::Duration::from_secs(5))
.build(),
RequestIssuer::default(),
);
let router = Router::new().hoop(cache).goal(server_error);
let service = Service::new(router);
let mut res = TestClient::get("http://127.0.0.1:5802")
.send(&service)
.await;
assert_eq!(res.status_code.unwrap(), StatusCode::INTERNAL_SERVER_ERROR);
let content0 = res.take_string().await.unwrap();
let mut res = TestClient::get("http://127.0.0.1:5802")
.send(&service)
.await;
let content1 = res.take_string().await.unwrap();
assert_ne!(content0, content1);
}
#[tokio::test]
async fn test_cache_issuer_none_runs_handler() {
let cache = Cache::new(
MokaStore::builder()
.time_to_live(std::time::Duration::from_secs(5))
.build(),
|_req: &mut Request, _depot: &Depot| Option::<String>::None,
);
let router = Router::new().hoop(cache).goal(cached);
let service = Service::new(router);
let mut res = TestClient::get("http://127.0.0.1:5803")
.send(&service)
.await;
assert_eq!(res.status_code.unwrap(), StatusCode::OK);
let body = res.take_string().await.unwrap();
assert!(body.contains("Hello World"));
}
#[test]
fn test_cached_response_skips_empty_unmatched_404() {
let res = Response::new();
assert!(res.status_code.is_none() && res.body.is_none());
assert!(cached_response(&res, &HeaderMap::new(), false).is_none());
}
#[test]
fn test_cached_response_caches_default_success() {
let mut res = Response::new();
res.render("ok");
assert!(res.status_code.is_none());
assert!(cached_response(&res, &HeaderMap::new(), false).is_some());
}
#[test]
fn test_request_issuer_new() {
let issuer = RequestIssuer::new();
assert!(issuer.use_scheme);
assert!(issuer.use_authority);
assert!(issuer.use_path);
assert!(issuer.use_query);
assert!(issuer.use_method);
}
#[test]
fn test_request_issuer_default() {
let issuer = RequestIssuer::default();
assert!(issuer.use_scheme);
assert!(issuer.use_authority);
assert!(issuer.use_path);
assert!(issuer.use_query);
assert!(issuer.use_method);
}
#[test]
fn test_request_issuer_use_scheme() {
let issuer = RequestIssuer::new().use_scheme(false);
assert!(!issuer.use_scheme);
assert!(issuer.use_authority);
}
#[test]
fn test_request_issuer_use_authority() {
let issuer = RequestIssuer::new().use_authority(false);
assert!(issuer.use_scheme);
assert!(!issuer.use_authority);
}
#[test]
fn test_request_issuer_use_path() {
let issuer = RequestIssuer::new().use_path(false);
assert!(!issuer.use_path);
}
#[test]
fn test_request_issuer_use_query() {
let issuer = RequestIssuer::new().use_query(false);
assert!(!issuer.use_query);
}
#[test]
fn test_request_issuer_use_method() {
let issuer = RequestIssuer::new().use_method(false);
assert!(!issuer.use_method);
}
#[test]
fn test_request_issuer_chain() {
let issuer = RequestIssuer::new()
.use_scheme(false)
.use_authority(false)
.use_path(true)
.use_query(false)
.use_method(true);
assert!(!issuer.use_scheme);
assert!(!issuer.use_authority);
assert!(issuer.use_path);
assert!(!issuer.use_query);
assert!(issuer.use_method);
}
#[test]
fn test_request_issuer_debug() {
let issuer = RequestIssuer::new();
let debug_str = format!("{issuer:?}");
assert!(debug_str.contains("RequestIssuer"));
assert!(debug_str.contains("use_scheme"));
}
#[test]
fn test_request_issuer_clone() {
let issuer = RequestIssuer::new().use_scheme(false);
let cloned = issuer.clone();
assert_eq!(issuer.use_scheme, cloned.use_scheme);
assert_eq!(issuer.use_authority, cloned.use_authority);
}
#[test]
fn test_cached_body_none() {
let body = CachedBody::None;
assert_eq!(body, CachedBody::None);
}
#[test]
fn test_cached_body_once() {
let bytes = Bytes::from("test data");
let body = CachedBody::Once(bytes.clone());
assert_eq!(body, CachedBody::Once(bytes));
}
#[test]
fn test_cached_body_chunks() {
let mut chunks = VecDeque::new();
chunks.push_back(Bytes::from("chunk1"));
chunks.push_back(Bytes::from("chunk2"));
let body = CachedBody::Chunks(chunks.clone());
assert_eq!(body, CachedBody::Chunks(chunks));
}
#[test]
fn test_cached_body_try_from_res_body_none() {
let res_body = ResBody::None;
let result: Result<CachedBody, _> = (&res_body).try_into();
assert_eq!(result.unwrap(), CachedBody::None);
}
#[test]
fn test_cached_body_try_from_res_body_once() {
let bytes = Bytes::from("test");
let res_body = ResBody::Once(bytes.clone());
let result: Result<CachedBody, _> = (&res_body).try_into();
assert_eq!(result.unwrap(), CachedBody::Once(bytes));
}
#[test]
fn test_cached_body_try_from_res_body_chunks() {
let mut chunks = VecDeque::new();
chunks.push_back(Bytes::from("chunk1"));
chunks.push_back(Bytes::from("chunk2"));
let res_body = ResBody::Chunks(chunks.clone());
let result: Result<CachedBody, _> = (&res_body).try_into();
assert_eq!(result.unwrap(), CachedBody::Chunks(chunks));
}
#[test]
fn test_cached_body_into_res_body_none() {
let cb = CachedBody::None;
let res_body: ResBody = cb.into();
assert!(matches!(res_body, ResBody::None));
}
#[test]
fn test_cached_body_into_res_body_once() {
let bytes = Bytes::from("test");
let cb = CachedBody::Once(bytes.clone());
let res_body: ResBody = cb.into();
assert!(matches!(res_body, ResBody::Once(b) if b == bytes));
}
#[test]
fn test_cached_body_into_res_body_chunks() {
let mut chunks = VecDeque::new();
chunks.push_back(Bytes::from("chunk1"));
let cb = CachedBody::Chunks(chunks);
let res_body: ResBody = cb.into();
assert!(matches!(res_body, ResBody::Chunks(_)));
}
#[test]
fn test_cached_body_debug() {
let body = CachedBody::None;
let debug_str = format!("{body:?}");
assert!(debug_str.contains("None"));
let body = CachedBody::Once(Bytes::from("test"));
let debug_str = format!("{body:?}");
assert!(debug_str.contains("Once"));
}
#[test]
fn test_cached_body_clone() {
let body = CachedBody::Once(Bytes::from("test"));
let cloned = body.clone();
assert_eq!(body, cloned);
}
#[test]
fn test_cached_entry_new() {
let entry = CachedEntry::new(Some(StatusCode::OK), HeaderMap::new(), CachedBody::None);
assert_eq!(entry.status, Some(StatusCode::OK));
assert!(entry.headers.is_empty());
assert_eq!(entry.body, CachedBody::None);
}
#[test]
fn test_cached_entry_status() {
let entry = CachedEntry::new(
Some(StatusCode::NOT_FOUND),
HeaderMap::new(),
CachedBody::None,
);
assert_eq!(entry.status(), Some(StatusCode::NOT_FOUND));
}
#[test]
fn test_cached_entry_status_none() {
let entry = CachedEntry::new(None, HeaderMap::new(), CachedBody::None);
assert_eq!(entry.status(), None);
}
#[test]
fn test_cached_entry_headers() {
let mut headers = HeaderMap::new();
headers.insert("Content-Type", "application/json".parse().unwrap());
let entry = CachedEntry::new(Some(StatusCode::OK), headers.clone(), CachedBody::None);
assert_eq!(entry.headers().len(), 1);
assert!(entry.headers().contains_key("Content-Type"));
}
#[test]
fn test_cached_entry_body() {
let body = CachedBody::Once(Bytes::from("test body"));
let entry = CachedEntry::new(Some(StatusCode::OK), HeaderMap::new(), body.clone());
assert_eq!(entry.body(), &body);
}
#[test]
fn test_cached_entry_debug() {
let entry = CachedEntry::new(Some(StatusCode::OK), HeaderMap::new(), CachedBody::None);
let debug_str = format!("{entry:?}");
assert!(debug_str.contains("CachedEntry"));
assert!(debug_str.contains("status"));
}
#[test]
fn handler_response_headers_excludes_untouched_outer_headers() {
let mut before = HeaderMap::new();
before.insert("x-request-id", "req-1".parse().unwrap());
let mut after = before.clone();
after.insert("content-type", "text/plain".parse().unwrap());
let stored = handler_response_headers(&before, &after);
assert!(!stored.contains_key("x-request-id"));
assert_eq!(stored.get("content-type").unwrap(), "text/plain");
}
#[test]
fn cached_response_skips_when_handler_removes_outer_header() {
let mut before = HeaderMap::new();
before.insert("x-default", "from-outer".parse().unwrap());
let mut res = Response::new();
res.body(ResBody::Once(Bytes::from_static(b"cached")));
assert!(cached_response(&res, &before, false).is_none());
assert!(cached_response(&res, &before, true).is_none());
assert!(cached_response(&res, &HeaderMap::new(), false).is_some());
}
#[test]
fn handler_response_headers_includes_handler_overrides() {
let mut before = HeaderMap::new();
before.insert(
"access-control-allow-origin",
"https://default.example".parse().unwrap(),
);
let mut after = HeaderMap::new();
after.insert(
"access-control-allow-origin",
"https://handler.example".parse().unwrap(),
);
let stored = handler_response_headers(&before, &after);
assert_eq!(
stored.get("access-control-allow-origin").unwrap(),
"https://handler.example"
);
}
#[test]
fn respond_from_cache_overrides_handler_headers_but_keeps_outer() {
let mut entry_headers = HeaderMap::new();
entry_headers.insert(
"access-control-allow-origin",
"https://handler.example".parse().unwrap(),
);
entry_headers.insert("content-type", "text/plain".parse().unwrap());
let entry = CachedEntry::new(
Some(StatusCode::OK),
entry_headers,
CachedBody::Once(Bytes::from_static(b"cached")),
);
let mut res = Response::new();
res.headers_mut()
.insert("x-request-id", "fresh-for-this-request".parse().unwrap());
res.headers_mut().insert(
"access-control-allow-origin",
"https://default.example".parse().unwrap(),
);
respond_from_cache(&mut res, entry);
assert_eq!(
res.headers().get("x-request-id").unwrap(),
"fresh-for-this-request"
);
assert_eq!(
res.headers().get("access-control-allow-origin").unwrap(),
"https://handler.example"
);
assert_eq!(res.headers().get("content-type").unwrap(), "text/plain");
}
#[test]
fn respond_from_cache_restores_multi_valued_headers() {
let mut cached_headers = HeaderMap::new();
cached_headers.append(SET_COOKIE, "a=1".parse().unwrap());
cached_headers.append(SET_COOKIE, "b=2".parse().unwrap());
let entry = CachedEntry::new(Some(StatusCode::OK), cached_headers, CachedBody::None);
let mut res = Response::new();
respond_from_cache(&mut res, entry);
let cookies: Vec<_> = res
.headers()
.get_all(SET_COOKIE)
.iter()
.map(|v| v.to_str().unwrap().to_owned())
.collect();
assert_eq!(cookies, vec!["a=1", "b=2"]);
}
#[test]
fn test_cached_entry_clone() {
let entry = CachedEntry::new(
Some(StatusCode::OK),
HeaderMap::new(),
CachedBody::Once(Bytes::from("test")),
);
let cloned = entry.clone();
assert_eq!(entry.status, cloned.status);
assert_eq!(entry.body, cloned.body);
}
#[test]
fn test_cache_new() {
let cache = Cache::new(MokaStore::<String>::new(100), RequestIssuer::default());
assert!(format!("{cache:?}").contains("Cache"));
}
#[test]
fn in_flight_limit_bypasses_new_keys_when_full() {
let in_flight = Arc::new(InFlight::new(1));
let FlightPermit::Leader(first) = in_flight.enter("a") else {
panic!("first key should lead an in-flight request");
};
assert!(matches!(in_flight.enter("a"), FlightPermit::Follower(_)));
assert!(matches!(in_flight.enter("b"), FlightPermit::Bypass));
drop(first);
assert!(matches!(in_flight.enter("b"), FlightPermit::Leader(_)));
}
#[test]
fn cache_max_in_flight_can_disable_coalescing() {
let cache =
Cache::new(MokaStore::<String>::new(100), RequestIssuer::default()).max_in_flight(0);
assert!(matches!(
cache.in_flight.enter("uncached".to_owned()),
FlightPermit::Bypass
));
}
#[test]
fn cached_response_skips_private_cache_headers_by_default() {
for (name, value) in [(SET_COOKIE, "sid=abc"), (CACHE_CONTROL, "private")] {
let mut res = Response::new();
res.body(ResBody::Once(Bytes::from_static(b"cached")));
res.headers_mut().insert(name, value.parse().unwrap());
assert!(cached_response(&res, &HeaderMap::new(), false).is_none());
assert!(cached_response(&res, &HeaderMap::new(), true).is_some());
}
}
#[test]
fn cached_response_never_stores_vary() {
let mut res = Response::new();
res.body(ResBody::Once(Bytes::from_static(b"cached")));
res.headers_mut()
.insert(VARY, "accept-language".parse().unwrap());
assert!(cached_response(&res, &HeaderMap::new(), false).is_none());
assert!(cached_response(&res, &HeaderMap::new(), true).is_none());
}
#[test]
fn cached_response_never_stores_no_store_or_no_cache() {
for value in ["max-age=60, no-store", "no-cache", "public, no-cache"] {
let mut res = Response::new();
res.body(ResBody::Once(Bytes::from_static(b"cached")));
res.headers_mut()
.insert(CACHE_CONTROL, value.parse().unwrap());
assert!(cached_response(&res, &HeaderMap::new(), false).is_none());
assert!(cached_response(&res, &HeaderMap::new(), true).is_none());
}
}
#[tokio::test]
async fn authorization_requests_are_not_cached_by_default() {
let calls = Arc::new(AtomicUsize::new(0));
let cache = Cache::new(
MokaStore::builder()
.time_to_live(std::time::Duration::from_secs(60))
.build(),
RequestIssuer::default(),
);
let router = Arc::new(Router::new().hoop(cache).goal(SlowCached {
calls: calls.clone(),
}));
for _ in 0..2 {
let mut res = TestClient::get("http://127.0.0.1:5801")
.add_header(AUTHORIZATION, "Bearer token", true)
.send(router.clone())
.await;
assert_eq!(res.status_code, Some(StatusCode::OK));
let _ = res.take_string().await.unwrap();
}
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[test]
fn test_cache_debug() {
let cache = Cache::new(MokaStore::<String>::new(100), RequestIssuer::default());
let debug_str = format!("{cache:?}");
assert!(debug_str.contains("Cache"));
assert!(debug_str.contains("store"));
assert!(debug_str.contains("issuer"));
}
#[tokio::test]
async fn test_cache_same_path_same_content() {
let cache = Cache::new(
MokaStore::builder()
.time_to_live(std::time::Duration::from_secs(60))
.build(),
RequestIssuer::default(),
);
let router = Router::new().hoop(cache).goal(cached);
let service = Service::new(router);
let mut res1 = TestClient::get("http://127.0.0.1:5801/same-path")
.send(&service)
.await;
let content1 = res1.take_string().await.unwrap();
let mut res2 = TestClient::get("http://127.0.0.1:5801/same-path")
.send(&service)
.await;
let content2 = res2.take_string().await.unwrap();
assert_eq!(content1, content2);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_cache_coalesces_concurrent_misses() {
let calls = Arc::new(AtomicUsize::new(0));
let cache = Cache::new(
MokaStore::builder()
.time_to_live(std::time::Duration::from_secs(60))
.build(),
RequestIssuer::default(),
);
let router = Arc::new(Router::new().hoop(cache).goal(SlowCached {
calls: calls.clone(),
}));
let barrier = Arc::new(tokio::sync::Barrier::new(16));
let mut tasks = Vec::new();
for _ in 0..16 {
let router = router.clone();
let barrier = barrier.clone();
tasks.push(tokio::spawn(async move {
barrier.wait().await;
let mut res = TestClient::get("http://127.0.0.1:5801").send(router).await;
res.take_string().await.unwrap()
}));
}
let mut bodies = Vec::new();
for task in tasks {
bodies.push(task.await.unwrap());
}
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(bodies.iter().all(|body| body == &bodies[0]));
}
}