1use crate::error::{Result, StreamingError};
4use bytes::Bytes;
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct TileCoordinate {
11 pub z: u8,
13
14 pub x: u32,
16
17 pub y: u32,
19}
20
21impl TileCoordinate {
22 pub fn new(z: u8, x: u32, y: u32) -> Self {
24 Self { z, x, y }
25 }
26
27 pub fn to_xyz_string(&self) -> String {
29 format!("{}/{}/{}", self.z, self.x, self.y)
30 }
31
32 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct TileRequest {
89 pub coord: TileCoordinate,
91
92 pub format: TileFormat,
94
95 pub layer: Option<String>,
97
98 pub style: Option<String>,
100
101 pub params: std::collections::HashMap<String, String>,
103}
104
105impl TileRequest {
106 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 pub fn with_layer(mut self, layer: String) -> Self {
119 self.layer = Some(layer);
120 self
121 }
122
123 pub fn with_style(mut self, style: String) -> Self {
125 self.style = Some(style);
126 self
127 }
128
129 pub fn with_param(mut self, key: String, value: String) -> Self {
131 self.params.insert(key, value);
132 self
133 }
134}
135
136#[derive(Debug, Clone)]
138pub struct TileResponse {
139 pub coord: TileCoordinate,
141
142 pub data: Bytes,
144
145 pub content_type: String,
147
148 pub cache_control: Option<String>,
150
151 pub etag: Option<String>,
153
154 pub last_modified: Option<chrono::DateTime<chrono::Utc>>,
156}
157
158impl TileResponse {
159 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 pub fn with_cache_control(mut self, cache_control: String) -> Self {
173 self.cache_control = Some(cache_control);
174 self
175 }
176
177 pub fn with_etag(mut self, etag: String) -> Self {
179 self.etag = Some(etag);
180 self
181 }
182
183 pub fn size_bytes(&self) -> usize {
185 self.data.len()
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191pub enum TileFormat {
192 Png,
194
195 Jpeg,
197
198 WebP,
200
201 Pbf,
203
204 GeoJson,
206
207 Json,
209}
210
211impl TileFormat {
212 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 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#[async_trait::async_trait]
245pub trait TileProtocol: Send + Sync {
246 async fn get_tile(&self, request: &TileRequest) -> Result<TileResponse>;
248
249 async fn has_tile(&self, coord: &TileCoordinate) -> Result<bool>;
251
252 async fn get_tile_metadata(&self, coord: &TileCoordinate) -> Result<TileMetadata>;
254
255 fn zoom_levels(&self) -> (u8, u8);
257
258 fn tile_size(&self) -> (u32, u32);
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct TileMetadata {
265 pub coord: TileCoordinate,
267
268 pub size_bytes: usize,
270
271 pub format: TileFormat,
273
274 pub created_at: Option<chrono::DateTime<chrono::Utc>>,
276
277 pub modified_at: Option<chrono::DateTime<chrono::Utc>>,
279
280 pub bbox: Option<oxigdal_core::types::BoundingBox>,
282
283 pub metadata: std::collections::HashMap<String, String>,
285}
286
287#[cfg(feature = "tile-http")]
293const HTTP_MAX_ATTEMPTS: u32 = 3;
294
295#[cfg(feature = "tile-http")]
297const HTTP_INITIAL_DELAY_MS: u64 = 100;
298
299#[cfg(feature = "tile-http")]
301fn parse_http_date(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
302 if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(value) {
304 return Some(dt.with_timezone(&chrono::Utc));
305 }
306 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value) {
309 return Some(dt.with_timezone(&chrono::Utc));
310 }
311 None
312}
313
314pub struct XyzProtocol {
320 url_template: String,
322
323 min_zoom: u8,
325
326 max_zoom: u8,
328
329 tile_size: (u32, u32),
331
332 #[cfg(feature = "tile-http")]
334 http_client: reqwest::Client,
335}
336
337impl XyzProtocol {
338 #[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 #[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 pub fn with_tile_size(mut self, width: u32, height: u32) -> Self {
363 self.tile_size = (width, height);
364 self
365 }
366
367 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 #[cfg(feature = "tile-http")]
377 fn build_url_with_ext(&self, coord: &TileCoordinate, ext: &str) -> String {
378 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 #[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 #[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); let mut last_err: Option<StreamingError> = None;
405
406 for attempt in 0..HTTP_MAX_ATTEMPTS {
407 if attempt > 0 {
408 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 if status == reqwest::StatusCode::NOT_FOUND {
425 return Err(StreamingError::TileNotFound);
426 }
427
428 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 if !status.is_success() {
439 return Err(StreamingError::HttpError {
440 status: status.as_u16(),
441 url: url.to_owned(),
442 });
443 }
444
445 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 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 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 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 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 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 tile_resp.coord = request.coord;
520 return Ok(tile_resp);
521 }
522
523 #[cfg(not(feature = "tile-http"))]
524 {
525 let _ = request; 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 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 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
618pub struct TmsProtocol {
620 inner: XyzProtocol,
621}
622
623impl TmsProtocol {
624 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 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); }
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}