larust_http/responsecache.rs
1//! Laravel's `spatie/laravel-responsecache` - narrowed to its core shape,
2//! same "names/assignment split" reasoning `larust-permissions`'s own doc
3//! comment used for its own narrowing (see `crates/larust-permissions/src/
4//! lib.rs`): this caches `GET` responses with a `200` status, keyed by URL,
5//! backed by `larust-cache` (which already owns its own table bootstrap and
6//! expiry sweep - this module adds no new table, just a value shape to
7//! store in the existing one).
8//!
9//! Opt-in per router/route via `.middleware(...)`, the same as
10//! [`crate::throttle`] - never part of `larust_core::Application::serve`'s
11//! own default middleware stack.
12//!
13//! ## Deliberately out of scope for this version
14//!
15//! - **No `Accept`/content-negotiation `Vary`.** This cache is purely
16//! server-side (backed by `larust_cache`, never a browser or proxy
17//! cache), so there's no real HTTP `Vary` semantics to implement - only
18//! the cache *key* needs to account for whatever the response actually
19//! depends on. Two requests to the same URL differing only in `Accept`
20//! or another header still collide (same cached entry either way); if a
21//! route genuinely content-negotiates, don't cache it, the same guidance
22//! as always. Per-session variance (the "auth state" half of the
23//! original limitation here) is now handled - see [`for_minutes_per_session`].
24//! - **No auto-invalidation on writes.** Laravel's package can auto-clear
25//! on any non-`GET` request; this crate only offers [`forget`] (a single
26//! URL) and TTL expiry - an app that needs eager invalidation calls
27//! `forget` itself wherever it mutates the underlying data.
28//! - **No bulk "clear everything."** `larust_cache` has no key-prefix scan
29//! API to build one on top of - a real follow-up if ever needed, not a
30//! gap worth solving speculatively here.
31//! - **Only `Content-Type` survives a cache hit.** Every other response
32//! header is dropped on a cached replay. Responses that set a cookie or
33//! declare `Cache-Control: private`, `no-cache`, or `no-store` are never
34//! stored at all: caching them in this shared cache could otherwise leak
35//! personalized content to a later visitor.
36
37use crate::session::Session;
38use axum::body::{to_bytes, Body};
39use axum::extract::{Request, State};
40use axum::http::{header, Method, StatusCode};
41use axum::middleware::{from_fn_with_state, FromFnLayer, Next};
42use axum::response::{IntoResponse, Response};
43use larust_core::AppError;
44use serde::{Deserialize, Serialize};
45use std::future::Future;
46use std::pin::Pin;
47use std::sync::Arc;
48use std::time::Duration;
49
50/// Bound on how large a response body actually gets *cached* - not a limit
51/// on reading the body at all, since that has to happen regardless (see
52/// [`middleware`]'s own doc comment). Mirrors axum's own `DefaultBodyLimit`
53/// default. A response over this size is still served correctly; it's just
54/// never written to the cache, so this middleware can't become a memory
55/// blow-up vector if applied to a route that (against its own doc advice)
56/// serves something large.
57const MAX_CACHEABLE_BODY_BYTES: usize = 2 * 1024 * 1024;
58
59/// `pub` only because it has to appear in [`for_minutes`]/[`for_duration`]'s
60/// public return type - every field stays private, matching
61/// `crate::throttle::ThrottleState`'s own reasoning.
62pub struct ResponseCacheState {
63 ttl: Duration,
64}
65
66#[derive(Serialize, Deserialize)]
67struct CachedResponse {
68 content_type: Option<String>,
69 body: Vec<u8>,
70}
71
72impl CachedResponse {
73 /// Infallible in practice - a `200` status plus a plain header/body
74 /// pair can't fail to build - but `.expect()` rather than an unchecked
75 /// unwrap-adjacent path, so a genuine bug here surfaces loudly instead
76 /// of silently producing a broken response.
77 fn into_response(self) -> Response {
78 let mut builder = Response::builder().status(StatusCode::OK);
79 if let Some(content_type) = &self.content_type {
80 builder = builder.header(header::CONTENT_TYPE, content_type);
81 }
82 builder
83 .body(Body::from(self.body))
84 .expect("a cached response with a plain header/body pair always builds")
85 }
86}
87
88fn cache_key_for_url(url: &str) -> String {
89 format!("__larust_responsecache:{url}")
90}
91
92/// Manually evicts one URL's cached entry - Laravel's own
93/// `ResponseCache::forget($url)`, narrowed to a single URL (see this
94/// module's own doc comment for why there's no bulk clear).
95pub async fn forget(url: &str) -> Result<(), AppError> {
96 larust_cache::forget(&cache_key_for_url(url)).await
97}
98
99/// `middleware`'s own extractor arguments (every one before `Next`), as a
100/// tuple - see `crate::throttle::Extractors`'s own doc comment for why
101/// `from_fn_with_state` needs this spelled out explicitly.
102type Extractors = (State<Arc<ResponseCacheState>>, Request);
103type PerSessionExtractors = (State<Arc<ResponseCacheState>>, Session, Request);
104
105/// A plain `fn` pointer, not the anonymous type of an `async fn` item -
106/// see `crate::throttle::MiddlewareFn`'s own doc comment for why.
107type MiddlewareFn = fn(State<Arc<ResponseCacheState>>, Request, Next) -> MiddlewareFuture;
108type PerSessionMiddlewareFn =
109 fn(State<Arc<ResponseCacheState>>, Session, Request, Next) -> MiddlewareFuture;
110type MiddlewareFuture = Pin<Box<dyn Future<Output = Response> + Send>>;
111
112/// Caches for `minutes` minutes, keyed by URL alone - see this module's own
113/// doc comment for why that means every viewer of a given URL shares one
114/// cached response. Use [`for_minutes_per_session`] instead for a route
115/// whose response depends on who's asking.
116pub fn for_minutes(minutes: u64) -> FromFnLayer<MiddlewareFn, Arc<ResponseCacheState>, Extractors> {
117 for_duration(Duration::from_secs(minutes * 60))
118}
119
120/// The general form behind [`for_minutes`]. Returns the fully concrete
121/// `FromFnLayer<...>` type rather than an opaque `impl Trait` - same
122/// reasoning as `crate::throttle::per`'s own doc comment: an app in a
123/// different crate calling `.middleware(responsecache::for_minutes(5))`
124/// needs every real trait bound on the returned value visible, which an
125/// opaque return type would erase.
126pub fn for_duration(
127 ttl: Duration,
128) -> FromFnLayer<MiddlewareFn, Arc<ResponseCacheState>, Extractors> {
129 from_fn_with_state(
130 Arc::new(ResponseCacheState { ttl }),
131 middleware as MiddlewareFn,
132 )
133}
134
135/// Caches for `minutes` minutes, keyed by URL **and session** - a cache hit
136/// for one visitor is never served to another. This is the fix for the
137/// "no per-user caching" half of this module's original limitation: the
138/// cache key incorporates the session id (from `Session::id()`, the same
139/// cookie-backed identity `require_auth`/CSRF already key off), not a raw
140/// `Cookie` header or full HTTP `Vary` semantics - this cache has no
141/// browser/proxy audience to announce a `Vary` header to, so only the key
142/// itself needs to account for the viewer. Requires `.with_sessions(...)`
143/// to already be enabled on this router (same requirement CSRF/`Session`
144/// extraction anywhere else already has) - `Session` is one of this
145/// method's own extractors, so applying it to a router with no session
146/// layer fails to compile the same way any other `Session`-extracting
147/// handler would.
148///
149/// Trades a lower hit rate for correctness: every distinct session gets its
150/// own cache entry, so this is worth it for a page that's expensive to
151/// render but genuinely per-viewer (a dashboard), not for content that's
152/// identical for everyone (use [`for_minutes`] for that - a shared cache
153/// entry serving every visitor is the whole point there).
154///
155/// **A session's very first-ever request is never cached** - see
156/// `middleware_per_session`'s own doc comment for why this is a hard
157/// `tower_sessions` architectural constraint (session ids are assigned
158/// lazily, only by the outer session layer's own post-processing, never
159/// visible to this middleware in time) rather than something this crate
160/// could reasonably work around. From that session's second request
161/// onward (once its cookie is being sent back), caching works normally.
162pub fn for_minutes_per_session(
163 minutes: u64,
164) -> FromFnLayer<PerSessionMiddlewareFn, Arc<ResponseCacheState>, PerSessionExtractors> {
165 for_duration_per_session(Duration::from_secs(minutes * 60))
166}
167
168/// The general form behind [`for_minutes_per_session`].
169pub fn for_duration_per_session(
170 ttl: Duration,
171) -> FromFnLayer<PerSessionMiddlewareFn, Arc<ResponseCacheState>, PerSessionExtractors> {
172 from_fn_with_state(
173 Arc::new(ResponseCacheState { ttl }),
174 middleware_per_session as PerSessionMiddlewareFn,
175 )
176}
177
178/// Only `GET` requests are ever looked up or stored - a `POST`/`PUT`/etc.
179/// bypasses this middleware entirely, running the handler and returning its
180/// response completely untouched (no buffering, no caching).
181///
182/// A `GET` response has to be fully read into memory either way to relay it
183/// to the client - that's not a cost this middleware introduces, it's the
184/// same cost already paid constructing any in-memory `View`/`Json`
185/// response. So the body is always buffered for a `200 GET` response;
186/// [`MAX_CACHEABLE_BODY_BYTES`] only bounds whether that buffered body then
187/// gets *written to the cache*, not whether it gets read at all.
188fn middleware(
189 State(state): State<Arc<ResponseCacheState>>,
190 request: Request,
191 next: Next,
192) -> MiddlewareFuture {
193 Box::pin(async move {
194 if request.method() != Method::GET {
195 return next.run(request).await;
196 }
197 let key = cache_key_for_url(&request.uri().to_string());
198 if let Some(hit) = lookup(&key).await {
199 return hit;
200 }
201 let response = next.run(request).await;
202 store_and_respond(&state, &key, response).await
203 })
204}
205
206/// Same shape as [`middleware`], but the cache key also incorporates the
207/// current session's id - see [`for_minutes_per_session`]'s own doc
208/// comment for the full rationale.
209///
210/// **A session's very first request (before it has a session cookie at
211/// all) is never cached, even for its own later requests.** This isn't a
212/// missed optimization - it's a hard constraint of `tower_sessions`'
213/// own architecture, confirmed by reading `Session::save`'s source
214/// directly: a brand-new session's id is only ever assigned inside
215/// `save()`, which the *outer* `SessionManagerLayer` calls in its own
216/// post-processing, strictly after the entire inner service chain
217/// (including this middleware and the handler it wraps) has already
218/// returned its response. There is no point during this function's own
219/// execution - not before `next.run()`, not after - where a genuinely
220/// new session's id exists yet to key a cache entry on. Once a session
221/// has made one real round trip (so the client is sending its cookie
222/// back), `session.id()` is populated from the start of every later
223/// request, and caching works normally from then on.
224fn middleware_per_session(
225 State(state): State<Arc<ResponseCacheState>>,
226 session: Session,
227 request: Request,
228 next: Next,
229) -> MiddlewareFuture {
230 Box::pin(async move {
231 if request.method() != Method::GET {
232 return next.run(request).await;
233 }
234 let Some(session_id) = session.id() else {
235 return next.run(request).await;
236 };
237 let key = format!(
238 "{}:{session_id}",
239 cache_key_for_url(&request.uri().to_string())
240 );
241 if let Some(hit) = lookup(&key).await {
242 return hit;
243 }
244 let response = next.run(request).await;
245 store_and_respond(&state, &key, response).await
246 })
247}
248
249/// Cache lookup shared by [`middleware`]/[`middleware_per_session`] - a
250/// store failure or miss both mean "nothing usable," collapsed into
251/// `None` either way; only a real hit short-circuits the caller.
252async fn lookup(key: &str) -> Option<Response> {
253 match larust_cache::get::<CachedResponse>(key).await {
254 Ok(Some(cached)) => Some(cached.into_response()),
255 Ok(None) => None,
256 Err(error) => {
257 tracing::warn!(%error, "responsecache lookup failed, serving live");
258 None
259 }
260 }
261}
262
263/// Buffers `response`'s body (required either way, to relay it to the
264/// client - see [`middleware`]'s own doc comment), writes it to the cache
265/// under `key` if it qualifies (`200`, under [`MAX_CACHEABLE_BODY_BYTES`]),
266/// and returns the rebuilt response.
267async fn store_and_respond(state: &ResponseCacheState, key: &str, response: Response) -> Response {
268 if response.status() != StatusCode::OK || response_is_private(&response) {
269 return response;
270 }
271
272 let content_type = response
273 .headers()
274 .get(header::CONTENT_TYPE)
275 .and_then(|value| value.to_str().ok())
276 .map(str::to_string);
277 let (parts, body) = response.into_parts();
278 let bytes = match to_bytes(body, usize::MAX).await {
279 Ok(bytes) => bytes,
280 Err(error) => {
281 tracing::error!(%error, "failed to read response body for caching");
282 return (
283 StatusCode::INTERNAL_SERVER_ERROR,
284 "response body read failed",
285 )
286 .into_response();
287 }
288 };
289
290 if bytes.len() <= MAX_CACHEABLE_BODY_BYTES {
291 let cached = CachedResponse {
292 content_type,
293 body: bytes.to_vec(),
294 };
295 if let Err(error) = larust_cache::put(key, &cached, state.ttl).await {
296 tracing::warn!(%error, "responsecache store failed");
297 }
298 }
299
300 Response::from_parts(parts, Body::from(bytes))
301}
302
303/// This cache is shared by default, so never store a response that carries a
304/// browser session mutation or explicitly says it is private/non-cacheable.
305/// `Cache-Control: no-cache` also bypasses storage because this middleware has
306/// no revalidation mechanism; treating it as cacheable would violate the
307/// response's contract.
308fn response_is_private(response: &Response) -> bool {
309 if response.headers().contains_key(header::SET_COOKIE) {
310 return true;
311 }
312
313 response
314 .headers()
315 .get_all(header::CACHE_CONTROL)
316 .iter()
317 .any(|value| {
318 value.to_str().map_or(true, |value| {
319 value.split(',').any(|directive| {
320 matches!(
321 directive.trim().to_ascii_lowercase().as_str(),
322 "private" | "no-cache" | "no-store"
323 )
324 })
325 })
326 })
327}
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn shared_cache_rejects_cookie_setting_and_private_responses() {
334 let cookie_response = Response::builder()
335 .header(header::SET_COOKIE, "session=secret; HttpOnly")
336 .body(Body::empty())
337 .unwrap();
338 assert!(response_is_private(&cookie_response));
339
340 for directive in ["private", "no-cache", "no-store", "public, private"] {
341 let response = Response::builder()
342 .header(header::CACHE_CONTROL, directive)
343 .body(Body::empty())
344 .unwrap();
345 assert!(
346 response_is_private(&response),
347 "{directive:?} must not enter the shared cache"
348 );
349 }
350 }
351
352 #[test]
353 fn shared_cache_allows_an_explicitly_public_response() {
354 let response = Response::builder()
355 .header(header::CACHE_CONTROL, "public, max-age=60")
356 .body(Body::empty())
357 .unwrap();
358 assert!(!response_is_private(&response));
359 }
360}