Skip to main content

rustial_engine/
tile_source_vector_http.rs

1//! HTTP vector tile source with MVT binary decoding.
2//!
3//! [`HttpVectorTileSource`] is a [`TileSource`] implementation that
4//! fetches binary Mapbox Vector Tiles (MVT/PBF) over HTTP, decodes
5//! them using the engine's built-in MVT decoder, and produces
6//! [`TileData::Vector`] payloads containing per-source-layer feature
7//! collections.
8//!
9//! ## Architecture
10//!
11//! This is the engine-side equivalent of MapLibre's
12//! `VectorTileSource` + `WorkerTile` pipeline.  In MapLibre:
13//!
14//! 1. `VectorTileSource.loadTile()` sends PBF bytes to a web worker.
15//! 2. `WorkerTile.parse()` decodes the PBF and builds render buckets.
16//! 3. The result is sent back to the main thread.
17//!
18//! In Rustial the same logical pipeline runs on the main thread during
19//! [`TileSource::poll`]:
20//!
21//! 1. `HttpClient` fetches the PBF bytes.
22//! 2. `decode_mvt()` decodes the protobuf into per-layer features.
23//! 3. The result is wrapped in [`TileData::Vector`] and returned.
24//!
25//! ## URL template
26//!
27//! The source is constructed with a URL template containing `{z}`,
28//! `{x}`, and `{y}` placeholders:
29//!
30//! ```text
31//! https://demotiles.maplibre.org/tiles/{z}/{x}/{y}.pbf
32//! ```
33//!
34//! ## TileJSON integration
35//!
36//! The source can optionally be configured with a [`TileJson`] metadata
37//! object that provides source zoom range and bounds filtering, matching
38//! MapLibre's `loadTileJSON()` ? source metadata flow.
39//!
40//! ## Thread safety
41//!
42//! `HttpVectorTileSource` is `Send + Sync`.
43//!
44//! [`TileSource`]: crate::tile_source::TileSource
45//! [`TileJson`]: crate::tilejson::TileJson
46
47use crate::io::{HttpClient, HttpRequest, HttpResponse};
48use crate::mvt::{decode_mvt, MvtDecodeOptions};
49use crate::tile_source::{
50    RevalidationHint, TileData, TileError, TileFreshness, TileResponse, TileSource, VectorTileData,
51};
52use crate::tilejson::TileJson;
53use rustial_math::TileId;
54use std::collections::HashMap;
55use std::sync::Mutex;
56use std::time::{Duration, SystemTime};
57
58// ---------------------------------------------------------------------------
59// Freshness parsing (shared with HttpTileSource)
60// ---------------------------------------------------------------------------
61
62fn parse_cache_control_max_age(value: &str) -> Option<u64> {
63    for directive in value.split(',') {
64        let directive = directive.trim();
65        if let Some(rest) = directive.strip_prefix("max-age=") {
66            if let Ok(seconds) = rest.trim_matches('"').parse::<u64>() {
67                return Some(seconds);
68            }
69        }
70    }
71    None
72}
73
74fn parse_age_seconds(response: &HttpResponse) -> u64 {
75    response
76        .header("age")
77        .and_then(|value| value.parse::<u64>().ok())
78        .unwrap_or(0)
79}
80
81fn parse_http_freshness(response: &HttpResponse) -> TileFreshness {
82    let now = SystemTime::now();
83    let age = parse_age_seconds(response);
84
85    let expires_at = response
86        .header("cache-control")
87        .and_then(parse_cache_control_max_age)
88        .map(|max_age| max_age.saturating_sub(age))
89        .map(Duration::from_secs)
90        .and_then(|ttl| now.checked_add(ttl))
91        .or_else(|| {
92            response
93                .header("expires")
94                .and_then(|value| httpdate::parse_http_date(value).ok())
95        });
96
97    TileFreshness {
98        expires_at,
99        etag: response.header("etag").map(ToOwned::to_owned),
100        last_modified: response.header("last-modified").map(ToOwned::to_owned),
101    }
102}
103
104// ---------------------------------------------------------------------------
105// HttpVectorTileSource
106// ---------------------------------------------------------------------------
107
108/// A [`TileSource`] that fetches and decodes Mapbox Vector Tiles (PBF)
109/// over HTTP.
110///
111/// See the [module-level documentation](self) for the full pipeline
112/// description.
113pub struct HttpVectorTileSource {
114    /// URL template with `{z}`, `{x}`, `{y}` placeholders.
115    url_template: String,
116
117    /// The HTTP client provided by the host application.
118    client: Box<dyn HttpClient>,
119
120    /// Extra headers added to every outgoing request.
121    default_headers: Vec<(String, String)>,
122
123    /// MVT decode options (e.g. layer filter).
124    decode_options: MvtDecodeOptions,
125
126    /// Optional TileJSON metadata for source zoom / bounds filtering.
127    tilejson: Option<TileJson>,
128
129    /// Mapping from request URL to the originating `TileId`.
130    pending: Mutex<HashMap<String, TileId>>,
131
132    /// When `true`, [`poll`](TileSource::poll) returns
133    /// [`TileData::RawVector`] payloads instead of fully decoded
134    /// [`TileData::Vector`], allowing the caller to offload the
135    /// CPU-heavy MVT protobuf decode to a background thread via
136    /// [`DataTaskPool::spawn_decode`](crate::async_data::DataTaskPool::spawn_decode).
137    deferred_decode: bool,
138}
139
140impl std::fmt::Debug for HttpVectorTileSource {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        let pending_count = self.pending.lock().map(|p| p.len()).unwrap_or(0);
143        f.debug_struct("HttpVectorTileSource")
144            .field("url_template", &self.url_template)
145            .field("has_tilejson", &self.tilejson.is_some())
146            .field("pending", &pending_count)
147            .finish()
148    }
149}
150
151impl HttpVectorTileSource {
152    /// Create a new HTTP vector tile source.
153    ///
154    /// The URL template should produce PBF/MVT binary responses,
155    /// e.g. `"https://demotiles.maplibre.org/tiles/{z}/{x}/{y}.pbf"`.
156    pub fn new(url_template: impl Into<String>, client: Box<dyn HttpClient>) -> Self {
157        Self {
158            url_template: url_template.into(),
159            client,
160            default_headers: Vec::new(),
161            decode_options: MvtDecodeOptions::default(),
162            tilejson: None,
163            pending: Mutex::new(HashMap::new()),
164            deferred_decode: false,
165        }
166    }
167
168    /// Enable deferred decoding mode.
169    ///
170    /// When enabled, [`poll`](TileSource::poll) returns
171    /// [`TileData::RawVector`] payloads carrying the raw PBF bytes
172    /// instead of performing the MVT decode inline.  The caller is
173    /// responsible for submitting the decode work to a background
174    /// thread and promoting the tile cache entry once complete.
175    pub fn with_deferred_decode(mut self, deferred: bool) -> Self {
176        self.deferred_decode = deferred;
177        self
178    }
179
180    /// Whether deferred decoding is enabled.
181    #[inline]
182    pub fn deferred_decode(&self) -> bool {
183        self.deferred_decode
184    }
185
186    /// Attach TileJSON metadata to configure source zoom range and
187    /// bounds filtering.
188    pub fn with_tilejson(mut self, tilejson: TileJson) -> Self {
189        self.tilejson = Some(tilejson);
190        self
191    }
192
193    /// Set MVT decode options (e.g. layer filter).
194    pub fn with_decode_options(mut self, options: MvtDecodeOptions) -> Self {
195        self.decode_options = options;
196        self
197    }
198
199    /// Add a default header sent with every tile request.
200    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
201        self.default_headers.push((name.into(), value.into()));
202        self
203    }
204
205    /// Expand the URL template for a given tile ID.
206    pub fn tile_url(&self, id: &TileId) -> String {
207        self.url_template
208            .replace("{z}", &id.zoom.to_string())
209            .replace("{x}", &id.x.to_string())
210            .replace("{y}", &id.y.to_string())
211    }
212
213    /// The URL template this source was constructed with.
214    #[inline]
215    pub fn url_template(&self) -> &str {
216        &self.url_template
217    }
218
219    /// The number of requests currently in flight.
220    pub fn pending_count(&self) -> usize {
221        self.pending.lock().map(|p| p.len()).unwrap_or(0)
222    }
223
224    /// Reference to the attached TileJSON metadata, if any.
225    pub fn tilejson(&self) -> Option<&TileJson> {
226        self.tilejson.as_ref()
227    }
228
229    /// Decode raw MVT bytes for a tile into a `VectorTileData`.
230    fn decode_tile_bytes(
231        &self,
232        bytes: &[u8],
233        tile_id: &TileId,
234    ) -> Result<VectorTileData, TileError> {
235        let layers = decode_mvt(bytes, tile_id, &self.decode_options)
236            .map_err(|e| TileError::Decode(format!("MVT decode: {e}")))?;
237
238        Ok(VectorTileData { layers })
239    }
240}
241
242impl TileSource for HttpVectorTileSource {
243    fn request(&self, id: TileId) {
244        let url = self.tile_url(&id);
245
246        if let Ok(mut pending) = self.pending.lock() {
247            pending.insert(url.clone(), id);
248        }
249
250        let mut req = HttpRequest::get(&url);
251        for (name, value) in &self.default_headers {
252            req = req.with_header(name.clone(), value.clone());
253        }
254
255        self.client.send(req);
256    }
257
258    fn request_revalidate(&self, id: TileId, hint: RevalidationHint) {
259        let url = self.tile_url(&id);
260
261        if let Ok(mut pending) = self.pending.lock() {
262            pending.insert(url.clone(), id);
263        }
264
265        let mut req = HttpRequest::get(&url);
266        for (name, value) in &self.default_headers {
267            req = req.with_header(name.clone(), value.clone());
268        }
269
270        if let Some(etag) = &hint.etag {
271            req = req.with_header("If-None-Match", etag.clone());
272        }
273        if let Some(last_modified) = &hint.last_modified {
274            req = req.with_header("If-Modified-Since", last_modified.clone());
275        }
276
277        self.client.send(req);
278    }
279
280    fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
281        let responses = self.client.poll();
282        if responses.is_empty() {
283            return Vec::new();
284        }
285
286        let mut pending = match self.pending.lock() {
287            Ok(p) => p,
288            Err(_) => return Vec::new(),
289        };
290
291        let mut results = Vec::with_capacity(responses.len());
292
293        for (url, response) in responses {
294            let tile_id = match pending.remove(&url) {
295                Some(id) => id,
296                None => continue,
297            };
298
299            match response {
300                Ok(resp) if resp.status == 304 => {
301                    let freshness = parse_http_freshness(&resp);
302                    results.push((tile_id, Ok(TileResponse::not_modified(freshness))));
303                }
304                Ok(resp) if resp.is_success() => {
305                    let freshness = parse_http_freshness(&resp);
306                    if self.deferred_decode {
307                        let raw = crate::tile_source::RawVectorPayload {
308                            tile_id,
309                            bytes: std::sync::Arc::new(resp.body),
310                            decode_options: self.decode_options.clone(),
311                        };
312                        results.push((
313                            tile_id,
314                            Ok(TileResponse {
315                                data: TileData::RawVector(raw),
316                                freshness,
317                                not_modified: false,
318                            }),
319                        ));
320                    } else {
321                        let tile_result =
322                            self.decode_tile_bytes(&resp.body, &tile_id)
323                                .map(|vector_data| TileResponse {
324                                    data: TileData::Vector(vector_data),
325                                    freshness,
326                                    not_modified: false,
327                                });
328                        results.push((tile_id, tile_result));
329                    }
330                }
331                Ok(resp) if resp.status == 404 => {
332                    results.push((tile_id, Err(TileError::NotFound(tile_id))));
333                }
334                Ok(resp) => {
335                    results.push((
336                        tile_id,
337                        Err(TileError::Network(format!("HTTP {}", resp.status))),
338                    ));
339                }
340                Err(err) => {
341                    results.push((tile_id, Err(TileError::Network(err))));
342                }
343            }
344        }
345
346        results
347    }
348
349    fn cancel(&self, id: TileId) {
350        if let Ok(mut pending) = self.pending.lock() {
351            let url = self.tile_url(&id);
352            pending.remove(&url);
353        }
354    }
355}
356
357// ---------------------------------------------------------------------------
358// Tests
359// ---------------------------------------------------------------------------
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::io::HttpResponse;
365    use std::sync::{Arc, Mutex as StdMutex};
366
367    struct MockClient {
368        sent: StdMutex<Vec<HttpRequest>>,
369        responses: StdMutex<Vec<(String, Result<HttpResponse, String>)>>,
370    }
371
372    impl MockClient {
373        fn new() -> Self {
374            Self {
375                sent: StdMutex::new(Vec::new()),
376                responses: StdMutex::new(Vec::new()),
377            }
378        }
379
380        fn queue_response(&self, url: &str, status: u16, body: Vec<u8>) {
381            self.responses.lock().unwrap().push((
382                url.to_string(),
383                Ok(HttpResponse {
384                    status,
385                    body,
386                    headers: vec![],
387                }),
388            ));
389        }
390    }
391
392    impl HttpClient for MockClient {
393        fn send(&self, request: HttpRequest) {
394            self.sent.lock().unwrap().push(request);
395        }
396
397        fn poll(&self) -> Vec<(String, Result<HttpResponse, String>)> {
398            std::mem::take(&mut *self.responses.lock().unwrap())
399        }
400    }
401
402    const TEMPLATE: &str = "https://tiles.example.com/{z}/{x}/{y}.pbf";
403
404    // Helper to build a minimal valid MVT tile with one point feature.
405    fn build_test_mvt() -> Vec<u8> {
406        fn encode_varint(mut val: u64) -> Vec<u8> {
407            let mut buf = Vec::new();
408            loop {
409                let mut byte = (val & 0x7F) as u8;
410                val >>= 7;
411                if val != 0 {
412                    byte |= 0x80;
413                }
414                buf.push(byte);
415                if val == 0 {
416                    break;
417                }
418            }
419            buf
420        }
421        fn encode_tag(field: u32, wt: u8) -> Vec<u8> {
422            encode_varint(((field as u64) << 3) | wt as u64)
423        }
424        fn encode_ld(field: u32, data: &[u8]) -> Vec<u8> {
425            let mut b = encode_tag(field, 2);
426            b.extend(encode_varint(data.len() as u64));
427            b.extend_from_slice(data);
428            b
429        }
430        fn encode_vi(field: u32, val: u64) -> Vec<u8> {
431            let mut b = encode_tag(field, 0);
432            b.extend(encode_varint(val));
433            b
434        }
435        fn zigzag(n: i32) -> u32 {
436            ((n << 1) ^ (n >> 31)) as u32
437        }
438
439        // Geometry: MoveTo(2048, 2048) - center of tile
440        let mut geom = Vec::new();
441        geom.extend(encode_varint(((1u64) << 3) | 1)); // MoveTo, count=1
442        geom.extend(encode_varint(zigzag(2048) as u64));
443        geom.extend(encode_varint(zigzag(2048) as u64));
444
445        // Feature: type=POINT, geometry
446        let mut feat = Vec::new();
447        feat.extend(encode_vi(3, 1)); // POINT
448        feat.extend(encode_ld(4, &geom));
449
450        // Layer: name="test", feature, extent=4096, version=2
451        let mut layer = Vec::new();
452        layer.extend(encode_ld(1, b"test"));
453        layer.extend(encode_ld(2, &feat));
454        layer.extend(encode_vi(5, 4096));
455        layer.extend(encode_vi(15, 2));
456
457        // Tile: layer
458        encode_ld(3, &layer)
459    }
460
461    #[test]
462    fn url_template_substitution() {
463        let client = MockClient::new();
464        let source = HttpVectorTileSource::new(TEMPLATE, Box::new(client));
465        let url = source.tile_url(&TileId::new(10, 512, 340));
466        assert_eq!(url, "https://tiles.example.com/10/512/340.pbf");
467    }
468
469    #[test]
470    fn request_sends_http_get() {
471        let _client = MockClient::new();
472        let sent = Arc::new(StdMutex::new(Vec::new()));
473        let sent_clone = sent.clone();
474
475        struct TrackingClient {
476            sent: Arc<StdMutex<Vec<String>>>,
477        }
478        impl HttpClient for TrackingClient {
479            fn send(&self, request: HttpRequest) {
480                self.sent.lock().unwrap().push(request.url);
481            }
482            fn poll(&self) -> Vec<(String, Result<HttpResponse, String>)> {
483                Vec::new()
484            }
485        }
486
487        let source =
488            HttpVectorTileSource::new(TEMPLATE, Box::new(TrackingClient { sent: sent_clone }));
489        source.request(TileId::new(5, 10, 20));
490
491        let urls = sent.lock().unwrap().clone();
492        assert_eq!(urls, vec!["https://tiles.example.com/5/10/20.pbf"]);
493    }
494
495    #[test]
496    fn successful_fetch_decodes_mvt() {
497        let client = MockClient::new();
498        let url = "https://tiles.example.com/0/0/0.pbf";
499        let mvt_bytes = build_test_mvt();
500        client.queue_response(url, 200, mvt_bytes);
501
502        let source = HttpVectorTileSource::new(TEMPLATE, Box::new(client));
503        source.request(TileId::new(0, 0, 0));
504
505        let results = source.poll();
506        assert_eq!(results.len(), 1);
507        let (id, result) = &results[0];
508        assert_eq!(*id, TileId::new(0, 0, 0));
509
510        let response = result.as_ref().expect("should succeed");
511        match &response.data {
512            TileData::Vector(vt) => {
513                assert!(vt.layers.contains_key("test"));
514                assert_eq!(vt.layers["test"].len(), 1);
515            }
516            other => panic!("expected Vector tile data, got {:?}", other),
517        }
518    }
519
520    #[test]
521    fn http_404_returns_not_found() {
522        let client = MockClient::new();
523        client.queue_response("https://tiles.example.com/0/0/0.pbf", 404, vec![]);
524
525        let source = HttpVectorTileSource::new(TEMPLATE, Box::new(client));
526        source.request(TileId::new(0, 0, 0));
527
528        let results = source.poll();
529        assert_eq!(results.len(), 1);
530        assert!(matches!(results[0].1, Err(TileError::NotFound(_))));
531    }
532
533    #[test]
534    fn cancel_removes_pending() {
535        let client = MockClient::new();
536        client.queue_response("https://tiles.example.com/0/0/0.pbf", 200, build_test_mvt());
537
538        let source = HttpVectorTileSource::new(TEMPLATE, Box::new(client));
539        source.request(TileId::new(0, 0, 0));
540        assert_eq!(source.pending_count(), 1);
541
542        source.cancel(TileId::new(0, 0, 0));
543        assert_eq!(source.pending_count(), 0);
544
545        let results = source.poll();
546        assert!(results.is_empty());
547    }
548
549    #[test]
550    fn with_tilejson_attaches_metadata() {
551        let client = MockClient::new();
552        let tj = TileJson::with_tiles(vec!["https://example.com/{z}/{x}/{y}.pbf".into()]);
553        let source =
554            HttpVectorTileSource::new(TEMPLATE, Box::new(client)).with_tilejson(tj.clone());
555        assert!(source.tilejson().is_some());
556        assert_eq!(source.tilejson().unwrap().tiles.len(), 1);
557    }
558
559    #[test]
560    fn default_headers_are_sent() {
561        #[derive(Clone)]
562        struct HeaderCapture {
563            last_headers: Arc<StdMutex<Vec<(String, String)>>>,
564        }
565        impl HttpClient for HeaderCapture {
566            fn send(&self, request: HttpRequest) {
567                *self.last_headers.lock().unwrap() = request.headers;
568            }
569            fn poll(&self) -> Vec<(String, Result<HttpResponse, String>)> {
570                Vec::new()
571            }
572        }
573
574        let capture = HeaderCapture {
575            last_headers: Arc::new(StdMutex::new(Vec::new())),
576        };
577        let headers_ref = capture.last_headers.clone();
578
579        let source = HttpVectorTileSource::new(TEMPLATE, Box::new(capture))
580            .with_header("Authorization", "Bearer tok");
581
582        source.request(TileId::new(0, 0, 0));
583
584        let headers = headers_ref.lock().unwrap().clone();
585        assert_eq!(headers.len(), 1);
586        assert_eq!(headers[0].0, "Authorization");
587    }
588
589    #[test]
590    fn debug_impl() {
591        let client = MockClient::new();
592        let source = HttpVectorTileSource::new(TEMPLATE, Box::new(client));
593        let dbg = format!("{source:?}");
594        assert!(dbg.contains("HttpVectorTileSource"));
595    }
596
597    #[test]
598    fn deferred_decode_returns_raw_vector() {
599        let client = MockClient::new();
600        let url = "https://tiles.example.com/0/0/0.pbf";
601        let mvt_bytes = build_test_mvt();
602        client.queue_response(url, 200, mvt_bytes.clone());
603
604        let source =
605            HttpVectorTileSource::new(TEMPLATE, Box::new(client)).with_deferred_decode(true);
606        assert!(source.deferred_decode());
607
608        source.request(TileId::new(0, 0, 0));
609        let results = source.poll();
610        assert_eq!(results.len(), 1);
611
612        let (id, result) = &results[0];
613        assert_eq!(*id, TileId::new(0, 0, 0));
614        let response = result.as_ref().expect("should succeed");
615        assert!(
616            response.data.is_raw_vector(),
617            "deferred mode should return RawVector"
618        );
619
620        let raw = response.data.as_raw_vector().expect("should be RawVector");
621        assert_eq!(raw.tile_id, TileId::new(0, 0, 0));
622        assert_eq!(raw.bytes.len(), mvt_bytes.len());
623    }
624
625    #[test]
626    fn deferred_decode_raw_bytes_can_be_decoded_later() {
627        let client = MockClient::new();
628        let url = "https://tiles.example.com/0/0/0.pbf";
629        client.queue_response(url, 200, build_test_mvt());
630
631        let source =
632            HttpVectorTileSource::new(TEMPLATE, Box::new(client)).with_deferred_decode(true);
633        source.request(TileId::new(0, 0, 0));
634        let results = source.poll();
635        let response = results[0].1.as_ref().expect("should succeed");
636        let raw = response.data.as_raw_vector().expect("should be RawVector");
637
638        // Decode the raw bytes manually (simulates what the async pipeline does).
639        let layers = crate::mvt::decode_mvt(&raw.bytes, &raw.tile_id, &raw.decode_options)
640            .expect("should decode");
641        assert!(layers.contains_key("test"));
642        assert_eq!(layers["test"].len(), 1);
643    }
644
645    #[test]
646    fn deferred_decode_off_returns_vector() {
647        let client = MockClient::new();
648        let url = "https://tiles.example.com/0/0/0.pbf";
649        client.queue_response(url, 200, build_test_mvt());
650
651        let source =
652            HttpVectorTileSource::new(TEMPLATE, Box::new(client)).with_deferred_decode(false);
653        assert!(!source.deferred_decode());
654
655        source.request(TileId::new(0, 0, 0));
656        let results = source.poll();
657        let response = results[0].1.as_ref().expect("should succeed");
658        assert!(
659            response.data.is_vector(),
660            "non-deferred mode should return Vector"
661        );
662    }
663}