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