oxigdal_services/tile_cache/http2_push.rs
1//! HTTP/2 server push hints, ETag validation, and unified tile serving logic.
2//!
3//! Provides `PushHint` (Link header builder), `PushPolicy` (neighbour-based
4//! push hint generator), `ETagValidator` (conditional-request helpers), and
5//! `TileServer` (cache + push policy integration).
6
7use super::cache::{CacheStats, CachedTile, TileCache, TileFormat, TileKey, TilePrefetcher};
8
9// ── PushRel ───────────────────────────────────────────────────────────────────
10
11/// The `rel` attribute of an HTTP Link header push hint.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum PushRel {
14 /// `rel=preload` — browser should load this resource soon.
15 Preload,
16 /// `rel=prefetch` — browser may load this resource in the background.
17 Prefetch,
18 /// `rel=preconnect` — browser should open a connection to the origin.
19 Preconnect,
20}
21
22impl PushRel {
23 fn as_str(&self) -> &'static str {
24 match self {
25 PushRel::Preload => "preload",
26 PushRel::Prefetch => "prefetch",
27 PushRel::Preconnect => "preconnect",
28 }
29 }
30}
31
32// ── PushHint ──────────────────────────────────────────────────────────────────
33
34/// An HTTP/2 server push hint represented as a `Link` header entry.
35#[derive(Debug, Clone)]
36pub struct PushHint {
37 /// The URL of the resource to push.
38 pub url: String,
39 /// The link relation type.
40 pub rel: PushRel,
41 /// Optional MIME type (`type` attribute).
42 pub type_: Option<String>,
43 /// Optional resource type (`as` attribute: `"image"`, `"fetch"`, etc.).
44 pub as_: Option<String>,
45 /// Whether to add the `crossorigin` attribute.
46 pub crossorigin: bool,
47 /// If `true`, add `nopush` (preload without an actual server push).
48 pub nopush: bool,
49}
50
51impl PushHint {
52 /// Creates a minimal push hint with all optional fields unset.
53 pub fn new(url: impl Into<String>, rel: PushRel) -> Self {
54 Self {
55 url: url.into(),
56 rel,
57 type_: None,
58 as_: None,
59 crossorigin: false,
60 nopush: false,
61 }
62 }
63
64 /// Creates a `Preload` push hint for a tile, setting `as_` and `type_`
65 /// according to the tile format.
66 pub fn preload_tile(url: impl Into<String>, format: &TileFormat) -> Self {
67 let as_ = match format {
68 TileFormat::Png | TileFormat::Jpeg | TileFormat::Webp => "image",
69 TileFormat::Mvt | TileFormat::Json => "fetch",
70 };
71 let type_ = format.content_type().to_owned();
72 Self {
73 url: url.into(),
74 rel: PushRel::Preload,
75 type_: Some(type_),
76 as_: Some(as_.to_owned()),
77 crossorigin: false,
78 nopush: false,
79 }
80 }
81
82 /// Serialises this hint as a single `Link` header value entry.
83 ///
84 /// Example: `</tiles/roads/10/512/384.mvt>; rel=preload; as=fetch; type="application/vnd.mapbox-vector-tile"`
85 #[must_use]
86 pub fn to_link_header(&self) -> String {
87 let mut s = format!("<{}>; rel={}", self.url, self.rel.as_str());
88 if let Some(ref as_) = self.as_ {
89 s.push_str(&format!("; as={as_}"));
90 }
91 if let Some(ref type_) = self.type_ {
92 s.push_str(&format!("; type=\"{type_}\""));
93 }
94 if self.crossorigin {
95 s.push_str("; crossorigin");
96 }
97 if self.nopush {
98 s.push_str("; nopush");
99 }
100 s
101 }
102}
103
104// ── PushPolicy ────────────────────────────────────────────────────────────────
105
106/// Decides which neighbouring tiles to push alongside a tile request.
107pub struct PushPolicy {
108 /// Maximum number of push hints to generate per request.
109 pub max_push_count: u8,
110 /// Minimum zoom level to consider for push hints.
111 pub min_zoom: u8,
112 /// Maximum zoom level to consider for push hints.
113 pub max_zoom: u8,
114 /// Tile formats to include in push hints.
115 pub formats: Vec<TileFormat>,
116 /// Base URL prepended to each tile path.
117 pub base_url: String,
118}
119
120impl PushPolicy {
121 /// Creates a `PushPolicy` with sensible defaults:
122 /// `max_push_count=8`, zoom 0–22, MVT format.
123 pub fn new(base_url: impl Into<String>) -> Self {
124 Self {
125 max_push_count: 8,
126 min_zoom: 0,
127 max_zoom: 22,
128 formats: vec![TileFormat::Mvt],
129 base_url: base_url.into(),
130 }
131 }
132
133 /// Generates push hints for the neighbours of `requested`.
134 ///
135 /// Uses a `TilePrefetcher` with radius 1, filters by zoom range and format,
136 /// and caps the result at `max_push_count`.
137 pub fn generate_hints(&self, requested: &TileKey) -> Vec<PushHint> {
138 let prefetcher = TilePrefetcher::new(1);
139 let neighbours = prefetcher.neighbors(requested);
140 let mut hints = Vec::new();
141 for neighbour in neighbours {
142 if hints.len() >= self.max_push_count as usize {
143 break;
144 }
145 if neighbour.z < self.min_zoom || neighbour.z > self.max_zoom {
146 continue;
147 }
148 if !self.formats.contains(&neighbour.format) {
149 continue;
150 }
151 let url = format!(
152 "{}/{}",
153 self.base_url.trim_end_matches('/'),
154 neighbour.path_string()
155 );
156 hints.push(PushHint::preload_tile(url, &neighbour.format));
157 }
158 hints
159 }
160
161 /// Joins multiple hints into a single `Link` header value (comma-separated).
162 #[must_use]
163 pub fn to_link_header_value(hints: &[PushHint]) -> String {
164 hints
165 .iter()
166 .map(PushHint::to_link_header)
167 .collect::<Vec<_>>()
168 .join(", ")
169 }
170
171 /// Parses a tile URL of the form `{base_url}/{layer}/{z}/{x}/{y}.{ext}`
172 /// back into a `TileKey`. Returns `None` if the URL is malformed.
173 #[must_use]
174 pub fn parse_tile_url(url: &str, base_url: &str) -> Option<TileKey> {
175 let base = base_url.trim_end_matches('/');
176 let path = url.strip_prefix(base)?.trim_start_matches('/');
177 // path should now be "{layer}/{z}/{x}/{y}.{ext}"
178 let parts: Vec<&str> = path.splitn(4, '/').collect();
179 if parts.len() != 4 {
180 return None;
181 }
182 let layer = parts[0];
183 let z: u8 = parts[1].parse().ok()?;
184 let x: u32 = parts[2].parse().ok()?;
185 // parts[3] is "{y}.{ext}"
186 let (y_str, ext) = parts[3].rsplit_once('.')?;
187 let y: u32 = y_str.parse().ok()?;
188 let format = match ext {
189 "mvt" => TileFormat::Mvt,
190 "png" => TileFormat::Png,
191 "jpg" => TileFormat::Jpeg,
192 "webp" => TileFormat::Webp,
193 "json" => TileFormat::Json,
194 _ => return None,
195 };
196 Some(TileKey::new(z, x, y, layer, format))
197 }
198}
199
200// ── ETagValidator ─────────────────────────────────────────────────────────────
201
202/// Helpers for evaluating HTTP conditional request headers (`If-None-Match`,
203/// `If-Match`).
204pub struct ETagValidator;
205
206impl ETagValidator {
207 /// Checks whether the tile should be sent in full.
208 ///
209 /// Returns `true` (send 200) if `tile_etag` is **not** matched by
210 /// `if_none_match`. Returns `false` (send 304) if matched or if the
211 /// header is the wildcard `*`.
212 #[must_use]
213 pub fn check_none_match(if_none_match: &str, tile_etag: &str) -> bool {
214 let trimmed = if_none_match.trim();
215 if trimmed == "*" {
216 return false; // wildcard matches → 304
217 }
218 let list = Self::parse_etag_list(trimmed);
219 let normalized = Self::normalize_etag(tile_etag);
220 // If any entry in the list matches, return false (304)
221 !list.iter().any(|e| Self::normalize_etag(e) == normalized)
222 }
223
224 /// Returns `true` if `tile_etag` is present in the `If-Match` list or the
225 /// list is the wildcard `*`.
226 #[must_use]
227 pub fn check_match(if_match: &str, tile_etag: &str) -> bool {
228 let trimmed = if_match.trim();
229 if trimmed == "*" {
230 return true;
231 }
232 let list = Self::parse_etag_list(trimmed);
233 let normalized = Self::normalize_etag(tile_etag);
234 list.iter().any(|e| Self::normalize_etag(e) == normalized)
235 }
236
237 /// Parses a comma-separated ETag list header value.
238 ///
239 /// Each entry is trimmed of whitespace but kept with its quotes. Weak
240 /// indicators (`W/`) are preserved.
241 #[must_use]
242 pub fn parse_etag_list(header: &str) -> Vec<String> {
243 header
244 .split(',')
245 .map(|s| s.trim().to_owned())
246 .filter(|s| !s.is_empty())
247 .collect()
248 }
249
250 /// Returns `true` if `etag` is a weak ETag (starts with `W/`).
251 #[must_use]
252 pub fn is_weak(etag: &str) -> bool {
253 etag.starts_with("W/")
254 }
255
256 /// Strips the `W/` prefix for comparison purposes.
257 fn normalize_etag(etag: &str) -> &str {
258 etag.strip_prefix("W/").unwrap_or(etag)
259 }
260}
261
262// ── TileResponseStatus ────────────────────────────────────────────────────────
263
264/// HTTP status code category for a tile response.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub enum TileResponseStatus {
267 /// 200 OK — tile found and returned.
268 Ok,
269 /// 304 Not Modified — ETag matched, no body needed.
270 NotModified,
271 /// 404 Not Found — tile not in cache.
272 NotFound,
273}
274
275// ── TileResponse ──────────────────────────────────────────────────────────────
276
277/// The result of a `TileServer::serve` call.
278#[derive(Debug)]
279pub struct TileResponse {
280 /// HTTP status category.
281 pub status: TileResponseStatus,
282 /// Tile bytes (present for `Ok` responses only).
283 pub data: Option<Vec<u8>>,
284 /// Response headers as `(name, value)` pairs.
285 pub headers: Vec<(String, String)>,
286 /// HTTP/2 push hints for neighbouring tiles.
287 pub push_hints: Vec<PushHint>,
288}
289
290// ── TileServer ────────────────────────────────────────────────────────────────
291
292/// Unified tile serving combining an LRU cache, push policy, and prefetcher.
293pub struct TileServer {
294 /// The underlying LRU tile cache.
295 pub cache: TileCache,
296 /// Policy for generating HTTP/2 push hints.
297 pub push_policy: PushPolicy,
298 /// Prefetcher for enumerating neighbouring tiles.
299 pub prefetcher: TilePrefetcher,
300}
301
302impl TileServer {
303 /// Creates a `TileServer` with a 1 GiB / 1 024-entry cache.
304 pub fn new(base_url: impl Into<String>) -> Self {
305 let base_url = base_url.into();
306 Self {
307 cache: TileCache::new(1024, 256 * 1024 * 1024),
308 push_policy: PushPolicy::new(base_url),
309 prefetcher: TilePrefetcher::new(1),
310 }
311 }
312
313 /// Serves a tile request, returning data, response headers, and push hints.
314 ///
315 /// * Cache miss → `NotFound`.
316 /// * Hit + matching `If-None-Match` → `NotModified` (304).
317 /// * Hit → `Ok` with data, `Cache-Control`, `ETag`, `Content-Type`, `Vary`,
318 /// and HTTP/2 push hints.
319 pub fn serve(&mut self, key: &TileKey, if_none_match: Option<&str>, now: u64) -> TileResponse {
320 // Perform the cache lookup and extract owned values to avoid borrow conflicts.
321 let cached: Option<(Vec<u8>, String)> = self
322 .cache
323 .get(key, now)
324 .map(|t: &CachedTile| (t.data.clone(), t.etag.clone()));
325
326 match cached {
327 None => TileResponse {
328 status: TileResponseStatus::NotFound,
329 data: None,
330 headers: vec![],
331 push_hints: vec![],
332 },
333 Some((data, etag)) => {
334 // Check conditional request
335 if let Some(inm) = if_none_match {
336 if !ETagValidator::check_none_match(inm, &etag) {
337 // ETag matched → 304
338 let headers = vec![
339 ("ETag".to_owned(), etag),
340 (
341 "Cache-Control".to_owned(),
342 "public, max-age=3600".to_owned(),
343 ),
344 ];
345 return TileResponse {
346 status: TileResponseStatus::NotModified,
347 data: None,
348 headers,
349 push_hints: vec![],
350 };
351 }
352 }
353
354 // Full 200 response
355 let content_type = key.content_type().to_owned();
356 let headers = vec![
357 (
358 "Cache-Control".to_owned(),
359 "public, max-age=3600".to_owned(),
360 ),
361 ("ETag".to_owned(), etag),
362 ("Content-Type".to_owned(), content_type),
363 ("Vary".to_owned(), "Accept-Encoding".to_owned()),
364 ];
365
366 let push_hints = self.push_policy.generate_hints(key);
367
368 TileResponse {
369 status: TileResponseStatus::Ok,
370 data: Some(data),
371 headers,
372 push_hints,
373 }
374 }
375 }
376 }
377
378 /// Stores a tile in the cache.
379 pub fn cache_tile(&mut self, key: TileKey, data: Vec<u8>, now: u64) {
380 let tile = CachedTile::new(key, data, now);
381 self.cache.insert(tile);
382 }
383
384 /// Returns a snapshot of cache statistics.
385 #[must_use]
386 pub fn cache_stats(&self) -> CacheStats {
387 self.cache.stats()
388 }
389}