Skip to main content

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}