bbox_tile_server/
config.rs

1use crate::cli::Commands;
2use crate::config_t_rex as t_rex;
3use crate::datasource::source_config_from_cli_arg;
4use bbox_core::cli::CommonCommands;
5use bbox_core::config::{
6    app_dir, error_exit, from_config_root_or_exit, ConfigError, DatasourceCfg, DsPostgisCfg,
7    NamedDatasourceCfg,
8};
9use bbox_core::service::ServiceConfig;
10use clap::{ArgMatches, FromArgMatches};
11use log::{info, warn};
12use regex::Regex;
13use serde::{Deserialize, Serialize};
14use std::collections::{HashMap, HashSet};
15use std::convert::From;
16use std::num::NonZeroU16;
17use std::path::{Path, PathBuf};
18
19#[derive(Deserialize, Serialize, Default, Debug)]
20#[serde(default)]
21pub struct TileServiceCfg {
22    /// Custom grid definitions
23    #[serde(rename = "grid")]
24    pub grids: Vec<GridCfg>,
25    #[serde(rename = "datasource")]
26    pub datasources: Vec<NamedDatasourceCfg>,
27    /// Tileset configurations
28    #[serde(rename = "tileset")]
29    pub tilesets: Vec<TileSetCfg>,
30    #[serde(rename = "tilestore")]
31    pub tilestores: Vec<TileCacheProviderCfg>,
32}
33
34/// Tileset configuration
35#[derive(Deserialize, Serialize, Clone, Debug)]
36#[serde(deny_unknown_fields)]
37pub struct TileSetCfg {
38    /// Tileset name, visible part of endpoint
39    pub name: String,
40    // Tile format (Default: Raster)
41    // pub format: Option<TileFormatCfg>,
42    /// Tile matrix set identifiers (Default: `["WebMercatorQuad"]`)
43    #[serde(default)]
44    pub tms: Vec<TilesetTmsCfg>,
45    /// Tile source
46    #[serde(flatten)]
47    pub source: SourceParamCfg,
48    /// Tile cache name (Default: no cache)
49    pub cache: Option<String>,
50    /// Tile format in store. Defaults to `png` for raster and `pbf` for vector tiles
51    pub cache_format: Option<String>,
52    /// Optional limits of zoom levels which should be cached. Tiles in other zoom levels are served from live data.
53    pub cache_limits: Option<CacheLimitCfg>,
54    /// HTTP cache control headers
55    #[serde(default)]
56    pub cache_control: Vec<CacheControlCfg>,
57}
58
59/// Custom grid definition
60#[derive(Deserialize, Serialize, Debug)]
61#[serde(deny_unknown_fields)]
62pub struct GridCfg {
63    /// Grid JSON file path
64    pub json: String,
65}
66
67impl GridCfg {
68    pub fn abs_path(&self) -> PathBuf {
69        app_dir(&self.json)
70    }
71}
72
73/// Available tile grid with optional zoom levels
74#[derive(Deserialize, Serialize, Clone, Debug)]
75#[serde(deny_unknown_fields)]
76pub struct TilesetTmsCfg {
77    /// Tile matrix set identifier
78    pub id: String,
79    /// Minimum zoom level for which tiles are available (Default: 0).
80    pub minzoom: Option<u8>,
81    /// Maximum zoom level for which tiles are available. Defaults to grid maxzoom (24 for `WebMercatorQuad`).
82    ///
83    /// Viewers should use data from tiles at maxzoom when displaying the map at higher zoom levels.
84    pub maxzoom: Option<u8>,
85}
86
87/// Tile sources
88#[derive(Deserialize, Serialize, Clone, Debug)]
89#[serde(deny_unknown_fields)]
90pub enum SourceParamCfg {
91    /// Raster tiles from external WMS
92    #[serde(rename = "wms_proxy")]
93    WmsHttp(WmsHttpSourceParamsCfg),
94    /// Raster tiles from map service
95    #[serde(rename = "map_service")]
96    WmsFcgi(WmsFcgiSourceParamsCfg),
97    /// PostGIS datasource
98    #[serde(rename = "postgis")]
99    Postgis(PostgisSourceParamsCfg),
100    /// Tiles from MBTile archive
101    #[serde(rename = "mbtiles")]
102    Mbtiles(MbtilesStoreCfg),
103    /// Tiles from PMTile archive
104    #[serde(rename = "pmtiles")]
105    Pmtiles(PmtilesStoreCfg),
106}
107
108/// Raster tiles from external WMS
109#[derive(Deserialize, Serialize, Clone, Debug)]
110#[serde(deny_unknown_fields)]
111pub struct WmsHttpSourceParamsCfg {
112    /// Name of `wms_proxy` datasource
113    pub source: String,
114    pub layers: String,
115}
116
117/// Raster tiles from map service
118#[derive(Deserialize, Serialize, Clone, Debug)]
119#[serde(deny_unknown_fields)]
120pub struct WmsFcgiSourceParamsCfg {
121    pub project: String,
122    pub suffix: String,
123    pub layers: String,
124    /// Additional WMS params like transparent=true
125    pub params: Option<String>,
126    /// Width and height of tile. Defaults to grid tile size (usually 256x256)
127    // TODO: per layer for MVT, investigate for OGC Tiles
128    pub tile_size: Option<NonZeroU16>,
129}
130
131/// PostGIS tile datasource
132#[derive(Deserialize, Serialize, Clone, Debug)]
133#[serde(deny_unknown_fields)]
134pub struct PostgisSourceParamsCfg {
135    /// Name of `postgis` datasource (Default: first with matching type)
136    // maybe we should allow direct DS URLs?
137    pub datasource: Option<String>,
138    pub extent: Option<ExtentCfg>,
139    /// Longitude, latitude of map center (in WGS84).
140    ///
141    /// Viewers can use this value to set the default location.
142    pub center: Option<(f64, f64)>,
143    /// Start zoom level. Must be between minzoom and maxzoom.
144    pub start_zoom: Option<u8>,
145    /// Acknowledgment of ownership, authorship or copyright.
146    pub attribution: Option<String>,
147    /// PostGIS 2 compatible query (without ST_AsMVT)
148    #[serde(default)]
149    pub postgis2: bool,
150    /// Add diagnostics layer
151    pub diagnostics: Option<TileDiagnosticsCfg>,
152    /// Layer definitions
153    #[serde(rename = "layer")]
154    pub layers: Vec<VectorLayerCfg>,
155}
156
157#[derive(Deserialize, Serialize, Clone, Debug)]
158#[serde(deny_unknown_fields)]
159pub struct ExtentCfg {
160    pub minx: f64,
161    pub miny: f64,
162    pub maxx: f64,
163    pub maxy: f64,
164}
165
166#[derive(Deserialize, Serialize, Clone, Debug)]
167#[serde(deny_unknown_fields)]
168pub struct TileDiagnosticsCfg {
169    /// Maximal tile size (uncompressed)
170    pub reference_size: Option<u64>,
171}
172
173/// PostGIS vector layer
174#[derive(Deserialize, Serialize, Clone, Debug)]
175#[serde(deny_unknown_fields)]
176pub struct VectorLayerCfg {
177    /// Layer name.
178    pub name: String,
179    /// Name of geometry field.
180    pub geometry_field: Option<String>,
181    /// Type of geometry in PostGIS database
182    ///
183    /// `POINT` | `MULTIPOINT` | `LINESTRING` | `MULTILINESTRING` | `POLYGON` | `MULTIPOLYGON` | `COMPOUNDCURVE` | `CURVEPOLYGON`
184    pub geometry_type: Option<String>,
185    /// Spatial reference system of source data (PostGIS SRID)
186    pub srid: Option<i32>,
187    /// Assume geometry is in grid SRS
188    #[serde(default)]
189    pub no_transform: bool,
190    /// Name of feature ID field
191    pub fid_field: Option<String>,
192    /// Select all fields from table (either table or `query` is required)
193    pub table_name: Option<String>,
194    /// Custom queries
195    #[serde(default, rename = "query")]
196    pub queries: Vec<VectorLayerQueryCfg>,
197    /// Minimal zoom level for which tiles are available.
198    pub minzoom: Option<u8>,
199    /// Maximum zoom level for which tiles are available.
200    pub maxzoom: Option<u8>,
201    /// Maximal number of features to read for a single tile (Default: unlimited).
202    pub query_limit: Option<u32>,
203    /// Width and height of the tile (Default: 4096. Grid default size is 256)
204    #[serde(default = "default_tile_size")]
205    pub tile_size: u32,
206    /// Tile buffer size in pixels (None: no clipping)
207    pub buffer_size: Option<u32>,
208    /// Simplify geometry (lines and polygons). (Default: false)
209    ///
210    /// Applied to PostGIS sources only.
211    #[serde(default)]
212    pub simplify: bool,
213    /// Simplification tolerance (defaults to `!pixel_width!/2`)
214    #[serde(default = "default_tolerance")]
215    pub tolerance: String,
216    /// Fix invalid geometries after simplification (Default: false)
217    ///
218    /// Remark: Clipping step does also fix invalid geometries.
219    #[serde(default)]
220    pub make_valid: bool,
221    /// Apply ST_Shift_Longitude to (transformed) bbox. (Default: false)
222    #[serde(default)]
223    pub shift_longitude: bool,
224}
225
226fn default_tile_size() -> u32 {
227    4096
228}
229
230const DEFAULT_TOLERANCE: &str = "!pixel_width!/2";
231
232fn default_tolerance() -> String {
233    DEFAULT_TOLERANCE.to_string()
234}
235
236#[derive(Deserialize, Serialize, Clone, Debug)]
237#[serde(deny_unknown_fields)]
238pub struct VectorLayerQueryCfg {
239    /// Minimal zoom level for using this query.
240    pub minzoom: Option<u8>,
241    /// Maximal zoom level for using this query.
242    pub maxzoom: Option<u8>,
243    /// Simplify geometry (override layer default setting)
244    pub simplify: Option<bool>,
245    /// Simplification tolerance (override layer default setting)
246    pub tolerance: Option<String>,
247    /// User defined SQL query.
248    ///
249    /// The following variables are replaced at runtime:
250    /// * `!bbox!`: Bounding box of tile
251    /// * `!zoom!`: Zoom level of tile request
252    /// * `!x!`, `!y!`: x, y of tile request (disables geometry filter)
253    /// * `!scale_denominator!`: Map scale of tile request
254    /// * `!pixel_width!`: Width of pixel in grid units
255    /// * `!<fieldname>!`: Custom field query variable
256    pub sql: Option<String>,
257}
258
259/// Tile cache limits
260#[derive(Deserialize, Serialize, Clone, Debug)]
261#[serde(deny_unknown_fields)]
262pub struct CacheLimitCfg {
263    #[serde(default)]
264    pub minzoom: u8,
265    pub maxzoom: Option<u8>,
266}
267
268/// HTTP cache control headers
269#[derive(Deserialize, Serialize, Clone, Debug)]
270#[serde(deny_unknown_fields)]
271pub struct CacheControlCfg {
272    /// `max-age` value in seconds (<https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#response_directives>)
273    pub max_age: u64,
274    /// Minimum zoom level (Default: 0).
275    pub minzoom: Option<u8>,
276    /// Maximum zoom level.
277    pub maxzoom: Option<u8>,
278}
279
280#[derive(Deserialize, Serialize, Clone, Debug)]
281#[serde(deny_unknown_fields)]
282pub struct TileCacheProviderCfg {
283    /// Name of tile cache
284    pub name: String,
285    /// Tile compression method. Default is store type dependent.
286    pub compression: Option<StoreCompressionCfg>,
287    // pub layout: CacheLayout,
288    /// Tile store
289    #[serde(flatten)]
290    pub cache: TileStoreCfg,
291}
292
293/// Tile data compression
294#[derive(Deserialize, Serialize, PartialEq, Clone, Debug)]
295pub enum StoreCompressionCfg {
296    // Unknown,
297    /// No compression
298    None,
299    /// Gzip compression. Default for MBTiles and PMTiles.
300    Gzip,
301    // Brotli,
302    // Zstd,
303}
304
305/// Tile stores
306#[derive(Deserialize, Serialize, Clone, Debug)]
307#[serde(deny_unknown_fields)]
308pub enum TileStoreCfg {
309    /// File system tiles store
310    #[serde(rename = "files")]
311    Files(FileStoreCfg),
312    /// S3 tile store
313    #[serde(rename = "s3")]
314    S3(S3StoreCfg),
315    /// MBTile archive
316    #[serde(rename = "mbtiles")]
317    Mbtiles(MbtilesStoreCfg),
318    /// PMTile archive
319    #[serde(rename = "pmtiles")]
320    Pmtiles(PmtilesStoreCfg),
321    /// Disable tile cache
322    #[serde(rename = "nostore")]
323    NoStore,
324}
325
326#[derive(Deserialize, Serialize, Clone, Debug)]
327#[serde(deny_unknown_fields)]
328pub struct FileStoreCfg {
329    /// Base directory, tileset name will be appended
330    pub base_dir: PathBuf,
331}
332
333impl FileStoreCfg {
334    pub fn abs_path(&self) -> PathBuf {
335        app_dir(&self.base_dir)
336    }
337}
338
339#[derive(Deserialize, Serialize, Clone, Debug)]
340#[serde(deny_unknown_fields)]
341pub struct S3StoreCfg {
342    pub path: String,
343    // pub s3_endpoint_url: Option<String>,
344    // pub aws_access_key_id: Option<String>,
345    // pub aws_secret_access_key: Option<String>,
346}
347
348#[derive(Deserialize, Serialize, Clone, Debug)]
349#[serde(deny_unknown_fields)]
350pub struct MbtilesStoreCfg {
351    pub path: PathBuf,
352}
353
354impl MbtilesStoreCfg {
355    pub fn abs_path(&self) -> PathBuf {
356        app_dir(&self.path)
357    }
358}
359
360#[derive(Deserialize, Serialize, Clone, Debug)]
361#[serde(deny_unknown_fields)]
362pub struct PmtilesStoreCfg {
363    pub path: PathBuf,
364}
365
366impl PmtilesStoreCfg {
367    pub fn abs_path(&self) -> PathBuf {
368        app_dir(&self.path)
369    }
370}
371
372impl TileStoreCfg {
373    pub fn from_cli_args(cli: &ArgMatches) -> Option<Self> {
374        let Ok(Commands::Seed(args)) = Commands::from_arg_matches(cli) else {
375            return None;
376        };
377        if let Some(path) = &args.tile_path {
378            let cache_cfg = TileStoreCfg::Files(FileStoreCfg {
379                base_dir: path.into(),
380            });
381            Some(cache_cfg)
382        } else if let Some(s3_path) = &args.s3_path {
383            let cache_cfg = TileStoreCfg::S3(S3StoreCfg {
384                path: s3_path.to_string(),
385            });
386            Some(cache_cfg)
387        } else if let Some(path) = &args.mb_path {
388            let cache_cfg = TileStoreCfg::Mbtiles(MbtilesStoreCfg { path: path.into() });
389            Some(cache_cfg)
390        } else if let Some(path) = &args.pm_path {
391            let cache_cfg = TileStoreCfg::Pmtiles(PmtilesStoreCfg { path: path.into() });
392            Some(cache_cfg)
393        } else if args.no_store {
394            Some(TileStoreCfg::NoStore)
395        } else {
396            None
397        }
398    }
399}
400
401impl ServiceConfig for TileServiceCfg {
402    fn initialize(cli: &ArgMatches) -> Result<Self, ConfigError> {
403        let mut cfg: TileServiceCfg = from_config_root_or_exit();
404
405        // Handle CLI args
406        if let Some(t_rex_config) = cli.get_one::<PathBuf>("t_rex_config") {
407            let t_rex_cfg: t_rex::ApplicationCfg =
408                t_rex::read_config(t_rex_config.to_str().expect("invalid string"))
409                    .unwrap_or_else(error_exit);
410            cfg = t_rex_cfg.into();
411            info!("Imported t-rex config:\n{}", cfg.as_toml());
412        }
413
414        if let Some(cache) = TileStoreCfg::from_cli_args(cli) {
415            cfg.tilestores.push(TileCacheProviderCfg {
416                name: "<cli>".to_string(),
417                compression: None,
418                cache,
419            });
420        }
421
422        // Get datasource from CLI
423        let file_or_url =
424            if let Ok(CommonCommands::Serve(args)) = CommonCommands::from_arg_matches(cli) {
425                args.file_or_url
426            } else if let Ok(Commands::Seed(args)) = Commands::from_arg_matches(cli) {
427                args.file_or_url
428            } else {
429                None
430            };
431        if let Some(file_or_url) = file_or_url {
432            if let Some(source_cfg) = source_config_from_cli_arg(&file_or_url) {
433                let name = if let Some(name) = Path::new(&file_or_url).file_stem() {
434                    name.to_string_lossy().to_string()
435                } else {
436                    file_or_url.to_string()
437                };
438                info!("Adding tileset `{name}`");
439                let ts = TileSetCfg {
440                    name,
441                    tms: Vec::new(),
442                    source: source_cfg,
443                    cache: None,
444                    cache_format: None,
445                    cache_limits: None,
446                    cache_control: Vec::new(),
447                };
448                cfg.tilesets.push(ts);
449            }
450        }
451        Ok(cfg)
452    }
453}
454
455impl TileServiceCfg {
456    pub fn as_toml(&self) -> String {
457        toml::to_string(&self).unwrap()
458    }
459}
460
461impl From<t_rex::ApplicationCfg> for TileServiceCfg {
462    fn from(t_rex_config: t_rex::ApplicationCfg) -> Self {
463        let re = Regex::new(r#"\) AS "\w+"$"#).expect("re");
464        let datasources = t_rex_config
465            .datasource
466            .into_iter()
467            .filter(|ds| {
468                if ds.path.is_some() {
469                    warn!("Skipping GDAL datasource");
470                }
471                ds.dbconn.is_some()
472            })
473            .map(|ds| {
474                let datasource = DatasourceCfg::Postgis(DsPostgisCfg {
475                    url: ds.dbconn.expect("dbconn"),
476                });
477                NamedDatasourceCfg {
478                    name: ds.name.unwrap_or("default".to_string()),
479                    datasource,
480                }
481            })
482            .collect();
483        let grids = if let Some(g) = &t_rex_config.grid.user {
484            warn!("User defined grid has to be configured manually");
485            vec![GridCfg {
486                json: format!("{}.json", g.srid),
487            }]
488        } else {
489            Vec::new()
490        };
491        let tms = if let Some(g) = &t_rex_config.grid.user {
492            format!("{}", g.srid)
493        } else {
494            match &t_rex_config.grid.predefined.as_deref() {
495                Some("wgs84") => "WorldCRS84Quad".to_string(),
496                Some("web_mercator") => "WebMercatorQuad".to_string(),
497                _ => "WebMercatorQuad".to_string(),
498            }
499        };
500        let tilestore = t_rex_config
501            .cache
502            .and_then(|cache| {
503                if let Some(fcache) = cache.file {
504                    Some(TileStoreCfg::Files(FileStoreCfg {
505                        base_dir: fcache.base.into(),
506                    }))
507                } else {
508                    None
509                }
510            })
511            .map(|cache| TileCacheProviderCfg {
512                name: "tilecache".to_string(),
513                compression: Some(StoreCompressionCfg::Gzip),
514                cache,
515            });
516        let cache_name = tilestore.as_ref().map(|ts| ts.name.clone());
517        let tilestores = if let Some(ts) = tilestore {
518            vec![ts]
519        } else {
520            Vec::new()
521        };
522        let tilesets = t_rex_config
523            .tilesets
524            .into_iter()
525            .map(|ts| {
526                // t-rex has datasource on layer level, bbox on tileset level
527                let dsnames = ts
528                    .layers
529                    .iter()
530                    .map(|l| l.datasource.clone())
531                    .collect::<HashSet<_>>()
532                    .into_iter()
533                    .collect::<Vec<_>>();
534                if dsnames.len() > 1 {
535                    warn!(
536                        "Please group layers with datasources ({dsnames:?}) into separate tilesets"
537                    )
538                }
539                let datasource = dsnames.first().expect("no datasource").clone();
540                let layers = ts
541                    .layers
542                    .into_iter()
543                    .map(|l| {
544                        let mut queries = l
545                            .query
546                            .into_iter()
547                            .map(|q| VectorLayerQueryCfg {
548                                minzoom: Some(q.minzoom),
549                                maxzoom: q.maxzoom,
550                                simplify: q.simplify,
551                                tolerance: q.tolerance,
552                                sql: q.sql,
553                            })
554                            .collect::<Vec<_>>();
555                        // Handle table name query hack
556                        let mut table_name = l.table_name.clone();
557                        if let Some(table) = &l.table_name {
558                            if table.starts_with("(SELECT ") {
559                                let sql = Some(re.replace_all(table, ")").to_string());
560                                queries.insert(
561                                    0,
562                                    VectorLayerQueryCfg {
563                                        minzoom: l.minzoom,
564                                        maxzoom: None,
565                                        simplify: Some(l.simplify),
566                                        tolerance: Some(l.tolerance.clone()),
567                                        sql,
568                                    },
569                                );
570                                table_name = None;
571                            }
572                        }
573                        VectorLayerCfg {
574                            name: l.name,
575                            geometry_field: l.geometry_field,
576                            geometry_type: l.geometry_type,
577                            srid: l.srid,
578                            no_transform: l.no_transform,
579                            fid_field: l.fid_field,
580                            table_name,
581                            query_limit: l.query_limit,
582                            queries,
583                            minzoom: l.minzoom,
584                            maxzoom: l.maxzoom,
585                            tile_size: l.tile_size,
586                            simplify: l.simplify,
587                            tolerance: l.tolerance,
588                            buffer_size: l.buffer_size,
589                            make_valid: l.make_valid,
590                            shift_longitude: l.shift_longitude,
591                        }
592                    })
593                    .collect();
594                let pgcfg = PostgisSourceParamsCfg {
595                    datasource,
596                    extent: ts.extent.map(|ext| ExtentCfg {
597                        maxx: ext.maxx,
598                        maxy: ext.maxy,
599                        minx: ext.minx,
600                        miny: ext.miny,
601                    }),
602                    center: ts.center,
603                    start_zoom: ts.start_zoom,
604                    attribution: ts.attribution,
605                    postgis2: false,
606                    diagnostics: None,
607                    layers,
608                };
609                TileSetCfg {
610                    name: ts.name,
611                    tms: vec![TilesetTmsCfg {
612                        id: tms.clone(),
613                        minzoom: ts.minzoom,
614                        maxzoom: ts.maxzoom,
615                    }],
616                    source: SourceParamCfg::Postgis(pgcfg),
617                    cache: cache_name.clone(),
618                    cache_format: None,
619                    cache_limits: ts.cache_limits.map(|l| CacheLimitCfg {
620                        minzoom: l.minzoom,
621                        maxzoom: l.maxzoom,
622                    }),
623                    cache_control: Vec::new(), // TODO: t_rex_config.webserver.cache_control_max_age
624                }
625            })
626            .collect();
627        TileServiceCfg {
628            grids,
629            datasources,
630            tilesets,
631            tilestores,
632        }
633    }
634}
635
636static WORLD_EXTENT: ExtentCfg = ExtentCfg {
637    minx: -180.0,
638    miny: -90.0,
639    maxx: 180.0,
640    maxy: 90.0,
641};
642
643impl PostgisSourceParamsCfg {
644    pub fn attribution(&self) -> String {
645        self.attribution.clone().unwrap_or("".to_string())
646    }
647    pub fn get_extent(&self) -> &ExtentCfg {
648        self.extent.as_ref().unwrap_or(&WORLD_EXTENT)
649    }
650    pub fn get_center(&self) -> (f64, f64) {
651        if self.center.is_none() {
652            let ext = self.get_extent();
653            (
654                ext.maxx - (ext.maxx - ext.minx) / 2.0,
655                ext.maxy - (ext.maxy - ext.miny) / 2.0,
656            )
657        } else {
658            self.center.unwrap()
659        }
660    }
661    pub fn get_start_zoom(&self) -> u8 {
662        self.start_zoom.unwrap_or(2)
663    }
664}
665
666impl VectorLayerCfg {
667    pub fn minzoom(&self) -> u8 {
668        self.minzoom.unwrap_or(
669            self.queries
670                .iter()
671                .filter_map(|q| q.minzoom)
672                .min()
673                .unwrap_or(0),
674        )
675    }
676    pub fn maxzoom(&self, default: u8) -> u8 {
677        self.maxzoom.unwrap_or(
678            self.queries
679                .iter()
680                .map(|q| q.maxzoom.unwrap_or(default))
681                .max()
682                .unwrap_or(default),
683        )
684    }
685    /// Collect min zoom levels
686    pub fn zoom_steps(&self, tms_cfg: &[TilesetTmsCfg]) -> Vec<u8> {
687        let mut zoom_steps: Vec<u8> = self
688            .queries
689            .iter()
690            .filter(|q| q.sql.is_some())
691            .filter_map(|q| q.minzoom)
692            // Append tile_src minzoom levels
693            .chain(tms_cfg.iter().filter_map(|crs| crs.minzoom))
694            // Append tile_src maxzoom levels
695            .chain(tms_cfg.iter().filter_map(|crs| crs.maxzoom.map(|z| z + 1)))
696            .filter(|z| *z >= self.minzoom())
697            // Append layer minzoom
698            .chain([self.minzoom()])
699            // remove duplicates
700            .collect::<HashSet<u8>>()
701            .into_iter()
702            .collect();
703        zoom_steps.sort();
704        zoom_steps
705    }
706    /// Lookup in HashMap with zoom step key
707    pub fn zoom_step_entry<T>(lookup: &HashMap<u8, T>, zoom: u8) -> Option<&T> {
708        let mut zooms = lookup.keys().cloned().collect::<Vec<_>>();
709        zooms.sort(); // sorted min zoom levels
710        let z = zooms.into_iter().rev().find(|z| zoom >= *z);
711        z.as_ref().and_then(|z| lookup.get(z))
712    }
713    /// Query config for zoom level
714    fn query_cfg<F>(&self, level: u8, check: F) -> Option<&VectorLayerQueryCfg>
715    where
716        F: Fn(&VectorLayerQueryCfg) -> bool,
717    {
718        let mut queries = self
719            .queries
720            .iter()
721            .map(|q| (q.minzoom.unwrap_or(0), q.maxzoom.unwrap_or(255), q))
722            .collect::<Vec<_>>();
723        queries.sort_by_key(|t| t.0);
724        // Start at highest zoom level and find first match
725        let query = queries
726            .iter()
727            .rev()
728            .find(|q| level >= q.0 && level <= q.1 && check(q.2));
729        query.map(|q| q.2)
730    }
731    /// SQL query for zoom level
732    pub fn query(&self, level: u8) -> Option<&String> {
733        let query_cfg = self.query_cfg(level, |q| q.sql.is_some());
734        query_cfg.and_then(|q| q.sql.as_ref())
735    }
736    /// simplify config for zoom level
737    pub fn simplify(&self, level: u8) -> bool {
738        let query_cfg = self.query_cfg(level, |q| q.simplify.is_some());
739        query_cfg.and_then(|q| q.simplify).unwrap_or(self.simplify)
740    }
741    /// tolerance config for zoom level
742    pub fn tolerance(&self, level: u8) -> &String {
743        let query_cfg = self.query_cfg(level, |q| q.tolerance.is_some());
744        query_cfg
745            .and_then(|q| q.tolerance.as_ref())
746            .unwrap_or(&self.tolerance)
747    }
748}
749
750// Mapproxy Yaml:
751
752// services:
753//   demo:
754
755//   wmts:
756//     restful: true
757//     featureinfo_formats:
758//       - mimetype: application/gml+xml; version=3.1
759//         suffix: gml
760//       - mimetype: text/html
761//         suffix: html
762//     md:
763//       title: "Gedati relativi al territorio del Canton Ticino"
764//       abstract: Geodati di base relativi al territorio della Repubblica e Canton Ticino esposti tramite geoservizi WMTS. L 'organizzazione dei geodati di base è secondo le geocategorie definite nella norma eCH0166. I geodati di base vengono offerti secondo i servizi, di rappresentazione (WMTS), definiti dal Regolamento della legge cantonale sulla geoinformazione.
765//       online_resource: https://dev.geo.ti.ch/wmts/1.0.0/WMTSCapabilities.xml
766//       contact:
767//         person: Ufficio della geomatica
768//         position: Point of contact
769//         organization: Repubblica e Cantone Ticino
770//         address: Via Franco Zorzi 13
771//         city: Bellinzona
772//         postcode: 6500
773//         state: Ticino
774//         country: Switzerland
775//         phone: +41(91)814 26 15
776//         fax: +41(91)814 25 29
777//         email: ccgeo@ti.ch
778//       access_constraints: Richiesta formale a ccgeo@ti.ch
779//       fees: 'None'
780//       keyword_list:
781//        - vocabulary: GEMET
782//        - keywords:   [MU]
783//        - keywords:   [Geodati di base, Dati territoriali]
784//   wms:
785//     srs: ['EPSG:4326','EPSG:3857', 'EPSG:21781', 'EPSG:2056']
786//     # force the layer extents (BBOX) to be displayed in this SRS
787//     # bbox_srs: ['EPSG:4326','EPSG:3857', 'EPSG:21781']
788//     # attribution:
789//       # text: "© Amministrazione cantonale"
790//     versions: ['1.0.0', '1.1.0', '1.1.1', '1.3.0']
791//     #versions: ['1.3.0']
792//     bbox_srs:
793//       - 'EPSG:4326'
794//       - 'EPSG:3857'
795//       - 'EPSG:2056'
796//       - srs:'EPSG:2056'
797//         bbox [2420000.00,1030000.00,2900000.00,1350000.00]
798//     md:
799//       title: "Geoservizi dei dati relativi al territorio del Canton Ticino"
800//       abstract: Geoservizi (WMS/WFS) espongono i geodati di base relativi al territorio della Repubblica e Canton Ticino. L'organizzazione dei geodati di base è secondo le geocategorie definite nella norma eCH0166. I geodati di base vengono offerti secondo i servizi, di rappresentazione (WMS) o di telecaricamento (WFS), definiti dal Regolamento della legge cantonale sulla geoinformazione.
801//       online_resource: https://dev.geo.ti.ch/service?
802//       contact:
803//         person: Ufficio della geomatica
804//         position: Point of contact
805//         organization: Repubblica e Cantone Ticino
806//         address: Via Franco Zorzi 13
807//         city: Bellinzona
808//         postcode: 6500
809//         state: Ticino
810//         country: Switzerland
811//         phone: +41(91)814 26 15
812//         fax: +41(91)814 25 29
813//         email: ccgeo@ti.ch
814//       access_constraints: Richeista formale a ccgeo@ti.ch
815//       fees: 'None'
816//       keyword_list:
817//        - vocabulary: GEMET
818//        - keywords:   [MU]
819//        - keywords:   [Geodati di base, Dati territoriali]
820
821// base: [layers.yaml,caches.yaml,sources.yaml]
822
823// grids:
824//     webmercator:
825//         base: GLOBAL_WEBMERCATOR
826
827//     ch_grid:
828//         srs: 'EPSG:21781'
829//         bbox: [420000.00,30000.00,920000.00,350000.00]
830//         origin: nw
831//         tile_size : [ 256 , 256 ]
832//         # resolutions created from scales with
833//         res: [4000,3750,3500,3250,3000,2750,2500,2250,2000,1750,1500,1250,1000,750,650,500,250,100,50,20,10,5,2.5,2,1.5,1,0.5,0.25,0.125,0.1,0.0625]
834
835//     ch95_grid:
836//         srs: 'EPSG:2056'
837//         bbox: [2420000.00,1030000.00,2920000.00,1350000.00]
838//         origin: nw
839//         tile_size : [ 256 , 256 ]
840//         # resolutions created from scales with
841//         res: [4000,3750,3500,3250,3000,2750,2500,2250,2000,1750,1500,1250,1000,750,650,500,250,100,50,20,10,5,2.5,2,1.5,1,0.5,0.25,0.125,0.1,0.0625]
842
843// # -- caches.yaml
844
845// caches:
846//   51_1_color_cache:
847//     grids:
848//     - ch95_grid
849//     sources:
850//     - 51-1_color
851//     bulk_meta_tiles: true
852//     cache:
853//       #type: sqlite
854//       #directory: /mapproxy/cache_data/51-1_color
855//       type: file
856//       directory: /home/marco/tmp/tiles
857//       #directory: /home/marco/officepc/tile_caches/ti_51-1_color
858//       #settings for s3
859//       #type: s3
860//       #bucket_name: tiles
861//       #endpoint_url: http://officepc:9000
862//       #directory: /
863//       directory_layout: tms
864
865// # -- sources.yaml
866
867// sources:
868//   51-1_color:
869//     type: wms
870//     wms_opts:
871//       legendgraphic: false
872//       featureinfo: true
873//     req:
874//       url: http://localhost/cgi-bin/qgis_mapserv.fcgi?MAP=/opt/qgis_server_data/ch_051_1_version1_7_mn95.qgz
875//       layers: ch.ti.051_1.piano_registro_fondiario_colori
876//       transparent: true
877//     supported_srs:
878//     - CRS:84
879//     - EPSG:3857
880//     - EPSG:21781
881//     - EPSG:2056
882//     coverage:
883//       bbox:
884//       - 2670330.0
885//       - 1073180.0
886//       - 2736990.0
887//       - 1167820.0
888//       srs: EPSG:2056
889
890// # -- seed.yaml
891
892// seeds:
893//     seed_update_mu:
894//         # productive configuration
895//         caches: [51_1_color_cache]
896//         #caches: [51_1_bn_cache,51_1_color_cache,51_1_bn_crdpp_cache]
897//         #caches: [ac002_1_3_cache]
898//         grids: [ch95_grid]
899//         coverages: [mu_update]
900//         refresh_before:
901//             mtime: coverage_TI.geojson
902//         levels:
903//             to: 26
904
905// <mapcache>
906//   <metadata>
907//     <title>WMTS / Amt für Geoinformation Kanton Solothurn</title>
908//     <abstract>None</abstract>
909//     <!-- <url>SERVICE_URL</url> -->
910//   </metadata>
911
912//   <grid name="2056">
913//     <metadata>
914//       <title>CH1903+ / LV95</title>
915//     </metadata>
916//     <origin>top-left</origin>
917//     <srs>EPSG:2056</srs>
918//     <units>m</units>
919//     <extent>2420000,1030000,2900000,1350000</extent>
920//     <!--eCH-0056 v2 ? / bisher -->
921//     <!--<resolutions>4000,3750,3500,3250,3000,2750,2500,2250,2000,1750,1500,1250,1000,750,650,500,250,100,50,20,10,5,2.5,2,1.5,1,0.5,0.25,0.1</resolutions>-->
922//     <!--eCH-0056 v3-->
923//     <!--Resolution 0.05 removed intentionally from the following list-->
924//     <resolutions>4000,2000,1000,500,250,100,50,20,10,5,2.5,1,0.5,0.25,0.1</resolutions>
925//     <size>256 256</size>
926//   </grid>
927
928//   <cache name="sqlite" type="sqlite3">
929//     <dbfile>/tiles/{tileset}-{z}-{grid}.db</dbfile>
930//     <detect_blank/>
931//   </cache>
932
933//   <format name="myjpeg" type ="JPEG">
934//     <quality>80</quality>
935//     <photometric>YCBCR</photometric>   <!-- RGB | YCBCR -->
936//   </format>
937
938//   <source name="ch.so.agi.hintergrundkarte_ortho" type="wms">
939//     <getmap>
940//       <params>
941//         <FORMAT>image/jpeg</FORMAT>
942//         <LAYERS>ch.so.agi.hintergrundkarte_ortho</LAYERS>
943//         <TRANSPARENT>true</TRANSPARENT>
944//       </params>
945//     </getmap>
946//     <http>
947//       <url>SOURCE_URL</url>
948//       <connection_timeout>60</connection_timeout>
949//     </http>
950//   </source>
951
952//   <tileset name="ch.so.agi.hintergrundkarte_sw">
953//     <source>ch.so.agi.hintergrundkarte_sw</source>
954//     <cache>sqlite</cache>
955//     <grid restricted_extent="2570000,1208000,2667000,1268000">2056</grid>
956//     <format>PNG</format>
957//     <metatile>8 8</metatile>
958//     <metabuffer>20</metabuffer>
959//     <expires>28800</expires>
960//   </tileset>
961
962//   <default_format>JPEG</default_format>
963//   <service type="wms" enabled="true">
964//     <full_wms>assemble</full_wms>
965//     <resample_mode>bilinear</resample_mode>
966//     <format allow_client_override="true">JPEG</format>
967//     <maxsize>4096</maxsize>
968//   </service>
969//   <service type="wmts" enabled="true"/>
970//   <service type="tms" enabled="false"/>
971//   <service type="kml" enabled="false"/>
972//   <service type="gmaps" enabled="false"/>
973//   <service type="ve" enabled="false"/>
974//   <service type="mapguide" enabled="false"/>
975//   <service type="demo" enabled="DEMO_SERVICE_ENABLED"/>
976//   <errors>report</errors>
977//   <locker type="disk">
978//     <directory>/tmp</directory>
979//     <timeout>300</timeout>
980//   </locker>
981// </mapcache>
982
983#[cfg(test)]
984mod tests {
985    use super::*;
986    use toml::Value;
987
988    fn parse_config<'a, T: Deserialize<'a>>(toml: &str) -> Result<T, String> {
989        toml.parse::<Value>()
990            .and_then(|cfg| cfg.try_into::<T>())
991            .map_err(|err| format!("{err}"))
992    }
993
994    #[test]
995    fn zoom_steps() {
996        const CONFIG: &str = r#"
997            [[datasource]]
998            name = "osmdb"
999            [datasource.postgis]
1000            url = "postgres:///osmdb"
1001
1002            [[tileset]]
1003            name = "osm"
1004
1005            [tileset.postgis]
1006            datasource = "osmdb"
1007
1008            [[tileset.postgis.layer]]
1009            geometry_field = "geom"
1010            geometry_type = "POLYGON"
1011            name = "ocean"
1012            #srid = 8857 / 3857
1013
1014            [[tileset.postgis.layer.query]]
1015            minzoom = 0
1016            maxzoom = 2
1017            sql = """SELECT "geom" FROM "eq"."ocean_low""""
1018
1019            [[tileset.postgis.layer.query]]
1020            minzoom = 3
1021            maxzoom = 9
1022            sql = """SELECT "id","geom" FROM "merc"."ocean_low""""
1023
1024            [[tileset.postgis.layer.query]]
1025            minzoom = 10
1026            sql = """SELECT "id","geom" FROM "merc"."ocean""""
1027        "#;
1028        let cfg: TileServiceCfg = parse_config(CONFIG).unwrap();
1029        let SourceParamCfg::Postgis(ref source) = cfg.tilesets[0].source else {
1030            panic!("Wrong tileset source")
1031        };
1032        assert_eq!(source.layers.len(), 1);
1033        assert_eq!(source.layers[0].minzoom(), 0);
1034        assert_eq!(source.layers[0].maxzoom(42), 42);
1035        assert_eq!(source.layers[0].zoom_steps(&[]), vec![0, 3, 10]);
1036    }
1037
1038    #[test]
1039    fn zoom_min_max() {
1040        const CONFIG: &str = r#"
1041            [[tileset]]
1042            name = "osm"
1043
1044            [tileset.postgis]
1045            datasource = "osmdb"
1046
1047            [[tileset.postgis.layer]]
1048            geometry_field = "geom"
1049            geometry_type = "POLYGON"
1050            name = "ocean"
1051
1052            [[tileset.postgis.layer.query]]
1053            minzoom = 3
1054            maxzoom = 9
1055            sql = """SELECT "id","geom" FROM "merc"."ocean_low""""
1056
1057            [[tileset.postgis.layer.query]]
1058            minzoom = 10
1059            maxzoom = 24
1060            sql = """SELECT "id","geom" FROM "merc"."ocean""""
1061        "#;
1062        let cfg: TileServiceCfg = parse_config(CONFIG).unwrap();
1063        let SourceParamCfg::Postgis(ref source) = cfg.tilesets[0].source else {
1064            panic!("Wrong tileset source")
1065        };
1066        assert_eq!(source.layers[0].minzoom(), 3);
1067        assert_eq!(source.layers[0].maxzoom(42), 24);
1068        assert_eq!(source.layers[0].zoom_steps(&[]), vec![3, 10]);
1069    }
1070
1071    #[test]
1072    fn multi_crs_projected() {
1073        const CONFIG: &str = r#"
1074            [[grid]]
1075            json = "EqualEarthGreenwichWGS84Quad.json"
1076
1077            [[tileset]]
1078            name = "tracking"
1079
1080            [[tileset.tms]]
1081            id = "EqualEarthGreenwichWGS84Quad"
1082            maxzoom = 2
1083
1084            [[tileset.tms]]
1085            id = "WebMercatorQuad"
1086            minzoom = 3
1087
1088            [tileset.postgis]
1089            datasource = "tracking"
1090
1091            [[tileset.postgis.layer]]
1092            name = "waypoints"
1093            geometry_field = "geom"
1094            geometry_type = "POINT"
1095            srid = 4326
1096
1097            [[tileset.postgis.layer.query]]
1098            sql = """SELECT id, ts::TEXT, ST_Point(lon, lat, 4326) AS geom FROM gps.gpslog"""
1099        "#;
1100        let cfg: TileServiceCfg = parse_config(CONFIG).unwrap();
1101        let ts = &cfg.tilesets[0];
1102        let SourceParamCfg::Postgis(ref source) = ts.source else {
1103            panic!("Wrong tileset source")
1104        };
1105        assert_eq!(source.layers[0].minzoom(), 0);
1106        assert_eq!(source.layers[0].maxzoom(42), 42);
1107        assert_eq!(source.layers[0].zoom_steps(&[]), vec![0]);
1108        assert_eq!(source.layers[0].zoom_steps(&ts.tms), vec![0, 3]);
1109    }
1110
1111    #[test]
1112    fn multi_crs_unprojected() {
1113        const CONFIG: &str = r#"
1114            [[grid]]
1115            json = "EqualEarthGreenwichWGS84Quad.json"
1116
1117            [[tileset]]
1118            name = "ocean"
1119
1120            [[tileset.tms]]
1121            id = "EqualEarthGreenwichWGS84Quad"
1122            maxzoom = 2
1123
1124            [[tileset.tms]]
1125            id = "WebMercatorQuad"
1126            minzoom = 3
1127
1128            [tileset.postgis]
1129            datasource = "osmdb"
1130
1131            [[tileset.postgis.layer]]
1132            geometry_field = "geom"
1133            geometry_type = "POLYGON"
1134            name = "ocean"
1135            #srid = 8857 / 3857
1136
1137            [[tileset.postgis.layer.query]]
1138            minzoom = 0
1139            maxzoom = 2
1140            sql = """SELECT "geom" FROM "eq"."ocean_low""""
1141
1142            [[tileset.postgis.layer.query]]
1143            minzoom = 3
1144            maxzoom = 9
1145            sql = """SELECT "id","geom" FROM "merc"."ocean_low""""
1146        "#;
1147        let cfg: TileServiceCfg = parse_config(CONFIG).unwrap();
1148        let ts = &cfg.tilesets[0];
1149        let SourceParamCfg::Postgis(ref source) = ts.source else {
1150            panic!("Wrong tileset source")
1151        };
1152        assert_eq!(source.layers[0].minzoom(), 0);
1153        assert_eq!(source.layers[0].maxzoom(42), 9);
1154        assert_eq!(source.layers[0].zoom_steps(&[]), vec![0, 3]);
1155        assert_eq!(source.layers[0].zoom_steps(&ts.tms), vec![0, 3]);
1156    }
1157}