Skip to main content

oxigdal_streaming/tile/
protocol.rs

1//! Tile streaming protocol implementations.
2
3use crate::error::{Result, StreamingError};
4use bytes::Bytes;
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8/// Tile coordinate in a tile matrix.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct TileCoordinate {
11    /// Zoom level
12    pub z: u8,
13
14    /// Column (x) index
15    pub x: u32,
16
17    /// Row (y) index
18    pub y: u32,
19}
20
21impl TileCoordinate {
22    /// Create a new tile coordinate.
23    pub fn new(z: u8, x: u32, y: u32) -> Self {
24        Self { z, x, y }
25    }
26
27    /// Convert to XYZ format string.
28    pub fn to_xyz_string(&self) -> String {
29        format!("{}/{}/{}", self.z, self.x, self.y)
30    }
31
32    /// Convert to TMS format (flipped Y).
33    pub fn to_tms(&self) -> Self {
34        let max_y = (1u32 << self.z) - 1;
35        Self {
36            z: self.z,
37            x: self.x,
38            y: max_y - self.y,
39        }
40    }
41
42    /// Get parent tile coordinate.
43    pub fn parent(&self) -> Option<Self> {
44        if self.z == 0 {
45            return None;
46        }
47        Some(Self {
48            z: self.z - 1,
49            x: self.x / 2,
50            y: self.y / 2,
51        })
52    }
53
54    /// Get child tile coordinates.
55    pub fn children(&self) -> Vec<Self> {
56        if self.z >= 31 {
57            return vec![];
58        }
59        let z = self.z + 1;
60        let x = self.x * 2;
61        let y = self.y * 2;
62        vec![
63            Self::new(z, x, y),
64            Self::new(z, x + 1, y),
65            Self::new(z, x, y + 1),
66            Self::new(z, x + 1, y + 1),
67        ]
68    }
69
70    /// Check if this tile is a valid coordinate for the given zoom level.
71    pub fn is_valid(&self) -> bool {
72        if self.z > 31 {
73            return false;
74        }
75        let max_coord = 1u32 << self.z;
76        self.x < max_coord && self.y < max_coord
77    }
78}
79
80impl fmt::Display for TileCoordinate {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        write!(f, "{}/{}/{}", self.z, self.x, self.y)
83    }
84}
85
86/// Tile request parameters.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct TileRequest {
89    /// Tile coordinate
90    pub coord: TileCoordinate,
91
92    /// Tile format (png, jpg, webp, pbf, etc.)
93    pub format: TileFormat,
94
95    /// Optional layer name
96    pub layer: Option<String>,
97
98    /// Optional style name
99    pub style: Option<String>,
100
101    /// Additional parameters
102    pub params: std::collections::HashMap<String, String>,
103}
104
105impl TileRequest {
106    /// Create a new tile request.
107    pub fn new(coord: TileCoordinate, format: TileFormat) -> Self {
108        Self {
109            coord,
110            format,
111            layer: None,
112            style: None,
113            params: std::collections::HashMap::new(),
114        }
115    }
116
117    /// Set the layer name.
118    pub fn with_layer(mut self, layer: String) -> Self {
119        self.layer = Some(layer);
120        self
121    }
122
123    /// Set the style name.
124    pub fn with_style(mut self, style: String) -> Self {
125        self.style = Some(style);
126        self
127    }
128
129    /// Add a parameter.
130    pub fn with_param(mut self, key: String, value: String) -> Self {
131        self.params.insert(key, value);
132        self
133    }
134}
135
136/// Tile response.
137#[derive(Debug, Clone)]
138pub struct TileResponse {
139    /// Tile coordinate
140    pub coord: TileCoordinate,
141
142    /// Tile data
143    pub data: Bytes,
144
145    /// Content type
146    pub content_type: String,
147
148    /// Cache control headers
149    pub cache_control: Option<String>,
150
151    /// ETag for cache validation
152    pub etag: Option<String>,
153
154    /// Last modified timestamp
155    pub last_modified: Option<chrono::DateTime<chrono::Utc>>,
156}
157
158impl TileResponse {
159    /// Create a new tile response.
160    pub fn new(coord: TileCoordinate, data: Bytes, content_type: String) -> Self {
161        Self {
162            coord,
163            data,
164            content_type,
165            cache_control: None,
166            etag: None,
167            last_modified: None,
168        }
169    }
170
171    /// Set cache control.
172    pub fn with_cache_control(mut self, cache_control: String) -> Self {
173        self.cache_control = Some(cache_control);
174        self
175    }
176
177    /// Set ETag.
178    pub fn with_etag(mut self, etag: String) -> Self {
179        self.etag = Some(etag);
180        self
181    }
182
183    /// Get the size in bytes.
184    pub fn size_bytes(&self) -> usize {
185        self.data.len()
186    }
187}
188
189/// Tile format.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191pub enum TileFormat {
192    /// PNG format
193    Png,
194
195    /// JPEG format
196    Jpeg,
197
198    /// WebP format
199    WebP,
200
201    /// Protocol Buffer (vector tiles)
202    Pbf,
203
204    /// GeoJSON
205    GeoJson,
206
207    /// JSON
208    Json,
209}
210
211impl TileFormat {
212    /// Get the MIME type for this format.
213    pub fn mime_type(&self) -> &'static str {
214        match self {
215            TileFormat::Png => "image/png",
216            TileFormat::Jpeg => "image/jpeg",
217            TileFormat::WebP => "image/webp",
218            TileFormat::Pbf => "application/x-protobuf",
219            TileFormat::GeoJson => "application/geo+json",
220            TileFormat::Json => "application/json",
221        }
222    }
223
224    /// Get the file extension for this format.
225    pub fn extension(&self) -> &'static str {
226        match self {
227            TileFormat::Png => "png",
228            TileFormat::Jpeg => "jpg",
229            TileFormat::WebP => "webp",
230            TileFormat::Pbf => "pbf",
231            TileFormat::GeoJson => "geojson",
232            TileFormat::Json => "json",
233        }
234    }
235}
236
237impl fmt::Display for TileFormat {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        write!(f, "{}", self.extension())
240    }
241}
242
243/// Tile protocol interface.
244#[async_trait::async_trait]
245pub trait TileProtocol: Send + Sync {
246    /// Get a tile.
247    async fn get_tile(&self, request: &TileRequest) -> Result<TileResponse>;
248
249    /// Check if a tile exists.
250    async fn has_tile(&self, coord: &TileCoordinate) -> Result<bool>;
251
252    /// Get tile metadata.
253    async fn get_tile_metadata(&self, coord: &TileCoordinate) -> Result<TileMetadata>;
254
255    /// Get the supported zoom levels.
256    fn zoom_levels(&self) -> (u8, u8);
257
258    /// Get the tile size in pixels.
259    fn tile_size(&self) -> (u32, u32);
260}
261
262/// Tile metadata.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct TileMetadata {
265    /// Tile coordinate
266    pub coord: TileCoordinate,
267
268    /// Size in bytes
269    pub size_bytes: usize,
270
271    /// Format
272    pub format: TileFormat,
273
274    /// Creation timestamp
275    pub created_at: Option<chrono::DateTime<chrono::Utc>>,
276
277    /// Last modified timestamp
278    pub modified_at: Option<chrono::DateTime<chrono::Utc>>,
279
280    /// Bounding box
281    pub bbox: Option<oxigdal_core::types::BoundingBox>,
282
283    /// Additional metadata
284    pub metadata: std::collections::HashMap<String, String>,
285}
286
287// ─────────────────────────────────────────────────────────────────────────────
288// HTTP retry helper (feature = "tile-http")
289// ─────────────────────────────────────────────────────────────────────────────
290
291/// Maximum number of retry attempts for transient 5xx errors.
292#[cfg(feature = "tile-http")]
293const HTTP_MAX_ATTEMPTS: u32 = 3;
294
295/// Initial backoff delay in milliseconds before the first retry.
296#[cfg(feature = "tile-http")]
297const HTTP_INITIAL_DELAY_MS: u64 = 100;
298
299/// Parse an RFC-2616 / RFC-7231 `Last-Modified` header value into a UTC datetime.
300#[cfg(feature = "tile-http")]
301fn parse_http_date(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
302    // Try RFC 2822 (most common in HTTP/1.1)
303    if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(value) {
304        return Some(dt.with_timezone(&chrono::Utc));
305    }
306    // Try the older ANSI-C asctime format: "Tue, 15 Nov 1994 08:12:31 GMT"
307    // also covered by rfc2822 above. Try ISO 8601 as a fallback.
308    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value) {
309        return Some(dt.with_timezone(&chrono::Utc));
310    }
311    None
312}
313
314// ─────────────────────────────────────────────────────────────────────────────
315// XyzProtocol
316// ─────────────────────────────────────────────────────────────────────────────
317
318/// XYZ tile protocol implementation.
319pub struct XyzProtocol {
320    /// Base URL template (`{z}`, `{x}`, `{y}` are replaced at fetch time)
321    url_template: String,
322
323    /// Minimum zoom level
324    min_zoom: u8,
325
326    /// Maximum zoom level
327    max_zoom: u8,
328
329    /// Tile size
330    tile_size: (u32, u32),
331
332    /// Reusable HTTP client (only compiled when tile-http is active)
333    #[cfg(feature = "tile-http")]
334    http_client: reqwest::Client,
335}
336
337impl XyzProtocol {
338    /// Create a new XYZ protocol.
339    #[cfg(not(feature = "tile-http"))]
340    pub fn new(url_template: String, min_zoom: u8, max_zoom: u8) -> Self {
341        Self {
342            url_template,
343            min_zoom,
344            max_zoom,
345            tile_size: (256, 256),
346        }
347    }
348
349    /// Create a new XYZ protocol with an HTTP client.
350    #[cfg(feature = "tile-http")]
351    pub fn new(url_template: String, min_zoom: u8, max_zoom: u8) -> Self {
352        Self {
353            url_template,
354            min_zoom,
355            max_zoom,
356            tile_size: (256, 256),
357            http_client: reqwest::Client::new(),
358        }
359    }
360
361    /// Set the tile size.
362    pub fn with_tile_size(mut self, width: u32, height: u32) -> Self {
363        self.tile_size = (width, height);
364        self
365    }
366
367    /// Build URL for a tile.
368    pub fn build_url(&self, coord: &TileCoordinate) -> String {
369        self.url_template
370            .replace("{z}", &coord.z.to_string())
371            .replace("{x}", &coord.x.to_string())
372            .replace("{y}", &coord.y.to_string())
373    }
374
375    /// Build URL for a tile with an explicit file extension.
376    #[cfg(feature = "tile-http")]
377    fn build_url_with_ext(&self, coord: &TileCoordinate, ext: &str) -> String {
378        // If the template already contains `{ext}` expand it; otherwise fall
379        // back to the numeric placeholders only.
380        self.url_template
381            .replace("{ext}", ext)
382            .replace("{z}", &coord.z.to_string())
383            .replace("{x}", &coord.x.to_string())
384            .replace("{y}", &coord.y.to_string())
385    }
386
387    /// Perform a single HTTP GET and return `(status, headers, body)`.
388    #[cfg(feature = "tile-http")]
389    async fn http_get_raw(
390        &self,
391        url: &str,
392    ) -> std::result::Result<reqwest::Response, reqwest::Error> {
393        self.http_client.get(url).send().await
394    }
395
396    /// Fetch the tile bytes with automatic retry on transient 5xx errors.
397    ///
398    /// Returns `(data_bytes, etag, last_modified, cache_control, content_type)`.
399    #[cfg(feature = "tile-http")]
400    async fn fetch_with_retry(&self, url: &str, expected_mime: &str) -> Result<TileResponse> {
401        use std::time::Duration;
402
403        let coord_placeholder = TileCoordinate::new(0, 0, 0); // only used for the error path
404        let mut last_err: Option<StreamingError> = None;
405
406        for attempt in 0..HTTP_MAX_ATTEMPTS {
407            if attempt > 0 {
408                // Simple exponential back-off: 100 ms, 200 ms, …
409                let delay_ms = HTTP_INITIAL_DELAY_MS * (1u64 << (attempt - 1));
410                tokio::time::sleep(Duration::from_millis(delay_ms)).await;
411            }
412
413            let response = match self.http_get_raw(url).await {
414                Ok(r) => r,
415                Err(e) => {
416                    last_err = Some(StreamingError::Reqwest(e));
417                    continue;
418                }
419            };
420
421            let status = response.status();
422
423            // 404 → tile does not exist; do not retry
424            if status == reqwest::StatusCode::NOT_FOUND {
425                return Err(StreamingError::TileNotFound);
426            }
427
428            // 5xx → transient; retry
429            if status.is_server_error() {
430                last_err = Some(StreamingError::HttpError {
431                    status: status.as_u16(),
432                    url: url.to_owned(),
433                });
434                continue;
435            }
436
437            // Any other non-2xx → permanent client-side error; fail immediately
438            if !status.is_success() {
439                return Err(StreamingError::HttpError {
440                    status: status.as_u16(),
441                    url: url.to_owned(),
442                });
443            }
444
445            // --- 2xx: extract metadata from headers before consuming body ---
446
447            // ETag
448            let etag = response
449                .headers()
450                .get(reqwest::header::ETAG)
451                .and_then(|v| v.to_str().ok())
452                .map(|s| s.to_owned());
453
454            // Last-Modified
455            let last_modified = response
456                .headers()
457                .get(reqwest::header::LAST_MODIFIED)
458                .and_then(|v| v.to_str().ok())
459                .and_then(parse_http_date);
460
461            // Cache-Control
462            let cache_control = response
463                .headers()
464                .get(reqwest::header::CACHE_CONTROL)
465                .and_then(|v| v.to_str().ok())
466                .map(|s| s.to_owned());
467
468            // Content-Type: prefer what the server says; fall back to expected_mime
469            let content_type = response
470                .headers()
471                .get(reqwest::header::CONTENT_TYPE)
472                .and_then(|v| v.to_str().ok())
473                .map(|s| s.to_owned())
474                .unwrap_or_else(|| expected_mime.to_owned());
475
476            // Consume body
477            let data = response.bytes().await.map_err(StreamingError::Reqwest)?;
478
479            let mut tile_resp = TileResponse::new(coord_placeholder, data, content_type);
480
481            if let Some(e) = etag {
482                tile_resp = tile_resp.with_etag(e);
483            }
484            if let Some(cc) = cache_control {
485                tile_resp = tile_resp.with_cache_control(cc);
486            }
487            tile_resp.last_modified = last_modified;
488
489            return Ok(tile_resp);
490        }
491
492        // All attempts exhausted
493        Err(last_err.unwrap_or_else(|| StreamingError::HttpError {
494            status: 500,
495            url: url.to_owned(),
496        }))
497    }
498}
499
500#[async_trait::async_trait]
501impl TileProtocol for XyzProtocol {
502    async fn get_tile(&self, request: &TileRequest) -> Result<TileResponse> {
503        if request.coord.z < self.min_zoom || request.coord.z > self.max_zoom {
504            return Err(StreamingError::InvalidOperation(format!(
505                "Zoom level {} out of range [{}, {}]",
506                request.coord.z, self.min_zoom, self.max_zoom
507            )));
508        }
509
510        #[cfg(feature = "tile-http")]
511        {
512            let ext = request.format.extension();
513            let url = self.build_url_with_ext(&request.coord, ext);
514            let expected_mime = request.format.mime_type();
515
516            let mut tile_resp = self.fetch_with_retry(&url, expected_mime).await?;
517            // Stamp the actual requested coordinate (the placeholder in fetch_with_retry
518            // is only used before we know which coord is being requested).
519            tile_resp.coord = request.coord;
520            return Ok(tile_resp);
521        }
522
523        #[cfg(not(feature = "tile-http"))]
524        {
525            let _ = request; // suppress unused warning
526            Ok(TileResponse::new(
527                request.coord,
528                Bytes::new(),
529                request.format.mime_type().to_string(),
530            ))
531        }
532    }
533
534    async fn has_tile(&self, coord: &TileCoordinate) -> Result<bool> {
535        Ok(coord.z >= self.min_zoom && coord.z <= self.max_zoom && coord.is_valid())
536    }
537
538    async fn get_tile_metadata(&self, coord: &TileCoordinate) -> Result<TileMetadata> {
539        #[cfg(feature = "tile-http")]
540        {
541            // Perform a HEAD-like request (GET is the safest fallback since many
542            // tile CDNs do not implement HEAD).  We use a PNG request by default.
543            let url = self.build_url(coord);
544            let response = self
545                .http_get_raw(&url)
546                .await
547                .map_err(StreamingError::Reqwest)?;
548
549            let status = response.status();
550            if status == reqwest::StatusCode::NOT_FOUND {
551                return Err(StreamingError::TileNotFound);
552            }
553            if !status.is_success() {
554                return Err(StreamingError::HttpError {
555                    status: status.as_u16(),
556                    url: url.clone(),
557                });
558            }
559
560            // Extract Last-Modified and ETag from headers.
561            let modified_at = response
562                .headers()
563                .get(reqwest::header::LAST_MODIFIED)
564                .and_then(|v| v.to_str().ok())
565                .and_then(parse_http_date);
566
567            let size_bytes = response
568                .headers()
569                .get(reqwest::header::CONTENT_LENGTH)
570                .and_then(|v| v.to_str().ok())
571                .and_then(|s| s.parse::<usize>().ok())
572                .unwrap_or(0);
573
574            let mut metadata_map = std::collections::HashMap::new();
575            if let Some(etag) = response
576                .headers()
577                .get(reqwest::header::ETAG)
578                .and_then(|v| v.to_str().ok())
579            {
580                metadata_map.insert("etag".to_owned(), etag.to_owned());
581            }
582
583            return Ok(TileMetadata {
584                coord: *coord,
585                size_bytes,
586                format: TileFormat::Png,
587                created_at: None,
588                modified_at,
589                bbox: None,
590                metadata: metadata_map,
591            });
592        }
593
594        #[cfg(not(feature = "tile-http"))]
595        {
596            let _ = coord;
597            Ok(TileMetadata {
598                coord: *coord,
599                size_bytes: 0,
600                format: TileFormat::Png,
601                created_at: None,
602                modified_at: None,
603                bbox: None,
604                metadata: std::collections::HashMap::new(),
605            })
606        }
607    }
608
609    fn zoom_levels(&self) -> (u8, u8) {
610        (self.min_zoom, self.max_zoom)
611    }
612
613    fn tile_size(&self) -> (u32, u32) {
614        self.tile_size
615    }
616}
617
618/// TMS (Tile Map Service) protocol implementation.
619pub struct TmsProtocol {
620    inner: XyzProtocol,
621}
622
623impl TmsProtocol {
624    /// Create a new TMS protocol.
625    pub fn new(url_template: String, min_zoom: u8, max_zoom: u8) -> Self {
626        Self {
627            inner: XyzProtocol::new(url_template, min_zoom, max_zoom),
628        }
629    }
630}
631
632#[async_trait::async_trait]
633impl TileProtocol for TmsProtocol {
634    async fn get_tile(&self, request: &TileRequest) -> Result<TileResponse> {
635        // Convert to TMS coordinates (flip Y)
636        let tms_coord = request.coord.to_tms();
637        let tms_request = TileRequest {
638            coord: tms_coord,
639            ..request.clone()
640        };
641        self.inner.get_tile(&tms_request).await
642    }
643
644    async fn has_tile(&self, coord: &TileCoordinate) -> Result<bool> {
645        let tms_coord = coord.to_tms();
646        self.inner.has_tile(&tms_coord).await
647    }
648
649    async fn get_tile_metadata(&self, coord: &TileCoordinate) -> Result<TileMetadata> {
650        let tms_coord = coord.to_tms();
651        self.inner.get_tile_metadata(&tms_coord).await
652    }
653
654    fn zoom_levels(&self) -> (u8, u8) {
655        self.inner.zoom_levels()
656    }
657
658    fn tile_size(&self) -> (u32, u32) {
659        self.inner.tile_size()
660    }
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    #[test]
668    fn test_tile_coordinate() {
669        let coord = TileCoordinate::new(10, 512, 384);
670        assert_eq!(coord.z, 10);
671        assert_eq!(coord.x, 512);
672        assert_eq!(coord.y, 384);
673        assert!(coord.is_valid());
674    }
675
676    #[test]
677    fn test_tile_parent() {
678        let coord = TileCoordinate::new(10, 512, 384);
679        let parent = coord.parent();
680        assert!(parent.is_some());
681        let parent = parent.expect("parent tile should exist for non-zero zoom level");
682        assert_eq!(parent.z, 9);
683        assert_eq!(parent.x, 256);
684        assert_eq!(parent.y, 192);
685    }
686
687    #[test]
688    fn test_tile_children() {
689        let coord = TileCoordinate::new(10, 512, 384);
690        let children = coord.children();
691        assert_eq!(children.len(), 4);
692        assert_eq!(children[0], TileCoordinate::new(11, 1024, 768));
693        assert_eq!(children[1], TileCoordinate::new(11, 1025, 768));
694        assert_eq!(children[2], TileCoordinate::new(11, 1024, 769));
695        assert_eq!(children[3], TileCoordinate::new(11, 1025, 769));
696    }
697
698    #[test]
699    fn test_tms_conversion() {
700        let coord = TileCoordinate::new(10, 512, 384);
701        let tms = coord.to_tms();
702        assert_eq!(tms.z, 10);
703        assert_eq!(tms.x, 512);
704        assert_eq!(tms.y, 639); // 1024 - 384 - 1
705    }
706
707    #[test]
708    fn test_tile_format() {
709        assert_eq!(TileFormat::Png.mime_type(), "image/png");
710        assert_eq!(TileFormat::Jpeg.extension(), "jpg");
711        assert_eq!(TileFormat::WebP.to_string(), "webp");
712    }
713
714    #[test]
715    fn test_build_url() {
716        let proto = XyzProtocol::new(
717            "https://tiles.example.com/{z}/{x}/{y}.png".to_string(),
718            0,
719            18,
720        );
721        let coord = TileCoordinate::new(10, 512, 384);
722        let url = proto.build_url(&coord);
723        assert_eq!(url, "https://tiles.example.com/10/512/384.png");
724    }
725
726    #[cfg(feature = "tile-http")]
727    #[test]
728    fn test_build_url_with_ext() {
729        let proto = XyzProtocol::new(
730            "https://tiles.example.com/{z}/{x}/{y}.{ext}".to_string(),
731            0,
732            18,
733        );
734        let coord = TileCoordinate::new(7, 63, 42);
735        let url = proto.build_url_with_ext(&coord, "pbf");
736        assert_eq!(url, "https://tiles.example.com/7/63/42.pbf");
737    }
738}