1use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
39
40use crate::manifest::{CachePolicy, Route};
41
42#[derive(Clone, Debug, Default, PartialEq, Eq)]
45pub struct CachePolicyTable {
46 rules: Vec<CacheRule>,
47}
48
49#[derive(Clone, Debug, PartialEq, Eq)]
50struct CacheRule {
51 pattern: String,
53 ok: Option<HeaderValue>,
56 negative: Option<HeaderValue>,
58 vary: Option<HeaderValue>,
60}
61
62#[derive(serde::Deserialize)]
72struct RouteSlice {
73 route: String,
74 #[serde(default)]
75 requires: Option<Vec<String>>,
76 #[serde(default)]
77 cache_policy: Option<CachePolicy>,
78}
79
80impl CachePolicyTable {
81 pub fn from_routes(routes: &[Route]) -> Self {
86 Self::build(routes.iter().map(|r| {
87 (
88 r.route.as_str(),
89 r.requires.as_ref().is_some_and(|q| !q.is_empty()),
90 Some(&r.cache_policy),
91 )
92 }))
93 }
94
95 pub fn from_manifest_json(raw: &[u8]) -> serde_json::Result<Self> {
97 #[derive(serde::Deserialize)]
98 struct ManifestSlice {
99 #[serde(default)]
100 routes: Vec<RouteSlice>,
101 }
102 let manifest: ManifestSlice = serde_json::from_slice(raw)?;
103 Ok(Self::build(manifest.routes.iter().map(|r| {
104 (
105 r.route.as_str(),
106 r.requires.as_ref().is_some_and(|q| !q.is_empty()),
107 r.cache_policy.as_ref(),
108 )
109 })))
110 }
111
112 fn build<'a>(
113 routes: impl Iterator<Item = (&'a str, bool, Option<&'a CachePolicy>)>,
114 ) -> Self {
115 let mut rules = Vec::new();
116 for (pattern, gated, policy) in routes {
117 let Some(policy) = policy else { continue };
118 let scope = if gated { "private" } else { "public" };
119 let rule = CacheRule {
120 pattern: pattern.to_string(),
121 ok: positive_cache_control(policy, scope).and_then(|v| HeaderValue::try_from(v).ok()),
122 negative: policy
123 .negative_ttl
124 .and_then(|ttl| HeaderValue::try_from(format!("{scope}, max-age={ttl}")).ok()),
125 vary: policy
126 .vary
127 .as_ref()
128 .filter(|v| !v.is_empty())
129 .and_then(|v| HeaderValue::try_from(v.join(", ")).ok()),
130 };
131 if rule.ok.is_some() || rule.negative.is_some() || rule.vary.is_some() {
132 rules.push(rule);
133 }
134 }
135 Self { rules }
136 }
137
138 pub fn is_empty(&self) -> bool {
139 self.rules.is_empty()
140 }
141
142 pub fn len(&self) -> usize {
143 self.rules.len()
144 }
145
146 pub fn cache_control_for(&self, path: &str) -> Option<&str> {
155 self.rules
156 .iter()
157 .find(|r| match_route_pattern(&r.pattern, path))
158 .and_then(|r| r.ok.as_ref())
159 .and_then(|v| v.to_str().ok())
160 }
161
162 pub fn apply(&self, path: &str, status: StatusCode, headers: &mut HeaderMap) {
167 let Some(rule) = self
168 .rules
169 .iter()
170 .find(|r| match_route_pattern(&r.pattern, path))
171 else {
172 return;
173 };
174 let cc = if status.is_success() {
177 rule.ok.as_ref()
178 } else if status == StatusCode::NOT_FOUND || status == StatusCode::GONE {
179 rule.negative.as_ref()
180 } else {
181 None
182 };
183 if let Some(cc) = cc {
184 headers.insert(header::CACHE_CONTROL, cc.clone());
185 if let Some(vary) = &rule.vary {
186 headers.insert(header::VARY, vary.clone());
187 }
188 }
189 }
190}
191
192pub fn positive_cache_control(policy: &CachePolicy, scope: &str) -> Option<String> {
196 if policy.ttl == 0 && policy.swr.is_none() {
197 return None;
198 }
199 let mut value = format!("{scope}, max-age={}", policy.ttl);
200 if let Some(swr) = policy.swr {
201 value.push_str(&format!(", stale-while-revalidate={swr}"));
202 }
203 Some(value)
204}
205
206pub fn match_route_pattern(pattern: &str, pathname: &str) -> bool {
211 let pat = pattern.split('/').filter(|s| !s.is_empty());
212 let path: Vec<&str> = pathname.split('/').filter(|s| !s.is_empty()).collect();
213 let pat: Vec<&str> = pat.collect();
214 if pat.len() != path.len() {
215 return false;
216 }
217 pat.iter().zip(path.iter()).all(|(seg, actual)| {
218 if seg.starts_with(':') {
219 !actual.is_empty()
220 } else {
221 seg == actual
222 }
223 })
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::manifest::{Requires, RouteMode};
230
231 fn route(path: &str, policy: CachePolicy, requires: Option<Vec<Requires>>) -> Route {
232 Route {
233 route: path.into(),
234 mode: RouteMode::Static,
235 render_entrypoint: "e.js".into(),
236 requires,
237 source_reads: None,
238 data_inputs: None,
239 cache_policy: policy,
240 concurrency: None,
241 hydration: None,
242 prerender: None,
243 placement: None,
244 resilience: None,
245 }
246 }
247
248 fn policy(
249 ttl: u64,
250 swr: Option<u64>,
251 negative_ttl: Option<u64>,
252 vary: Option<Vec<String>>,
253 ) -> CachePolicy {
254 CachePolicy {
255 ttl,
256 swr,
257 negative_ttl,
258 vary,
259 }
260 }
261
262 fn header_of(table: &CachePolicyTable, path: &str, status: StatusCode) -> Option<String> {
263 let mut headers = HeaderMap::new();
264 table.apply(path, status, &mut headers);
265 headers
266 .get(header::CACHE_CONTROL)
267 .map(|v| v.to_str().unwrap().to_string())
268 }
269
270 #[test]
271 fn the_inert_policy_every_route_carries_produces_no_rule() {
272 let table = CachePolicyTable::from_routes(&[route("/", policy(0, None, None, None), None)]);
273 assert!(table.is_empty());
274 assert_eq!(header_of(&table, "/", StatusCode::OK), None);
275 }
276
277 #[test]
278 fn a_declared_ttl_reaches_the_response() {
279 let table = CachePolicyTable::from_routes(&[route(
280 "/issues",
281 policy(3600, Some(86_400), None, None),
282 None,
283 )]);
284 assert_eq!(
285 header_of(&table, "/issues", StatusCode::OK).as_deref(),
286 Some("public, max-age=3600, stale-while-revalidate=86400"),
287 );
288 }
289
290 #[test]
293 fn an_authed_route_is_private_not_public() {
294 let table = CachePolicyTable::from_routes(&[route(
295 "/app",
296 policy(60, None, None, None),
297 Some(vec![Requires::User]),
298 )]);
299 let cc = header_of(&table, "/app", StatusCode::OK).unwrap();
300 assert!(cc.starts_with("private,"), "{cc}");
301 }
302
303 #[test]
304 fn a_param_route_matches_its_instances_only_on_segment_boundaries() {
305 let table =
306 CachePolicyTable::from_routes(&[route("/c/:slug", policy(60, None, None, None), None)]);
307 assert!(header_of(&table, "/c/abc", StatusCode::OK).is_some());
308 assert!(header_of(&table, "/c/abc/extra", StatusCode::OK).is_none());
309 assert!(header_of(&table, "/c", StatusCode::OK).is_none());
310 }
311
312 #[test]
313 fn negative_ttl_applies_to_misses_and_the_positive_ttl_does_not() {
314 let table =
315 CachePolicyTable::from_routes(&[route("/r", policy(600, None, Some(30), None), None)]);
316 assert_eq!(
317 header_of(&table, "/r", StatusCode::NOT_FOUND).as_deref(),
318 Some("public, max-age=30"),
319 );
320 assert_eq!(
321 header_of(&table, "/r", StatusCode::OK).as_deref(),
322 Some("public, max-age=600"),
323 );
324 assert_eq!(
326 header_of(&table, "/r", StatusCode::INTERNAL_SERVER_ERROR),
327 None
328 );
329 }
330
331 #[test]
332 fn vary_rides_along_with_the_cache_control_it_qualifies() {
333 let table = CachePolicyTable::from_routes(&[route(
334 "/r",
335 policy(
336 60,
337 None,
338 None,
339 Some(vec!["accept-language".into(), "cookie".into()]),
340 ),
341 None,
342 )]);
343 let mut headers = HeaderMap::new();
344 table.apply("/r", StatusCode::OK, &mut headers);
345 assert_eq!(
346 headers.get(header::VARY).unwrap().to_str().unwrap(),
347 "accept-language, cookie",
348 );
349 }
350
351 #[test]
355 fn swr_at_ttl_zero_is_still_a_declaration() {
356 let table =
357 CachePolicyTable::from_routes(&[route("/r", policy(0, Some(60), None, None), None)]);
358 assert!(!table.is_empty());
359 assert_eq!(
360 header_of(&table, "/r", StatusCode::OK).as_deref(),
361 Some("public, max-age=0, stale-while-revalidate=60"),
362 );
363 }
364
365 #[test]
369 fn the_publish_accessor_agrees_with_the_serve_middleware() {
370 let table = CachePolicyTable::from_routes(&[
371 route("/issues", policy(3600, Some(86_400), None, None), None),
372 route("/app", policy(60, None, None, None), Some(vec![Requires::User])),
373 route("/plain", policy(0, None, None, None), None),
374 ]);
375 for path in ["/issues", "/app", "/plain", "/nope"] {
376 assert_eq!(
377 table.cache_control_for(path).map(str::to_string),
378 header_of(&table, path, StatusCode::OK),
379 "{path}",
380 );
381 }
382 }
383}