Skip to main content

oxigeo_streaming/tile/
provider.rs

1//! Tile provider implementations.
2
3use super::cache::TileCache;
4use super::protocol::{
5    FileSystemTileProtocol, TileCoordinate, TileProtocol, TileRequest, TileResponse,
6};
7use crate::error::{Result, StreamingError};
8use async_trait::async_trait;
9use std::sync::Arc;
10use tracing::{debug, info};
11
12/// Tile provider trait.
13#[async_trait]
14pub trait TileProvider: Send + Sync {
15    /// Get a tile.
16    async fn get_tile(&self, request: &TileRequest) -> Result<TileResponse>;
17
18    /// Prefetch multiple tiles.
19    async fn prefetch_tiles(&self, requests: Vec<TileRequest>) -> Result<Vec<TileResponse>>;
20}
21
22/// User-supplied in-memory tile generator.
23///
24/// Backs [`TileSource::Generator`]: the provider calls [`Self::generate`] to
25/// synthesize a tile for a coordinate (e.g. procedurally rendered debug tiles,
26/// on-the-fly rasterization, or a test fixture) rather than fetching it over a
27/// network or reading it from disk.
28#[async_trait]
29pub trait TileGenerator: Send + Sync {
30    /// Generate a tile for the given coordinate.
31    async fn generate(&self, coord: &TileCoordinate) -> Result<TileResponse>;
32}
33
34/// Tile source configuration.
35#[derive(Debug, Clone)]
36pub enum TileSource {
37    /// HTTP/HTTPS URL template
38    Http {
39        /// URL template with `{z}`, `{x}`, `{y}` placeholders
40        url_template: String,
41        /// Minimum supported zoom level
42        min_zoom: u8,
43        /// Maximum supported zoom level
44        max_zoom: u8,
45    },
46
47    /// Local file system
48    FileSystem {
49        /// Root directory containing tile files
50        base_path: std::path::PathBuf,
51        /// Tile image format
52        format: super::protocol::TileFormat,
53    },
54
55    /// In-memory tile generator
56    Generator {
57        /// Minimum supported zoom level
58        min_zoom: u8,
59        /// Maximum supported zoom level
60        max_zoom: u8,
61    },
62}
63
64/// Standard tile provider with caching.
65///
66/// `fetch_tile` routes on the configured [`TileSource`]:
67/// - [`TileSource::Http`] delegates to the supplied [`TileProtocol`].
68/// - [`TileSource::FileSystem`] reads tiles from disk via an internal
69///   [`FileSystemTileProtocol`] (the supplied protocol is not used).
70/// - [`TileSource::Generator`] calls the [`TileGenerator`] registered with
71///   [`Self::with_generator`]; if none is set, an honest typed error is
72///   returned rather than silently falling back to the supplied protocol.
73pub struct StandardTileProvider {
74    /// Source configuration — actively drives routing in `fetch_tile`.
75    source: TileSource,
76    cache: Option<Arc<TileCache>>,
77    /// Protocol used for `Http` sources.
78    protocol: Arc<dyn TileProtocol>,
79    /// Backing disk protocol built for `FileSystem` sources.
80    fs_protocol: Option<FileSystemTileProtocol>,
81    /// Generator used for `Generator` sources.
82    generator: Option<Arc<dyn TileGenerator>>,
83}
84
85impl StandardTileProvider {
86    /// Create a new tile provider.
87    ///
88    /// For [`TileSource::FileSystem`] the `protocol` argument is unused (disk
89    /// reads are handled internally); pass any protocol (e.g. a placeholder) or
90    /// prefer routing through the source directly. For [`TileSource::Generator`]
91    /// register a generator with [`Self::with_generator`].
92    pub fn new(source: TileSource, protocol: Arc<dyn TileProtocol>) -> Self {
93        let fs_protocol = match &source {
94            TileSource::FileSystem { base_path, format } => {
95                Some(FileSystemTileProtocol::new(base_path.clone(), *format))
96            }
97            _ => None,
98        };
99        Self {
100            source,
101            cache: None,
102            protocol,
103            fs_protocol,
104            generator: None,
105        }
106    }
107
108    /// Enable caching.
109    pub fn with_cache(mut self, cache: Arc<TileCache>) -> Self {
110        self.cache = Some(cache);
111        self
112    }
113
114    /// Register an in-memory tile generator, used when the source is
115    /// [`TileSource::Generator`].
116    pub fn with_generator(mut self, generator: Arc<dyn TileGenerator>) -> Self {
117        self.generator = Some(generator);
118        self
119    }
120
121    /// Fetch a tile from the configured source.
122    async fn fetch_tile(&self, request: &TileRequest) -> Result<TileResponse> {
123        match &self.source {
124            TileSource::Http {
125                min_zoom, max_zoom, ..
126            } => {
127                if request.coord.z < *min_zoom || request.coord.z > *max_zoom {
128                    return Err(StreamingError::InvalidOperation(format!(
129                        "Zoom level {} out of range [{}, {}]",
130                        request.coord.z, min_zoom, max_zoom
131                    )));
132                }
133                self.protocol.get_tile(request).await
134            }
135            TileSource::FileSystem { .. } => {
136                let fs = self.fs_protocol.as_ref().ok_or_else(|| {
137                    StreamingError::InvalidState(
138                        "FileSystem source without an initialized disk protocol".to_string(),
139                    )
140                })?;
141                fs.get_tile(request).await
142            }
143            TileSource::Generator { min_zoom, max_zoom } => {
144                if request.coord.z < *min_zoom || request.coord.z > *max_zoom {
145                    return Err(StreamingError::InvalidOperation(format!(
146                        "Zoom level {} out of range [{}, {}]",
147                        request.coord.z, min_zoom, max_zoom
148                    )));
149                }
150                match &self.generator {
151                    Some(generator) => generator.generate(&request.coord).await,
152                    None => Err(StreamingError::InvalidOperation(
153                        "Generator source requires a generator; register one via \
154                         StandardTileProvider::with_generator"
155                            .to_string(),
156                    )),
157                }
158            }
159        }
160    }
161}
162
163#[async_trait]
164impl TileProvider for StandardTileProvider {
165    async fn get_tile(&self, request: &TileRequest) -> Result<TileResponse> {
166        // Check cache first
167        if let Some(cache) = &self.cache
168            && let Some(response) = cache.get(&request.coord).await
169        {
170            debug!("Cache hit for tile {}", request.coord);
171            return Ok(response);
172        }
173
174        // Fetch from source
175        let response = self.fetch_tile(request).await?;
176
177        // Store in cache
178        if let Some(cache) = &self.cache {
179            cache.put(response.clone()).await.ok();
180        }
181
182        Ok(response)
183    }
184
185    async fn prefetch_tiles(&self, requests: Vec<TileRequest>) -> Result<Vec<TileResponse>> {
186        let mut responses = Vec::with_capacity(requests.len());
187
188        for request in requests {
189            match self.get_tile(&request).await {
190                Ok(response) => responses.push(response),
191                Err(e) => {
192                    debug!("Failed to prefetch tile {}: {}", request.coord, e);
193                }
194            }
195        }
196
197        Ok(responses)
198    }
199}
200
201/// Multi-source tile provider with fallback.
202pub struct MultiSourceTileProvider {
203    providers: Vec<Arc<dyn TileProvider>>,
204}
205
206impl MultiSourceTileProvider {
207    /// Create a new multi-source provider.
208    pub fn new(providers: Vec<Arc<dyn TileProvider>>) -> Self {
209        Self { providers }
210    }
211
212    /// Add a provider.
213    pub fn add_provider(&mut self, provider: Arc<dyn TileProvider>) {
214        self.providers.push(provider);
215    }
216}
217
218#[async_trait]
219impl TileProvider for MultiSourceTileProvider {
220    async fn get_tile(&self, request: &TileRequest) -> Result<TileResponse> {
221        for (i, provider) in self.providers.iter().enumerate() {
222            match provider.get_tile(request).await {
223                Ok(response) => {
224                    if i > 0 {
225                        info!("Fallback to provider {} for tile {}", i, request.coord);
226                    }
227                    return Ok(response);
228                }
229                Err(e) => {
230                    debug!("Provider {} failed for tile {}: {}", i, request.coord, e);
231                    continue;
232                }
233            }
234        }
235
236        Err(StreamingError::Other(format!(
237            "All providers failed for tile {}",
238            request.coord
239        )))
240    }
241
242    async fn prefetch_tiles(&self, requests: Vec<TileRequest>) -> Result<Vec<TileResponse>> {
243        // Use first provider for prefetch
244        if let Some(provider) = self.providers.first() {
245            provider.prefetch_tiles(requests).await
246        } else {
247            Ok(Vec::new())
248        }
249    }
250}
251
252#[cfg(test)]
253#[allow(clippy::panic)]
254mod tests {
255    use super::super::protocol::{TileCoordinate, TileFormat, TileMetadata};
256    use super::*;
257    use bytes::Bytes;
258
259    /// A protocol that always yields a distinctive marker body, used to prove
260    /// that FileSystem/Generator sources do NOT delegate to it.
261    struct MarkerProtocol;
262
263    #[async_trait]
264    impl TileProtocol for MarkerProtocol {
265        async fn get_tile(&self, request: &TileRequest) -> Result<TileResponse> {
266            Ok(TileResponse::new(
267                request.coord,
268                Bytes::from_static(b"FROM_HTTP_PROTOCOL"),
269                "image/png".to_string(),
270            ))
271        }
272        async fn has_tile(&self, _coord: &TileCoordinate) -> Result<bool> {
273            Ok(true)
274        }
275        async fn get_tile_metadata(&self, coord: &TileCoordinate) -> Result<TileMetadata> {
276            Ok(TileMetadata {
277                coord: *coord,
278                size_bytes: 0,
279                format: TileFormat::Png,
280                created_at: None,
281                modified_at: None,
282                bbox: None,
283                metadata: std::collections::HashMap::new(),
284            })
285        }
286        fn zoom_levels(&self) -> (u8, u8) {
287            (0, 31)
288        }
289        fn tile_size(&self) -> (u32, u32) {
290            (256, 256)
291        }
292    }
293
294    struct SolidGenerator;
295
296    #[async_trait]
297    impl TileGenerator for SolidGenerator {
298        async fn generate(&self, coord: &TileCoordinate) -> Result<TileResponse> {
299            Ok(TileResponse::new(
300                *coord,
301                Bytes::from(format!("GEN:{}/{}/{}", coord.z, coord.x, coord.y).into_bytes()),
302                "image/png".to_string(),
303            ))
304        }
305    }
306
307    #[tokio::test]
308    async fn test_filesystem_source_reads_disk_not_protocol() {
309        let dir = tempfile::tempdir().expect("temp dir");
310        let coord = TileCoordinate::new(4, 2, 3);
311        let tile_file = dir.path().join("4/2/3.png");
312        tokio::fs::create_dir_all(tile_file.parent().expect("parent"))
313            .await
314            .expect("mkdir");
315        tokio::fs::write(&tile_file, b"FROM_DISK")
316            .await
317            .expect("write");
318
319        let source = TileSource::FileSystem {
320            base_path: dir.path().to_path_buf(),
321            format: TileFormat::Png,
322        };
323        // Supplied protocol would return a different marker if (incorrectly) used.
324        let provider = StandardTileProvider::new(source, Arc::new(MarkerProtocol));
325        let request = TileRequest::new(coord, TileFormat::Png);
326        let resp = provider.get_tile(&request).await.expect("disk tile");
327        assert_eq!(&resp.data[..], b"FROM_DISK");
328    }
329
330    #[tokio::test]
331    async fn test_generator_source_uses_generator() {
332        let source = TileSource::Generator {
333            min_zoom: 0,
334            max_zoom: 20,
335        };
336        let coord = TileCoordinate::new(6, 1, 2);
337        let provider = StandardTileProvider::new(source, Arc::new(MarkerProtocol))
338            .with_generator(Arc::new(SolidGenerator));
339        let request = TileRequest::new(coord, TileFormat::Png);
340        let resp = provider.get_tile(&request).await.expect("generated tile");
341        assert_eq!(&resp.data[..], b"GEN:6/1/2");
342    }
343
344    #[tokio::test]
345    async fn test_generator_source_without_generator_errors() {
346        let source = TileSource::Generator {
347            min_zoom: 0,
348            max_zoom: 20,
349        };
350        let provider = StandardTileProvider::new(source, Arc::new(MarkerProtocol));
351        let request = TileRequest::new(TileCoordinate::new(6, 1, 2), TileFormat::Png);
352        let err = provider
353            .get_tile(&request)
354            .await
355            .expect_err("should error without generator");
356        assert!(matches!(err, StreamingError::InvalidOperation(_)));
357    }
358
359    #[tokio::test]
360    async fn test_http_source_uses_protocol() {
361        let source = TileSource::Http {
362            url_template: "https://example.com/{z}/{x}/{y}.png".to_string(),
363            min_zoom: 0,
364            max_zoom: 18,
365        };
366        let provider = StandardTileProvider::new(source, Arc::new(MarkerProtocol));
367        let request = TileRequest::new(TileCoordinate::new(5, 1, 1), TileFormat::Png);
368        let resp = provider.get_tile(&request).await.expect("http tile");
369        assert_eq!(&resp.data[..], b"FROM_HTTP_PROTOCOL");
370    }
371
372    #[test]
373    fn test_tile_source() {
374        let source = TileSource::Http {
375            url_template: "https://tile.openstreetmap.org/{z}/{x}/{y}.png".to_string(),
376            min_zoom: 0,
377            max_zoom: 18,
378        };
379
380        match source {
381            TileSource::Http {
382                min_zoom, max_zoom, ..
383            } => {
384                assert_eq!(min_zoom, 0);
385                assert_eq!(max_zoom, 18);
386            }
387            _ => panic!("Wrong variant"),
388        }
389    }
390}