From 194a83e63f763323865a7f59e410e2931ce46e0a Mon Sep 17 00:00:00 2001 From: Stepan Kuzmin Date: Wed, 13 Oct 2021 14:51:29 +0300 Subject: [PATCH] feat: add minzoom and maxzoom support (#265) --- README.md | 71 +++++-- benches/sources.rs | 5 + src/composite_source.rs | 22 ++ src/dev.rs | 69 +++--- src/function_source.rs | 34 +++ src/server.rs | 30 ++- src/table_source.rs | 54 ++++- tests/config.yaml | 82 ++++++-- tests/debug.html | 123 ++++++----- tests/server_test.rs | 453 ++++++++++++++++++++++++++++++++++++++-- 10 files changed, 801 insertions(+), 142 deletions(-) diff --git a/README.md b/README.md index b5231da9..ec14abbb 100755 --- a/README.md +++ b/README.md @@ -390,8 +390,11 @@ martin --config config.yaml You can find an example of a configuration file [here](https://github.com/urbica/martin/blob/master/tests/config.yaml). ```yaml +# The socket address to bind [default: 0.0.0.0:3000] +listen_addresses: '0.0.0.0:3000' + # Database connection string -connection_string: 'postgres://postgres@localhost/db' +connection_string: 'postgres://postgres@localhost:5432/db' # Maximum connections pool size [default: 20] pool_size: 20 @@ -402,60 +405,84 @@ keep_alive: 75 # Number of web server workers worker_processes: 8 -# The socket address to bind [default: 0.0.0.0:3000] -listen_addresses: '0.0.0.0:3000' - # Enable watch mode -watch: true +watch: false # Trust invalid certificates. This introduces significant vulnerabilities, and should only be used as a last resort. danger_accept_invalid_certs: false -# associative arrays of table sources +# Associative arrays of table sources table_sources: public.table_source: - # table source id + # Table source id (required) id: public.table_source - # table schema + # Table schema (required) schema: public - # table name + # Table name (required) table: table_source - # geometry column name - geometry_column: geom - - # geometry srid + # Geometry SRID (required) srid: 4326 - # tile extent in tile coordinate space + # Geometry column name (required) + geometry_column: geom + + # Feature id column name + id_column: ~ + + # An integer specifying the minimum zoom level + minzoom: 0 + + # An integer specifying the maximum zoom level. MUST be >= minzoom + maxzoom: 30 + + # The maximum extent of available map tiles. Bounds MUST define an area + # covered by all zoom levels. The bounds are represented in WGS:84 + # latitude and longitude values, in the order left, bottom, right, top. + # Values may be integers or floating point numbers. + bounds: [-180.0, -90.0, 180.0, 90.0] + + # Tile extent in tile coordinate space extent: 4096 - # buffer distance in tile coordinate space to optionally clip geometries + # Buffer distance in tile coordinate space to optionally clip geometries buffer: 64 - # boolean to control if geometries should be clipped or encoded as is + # Boolean to control if geometries should be clipped or encoded as is clip_geom: true - # geometry type + # Geometry type geometry_type: GEOMETRY - # list of columns, that should be encoded as a tile properties + # List of columns, that should be encoded as tile properties (required) properties: gid: int4 -# associative arrays of function sources +# Associative arrays of function sources function_sources: public.function_source: - # function source id + # Function source id (required) id: public.function_source - # schema name + # Schema name (required) schema: public - # function name + # Function name (required) function: function_source + + # An integer specifying the minimum zoom level + minzoom: 0 + + # An integer specifying the maximum zoom level. MUST be >= minzoom + maxzoom: 30 + + # The maximum extent of available map tiles. Bounds MUST define an area + # covered by all zoom levels. The bounds are represented in WGS:84 + # latitude and longitude values, in the order left, bottom, right, top. + # Values may be integers or floating point numbers. + bounds: [-180.0, -90.0, 180.0, 90.0] ``` ## Using with Docker diff --git a/benches/sources.rs b/benches/sources.rs index 08d65e33..d193e04f 100644 --- a/benches/sources.rs +++ b/benches/sources.rs @@ -16,6 +16,8 @@ fn mock_table_source(schema: &str, table: &str) -> TableSource { table: table.to_owned(), id_column: None, geometry_column: "geom".to_owned(), + minzoom: None, + maxzoom: None, bounds: None, srid: 3857, extent: Some(4096), @@ -31,6 +33,9 @@ fn mock_function_source(schema: &str, function: &str) -> FunctionSource { id: format!("{}.{}", schema, function), schema: schema.to_owned(), function: function.to_owned(), + minzoom: None, + maxzoom: None, + bounds: None, } } diff --git a/src/composite_source.rs b/src/composite_source.rs index 044683f7..6cdc03d3 100644 --- a/src/composite_source.rs +++ b/src/composite_source.rs @@ -48,6 +48,20 @@ impl CompositeSource { format!("{} {}", bounds_cte, tile_query) } + pub fn get_minzoom(&self) -> Option { + self.table_sources + .iter() + .filter_map(|table_source| table_source.minzoom) + .min() + } + + pub fn get_maxzoom(&self) -> Option { + self.table_sources + .iter() + .filter_map(|table_source| table_source.maxzoom) + .max() + } + pub fn get_bounds(&self) -> Option> { self.table_sources .iter() @@ -75,6 +89,14 @@ impl Source for CompositeSource { tilejson_builder.scheme("xyz"); tilejson_builder.name(&self.id); + if let Some(minzoom) = self.get_minzoom() { + tilejson_builder.minzoom(minzoom); + }; + + if let Some(maxzoom) = self.get_maxzoom() { + tilejson_builder.maxzoom(maxzoom); + }; + if let Some(bounds) = self.get_bounds() { tilejson_builder.bounds(bounds); }; diff --git a/src/dev.rs b/src/dev.rs index 517b7513..061c9e82 100644 --- a/src/dev.rs +++ b/src/dev.rs @@ -12,13 +12,24 @@ use crate::function_source::{FunctionSource, FunctionSources}; use crate::server::AppState; use crate::table_source::{TableSource, TableSources}; -pub fn mock_table_sources() -> Option { +pub fn mock_table_sources(sources: Vec) -> TableSources { + let mut table_sources: TableSources = HashMap::new(); + for source in sources { + table_sources.insert(source.id.to_owned(), Box::new(source)); + } + + table_sources +} + +pub fn mock_default_table_sources() -> TableSources { let source = TableSource { id: "public.table_source".to_owned(), schema: "public".to_owned(), table: "table_source".to_owned(), id_column: None, geometry_column: "geom".to_owned(), + minzoom: Some(0), + maxzoom: Some(30), bounds: Some(vec![-180.0, -90.0, 180.0, 90.0]), srid: 4326, extent: Some(4096), @@ -34,6 +45,8 @@ pub fn mock_table_sources() -> Option { table: "points1".to_owned(), id_column: None, geometry_column: "geom".to_owned(), + minzoom: Some(0), + maxzoom: Some(30), bounds: Some(vec![-180.0, -90.0, 180.0, 90.0]), srid: 4326, extent: Some(4096), @@ -49,6 +62,8 @@ pub fn mock_table_sources() -> Option { table: "points2".to_owned(), id_column: None, geometry_column: "geom".to_owned(), + minzoom: Some(0), + maxzoom: Some(30), bounds: Some(vec![-180.0, -90.0, 180.0, 90.0]), srid: 4326, extent: Some(4096), @@ -64,6 +79,8 @@ pub fn mock_table_sources() -> Option { table: "points3857".to_owned(), id_column: None, geometry_column: "geom".to_owned(), + minzoom: Some(0), + maxzoom: Some(30), bounds: Some(vec![-180.0, -90.0, 180.0, 90.0]), srid: 3857, extent: Some(4096), @@ -73,36 +90,38 @@ pub fn mock_table_sources() -> Option { properties: HashMap::new(), }; - let mut table_sources: TableSources = HashMap::new(); - table_sources.insert("public.table_source".to_owned(), Box::new(source)); - table_sources.insert("public.points1".to_owned(), Box::new(table_source1)); - table_sources.insert("public.points2".to_owned(), Box::new(table_source2)); - table_sources.insert("public.points3857".to_owned(), Box::new(table_source3857)); - Some(table_sources) + mock_table_sources(vec![source, table_source1, table_source2, table_source3857]) } -pub fn mock_function_sources() -> Option { +pub fn mock_function_sources(sources: Vec) -> FunctionSources { let mut function_sources: FunctionSources = HashMap::new(); + for source in sources { + function_sources.insert(source.id.to_owned(), Box::new(source)); + } - function_sources.insert( - "public.function_source".to_owned(), - Box::new(FunctionSource { - id: "public.function_source".to_owned(), - schema: "public".to_owned(), - function: "function_source".to_owned(), - }), - ); + function_sources +} - function_sources.insert( - "public.function_source_query_params".to_owned(), - Box::new(FunctionSource { - id: "public.function_source_query_params".to_owned(), - schema: "public".to_owned(), - function: "function_source_query_params".to_owned(), - }), - ); +pub fn mock_default_function_sources() -> FunctionSources { + let function_source = FunctionSource { + id: "public.function_source".to_owned(), + schema: "public".to_owned(), + function: "function_source".to_owned(), + minzoom: Some(0), + maxzoom: Some(30), + bounds: Some(vec![-180.0, -90.0, 180.0, 90.0]), + }; - Some(function_sources) + let function_source_query_params = FunctionSource { + id: "public.function_source_query_params".to_owned(), + schema: "public".to_owned(), + function: "function_source_query_params".to_owned(), + minzoom: Some(0), + maxzoom: Some(30), + bounds: Some(vec![-180.0, -90.0, 180.0, 90.0]), + }; + + mock_function_sources(vec![function_source, function_source_query_params]) } pub fn make_pool() -> Pool { diff --git a/src/function_source.rs b/src/function_source.rs index 737a832b..07f624ba 100644 --- a/src/function_source.rs +++ b/src/function_source.rs @@ -11,9 +11,28 @@ use crate::utils::{prettify_error, query_to_json}; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct FunctionSource { + // Function source id pub id: String, + // Schema name pub schema: String, + + // Function name pub function: String, + + // An integer specifying the minimum zoom level + #[serde(skip_serializing_if = "Option::is_none")] + pub minzoom: Option, + + // An integer specifying the maximum zoom level. MUST be >= minzoom + #[serde(skip_serializing_if = "Option::is_none")] + pub maxzoom: Option, + + // The maximum extent of available map tiles. Bounds MUST define an area + // covered by all zoom levels. The bounds are represented in WGS:84 + // latitude and longitude values, in the order left, bottom, right, top. + // Values may be integers or floating point numbers. + #[serde(skip_serializing_if = "Option::is_none")] + pub bounds: Option>, } pub type FunctionSources = HashMap>; @@ -30,6 +49,18 @@ impl Source for FunctionSource { tilejson_builder.name(&self.id); tilejson_builder.tiles(vec![]); + if let Some(minzoom) = &self.minzoom { + tilejson_builder.minzoom(*minzoom); + }; + + if let Some(maxzoom) = &self.maxzoom { + tilejson_builder.maxzoom(*maxzoom); + }; + + if let Some(bounds) = &self.bounds { + tilejson_builder.bounds(bounds.to_vec()); + }; + Ok(tilejson_builder.finalize()) } @@ -100,6 +131,9 @@ pub fn get_function_sources(conn: &mut Connection) -> Result= minzoom.into()); + + let lte_maxzoom = source + .maxzoom + .map_or(true, |maxzoom| path.z <= maxzoom.into()); + + gte_minzoom && lte_maxzoom + }) .collect(); if sources.is_empty() { @@ -295,9 +306,22 @@ async fn get_function_source_tile( .clone() .ok_or_else(|| error::ErrorNotFound("There is no function sources"))?; - let source = function_sources.get(&path.source_id).ok_or_else(|| { - error::ErrorNotFound(format!("Function source '{}' not found", path.source_id)) - })?; + let source = function_sources + .get(&path.source_id) + .filter(|source| { + let gte_minzoom = source + .minzoom + .map_or(true, |minzoom| path.z >= minzoom.into()); + + let lte_maxzoom = source + .maxzoom + .map_or(true, |maxzoom| path.z <= maxzoom.into()); + + gte_minzoom && lte_maxzoom + }) + .ok_or_else(|| { + error::ErrorNotFound(format!("Function source '{}' not found", path.source_id)) + })?; let xyz = Xyz { z: path.z, diff --git a/src/table_source.rs b/src/table_source.rs index d57336cf..469a891a 100644 --- a/src/table_source.rs +++ b/src/table_source.rs @@ -10,17 +10,57 @@ use crate::utils; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TableSource { + // Table source id pub id: String, + + // Table schema pub schema: String, + + // Table name pub table: String, - pub id_column: Option, - pub geometry_column: String, + + // Geometry SRID pub srid: u32, + + // Geometry column name + pub geometry_column: String, + + // Feature id column name + #[serde(skip_serializing_if = "Option::is_none")] + pub id_column: Option, + + // An integer specifying the minimum zoom level + #[serde(skip_serializing_if = "Option::is_none")] + pub minzoom: Option, + + // An integer specifying the maximum zoom level. MUST be >= minzoom + #[serde(skip_serializing_if = "Option::is_none")] + pub maxzoom: Option, + + // The maximum extent of available map tiles. Bounds MUST define an area + // covered by all zoom levels. The bounds are represented in WGS:84 + // latitude and longitude values, in the order left, bottom, right, top. + // Values may be integers or floating point numbers. + #[serde(skip_serializing_if = "Option::is_none")] pub bounds: Option>, + + // Tile extent in tile coordinate space + #[serde(skip_serializing_if = "Option::is_none")] pub extent: Option, + + // Buffer distance in tile coordinate space to optionally clip geometries + #[serde(skip_serializing_if = "Option::is_none")] pub buffer: Option, + + // Boolean to control if geometries should be clipped or encoded as is + #[serde(skip_serializing_if = "Option::is_none")] pub clip_geom: Option, + + // Geometry type + #[serde(skip_serializing_if = "Option::is_none")] pub geometry_type: Option, + + // List of columns, that should be encoded as tile properties pub properties: HashMap, } @@ -93,6 +133,14 @@ impl Source for TableSource { tilejson_builder.scheme("xyz"); tilejson_builder.name(&self.id); + if let Some(minzoom) = &self.minzoom { + tilejson_builder.minzoom(*minzoom); + }; + + if let Some(maxzoom) = &self.maxzoom { + tilejson_builder.maxzoom(*maxzoom); + }; + if let Some(bounds) = &self.bounds { tilejson_builder.bounds(bounds.to_vec()); }; @@ -168,6 +216,8 @@ pub fn get_table_sources(conn: &mut Connection) -> Result= minzoom + maxzoom: 30 + + # The maximum extent of available map tiles. Bounds MUST define an area + # covered by all zoom levels. The bounds are represented in WGS:84 + # latitude and longitude values, in the order left, bottom, right, top. + # Values may be integers or floating point numbers. + bounds: [-180.0, -90.0, 180.0, 90.0] + + # Tile extent in tile coordinate space extent: 4096 - # buffer distance in tile coordinate space to optionally clip geometries + + # Buffer distance in tile coordinate space to optionally clip geometries buffer: 64 - # boolean to control if geometries should be clipped or encoded as is + + # Boolean to control if geometries should be clipped or encoded as is clip_geom: true - # geometry type + + # Geometry type geometry_type: GEOMETRY - # list of columns, that should be encoded as tile properties + + # List of columns, that should be encoded as tile properties (required) properties: gid: int4 @@ -49,6 +73,9 @@ table_sources: id: public.points1 schema: public table: points1 + minzoom: 0 + maxzoom: 30 + bounds: [-180.0, -90.0, 180.0, 90.0] id_column: ~ geometry_column: geom srid: 4326 @@ -63,6 +90,9 @@ table_sources: id: public.points2 schema: public table: points2 + minzoom: 0 + maxzoom: 30 + bounds: [-180.0, -90.0, 180.0, 90.0] id_column: ~ geometry_column: geom srid: 4326 @@ -77,6 +107,9 @@ table_sources: id: public.points3857 schema: public table: points3857 + minzoom: 0 + maxzoom: 30 + bounds: [-180.0, -90.0, 180.0, 90.0] id_column: ~ geometry_column: geom srid: 3857 @@ -87,17 +120,34 @@ table_sources: properties: gid: int4 -# associative arrays of function sources +# Associative arrays of function sources function_sources: public.function_source: - # function source id + # Function source id (required) id: public.function_source - # schema name + + # Schema name (required) schema: public - # function name + + # Function name (required) function: function_source + # An integer specifying the minimum zoom level + minzoom: 0 + + # An integer specifying the maximum zoom level. MUST be >= minzoom + maxzoom: 30 + + # The maximum extent of available map tiles. Bounds MUST define an area + # covered by all zoom levels. The bounds are represented in WGS:84 + # latitude and longitude values, in the order left, bottom, right, top. + # Values may be integers or floating point numbers. + bounds: [-180.0, -90.0, 180.0, 90.0] + public.function_source_query_params: id: public.function_source_query_params schema: public function: function_source_query_params + minzoom: 0 + maxzoom: 30 + bounds: [-180.0, -90.0, 180.0, 90.0] diff --git a/tests/debug.html b/tests/debug.html index 39f3e060..ea206193 100644 --- a/tests/debug.html +++ b/tests/debug.html @@ -31,8 +31,8 @@ position: absolute; z-index: 1; top: 10px; - right: 10px; bottom: 10px; + left: 10px; border-radius: 3px; width: 120px; font-family: 'Open Sans', sans-serif; @@ -112,72 +112,81 @@ case 'MULTISURFACE': return 'fill'; default: - throw new Error(`Unknown geometry_type ${source.geometry_type}`); + return 'circle'; } } - map.on('load', function () { - fetch('http://0.0.0.0:3000/index.json') - .then((r) => r.json()) - .then((sources) => { - // Set up the corresponding toggle button for each layer. - for (const sourceId of Object.keys(sources).sort()) { - // Skip layers that already have a button set up. - if (document.getElementById(sourceId)) { - continue; - } + map.on('load', async function () { + const table_sources = await fetch( + 'http://0.0.0.0:3000/index.json' + ).then((r) => r.json()); - const source = sources[sourceId]; - const layerType = geometryTypeToLayerType(source.geometry_type); + const function_sources = await fetch( + 'http://0.0.0.0:3000/rpc/index.json' + ).then((r) => r.json()); - map.addLayer({ - id: sourceId, - type: layerType, - source: { - type: 'vector', - url: `http://0.0.0.0:3000/${sourceId}.json` - }, - 'source-layer': sourceId, - layout: { - visibility: 'none' - }, - paint: { - [`${layerType}-color`]: 'red' - } - }); + const sources = Object.values(table_sources).concat( + Object.values(function_sources) + ); - // Create a link. - const link = document.createElement('a'); - link.id = sourceId; - link.href = '#'; - link.textContent = sourceId; - link.title = sourceId; + // Set up the corresponding toggle button for each layer. + for (const source of sources) { + // Skip layers that already have a button set up. + if (document.getElementById(source.id)) { + continue; + } - // Show or hide layer when the toggle is clicked. - link.onclick = function (e) { - const clickedLayer = this.textContent; - e.preventDefault(); - e.stopPropagation(); + const layerType = geometryTypeToLayerType(source.geometry_type); - const visibility = map.getLayoutProperty( - clickedLayer, - 'visibility' - ); - - // Toggle layer visibility by changing the layout object's visibility property. - if (visibility === 'visible') { - map.setLayoutProperty(clickedLayer, 'visibility', 'none'); - this.className = ''; - } else { - this.className = 'active'; - map.setLayoutProperty(clickedLayer, 'visibility', 'visible'); - } - }; - - const layers = document.getElementById('menu'); - layers.appendChild(link); + map.addLayer({ + id: source.id, + type: layerType, + source: { + type: 'vector', + url: source.hasOwnProperty('table') + ? `http://0.0.0.0:3000/${source.id}.json` + : `http://0.0.0.0:3000/rpc/${source.id}.json` + }, + 'source-layer': source.id, + layout: { + visibility: 'none' + }, + paint: { + [`${layerType}-color`]: 'red' } }); + + // Create a link. + const link = document.createElement('a'); + link.id = source.id; + link.href = '#'; + link.textContent = source.id; + link.title = source.id; + + // Show or hide layer when the toggle is clicked. + link.onclick = function (e) { + const clickedLayer = this.textContent; + e.preventDefault(); + e.stopPropagation(); + + const visibility = map.getLayoutProperty( + clickedLayer, + 'visibility' + ); + + // Toggle layer visibility by changing the layout object's visibility property. + if (visibility === 'visible') { + map.setLayoutProperty(clickedLayer, 'visibility', 'none'); + this.className = ''; + } else { + this.className = 'active'; + map.setLayoutProperty(clickedLayer, 'visibility', 'visible'); + } + }; + + const layers = document.getElementById('menu'); + layers.appendChild(link); + } }); diff --git a/tests/server_test.rs b/tests/server_test.rs index cb937cb1..f8bcb1d9 100644 --- a/tests/server_test.rs +++ b/tests/server_test.rs @@ -1,11 +1,14 @@ +use std::collections::HashMap; + extern crate log; use actix_web::{http, test, App}; +use tilejson::TileJSON; -use martin::dev::{mock_function_sources, mock_state, mock_table_sources}; -use martin::function_source::FunctionSources; +use martin::dev; +use martin::function_source::{FunctionSource, FunctionSources}; use martin::server::router; -use martin::table_source::TableSources; +use martin::table_source::{TableSource, TableSources}; fn init() { let _ = env_logger::builder().is_test(true).try_init(); @@ -15,7 +18,7 @@ fn init() { async fn test_get_table_sources_ok() { init(); - let state = mock_state(mock_table_sources(), None, false); + let state = dev::mock_state(Some(dev::mock_default_table_sources()), None, false); let mut app = test::init_service(App::new().data(state).configure(router)).await; let req = test::TestRequest::get().uri("/index.json").to_request(); @@ -31,7 +34,7 @@ async fn test_get_table_sources_ok() { async fn test_get_table_sources_watch_mode_ok() { init(); - let state = mock_state(mock_table_sources(), None, true); + let state = dev::mock_state(Some(dev::mock_default_table_sources()), None, true); let mut app = test::init_service(App::new().data(state).configure(router)).await; let req = test::TestRequest::get().uri("/index.json").to_request(); @@ -47,7 +50,28 @@ async fn test_get_table_sources_watch_mode_ok() { async fn test_get_table_source_ok() { init(); - let state = mock_state(mock_table_sources(), None, false); + let table_source = TableSource { + id: "public.table_source".to_owned(), + schema: "public".to_owned(), + table: "table_source".to_owned(), + id_column: None, + geometry_column: "geom".to_owned(), + bounds: Some(vec![-180.0, -90.0, 180.0, 90.0]), + minzoom: Some(0), + maxzoom: Some(30), + srid: 4326, + extent: Some(4096), + buffer: Some(64), + clip_geom: Some(true), + geometry_type: None, + properties: HashMap::new(), + }; + + let state = dev::mock_state( + Some(dev::mock_table_sources(vec![table_source])), + None, + false, + ); let mut app = test::init_service(App::new().data(state).configure(router)).await; let req = test::TestRequest::get() @@ -61,15 +85,25 @@ async fn test_get_table_source_ok() { .uri("/public.table_source.json") .to_request(); - let response = test::call_service(&mut app, req).await; - assert!(response.status().is_success()); + let result: TileJSON = test::read_response_json(&mut app, req).await; + + println!("{:?}", result); + + assert_eq!(result.name, Some(String::from("public.table_source"))); + assert_eq!( + result.tiles, + vec!["http://localhost:8080/public.table_source/{z}/{x}/{y}.pbf"] + ); + assert_eq!(result.minzoom, Some(0)); + assert_eq!(result.maxzoom, Some(30)); + assert_eq!(result.bounds, Some(vec![-180.0, -90.0, 180.0, 90.0])); } #[actix_rt::test] async fn test_get_table_source_tile_ok() { init(); - let state = mock_state(mock_table_sources(), None, false); + let state = dev::mock_state(Some(dev::mock_default_table_sources()), None, false); let mut app = test::init_service(App::new().data(state).configure(router)).await; let req = test::TestRequest::get() @@ -87,11 +121,193 @@ async fn test_get_table_source_tile_ok() { assert!(response.status().is_success()); } +#[actix_rt::test] +async fn test_get_table_source_tile_minmax_zoom_ok() { + init(); + + let points1 = TableSource { + id: "public.points1".to_owned(), + schema: "public".to_owned(), + table: "points1".to_owned(), + id_column: None, + geometry_column: "geom".to_owned(), + bounds: Some(vec![-180.0, -90.0, 180.0, 90.0]), + minzoom: Some(6), + maxzoom: Some(12), + srid: 4326, + extent: Some(4096), + buffer: Some(64), + clip_geom: Some(true), + geometry_type: None, + properties: HashMap::new(), + }; + + let points2 = TableSource { + id: "public.points2".to_owned(), + schema: "public".to_owned(), + table: "points2".to_owned(), + id_column: None, + geometry_column: "geom".to_owned(), + bounds: Some(vec![-180.0, -90.0, 180.0, 90.0]), + minzoom: None, + maxzoom: None, + srid: 4326, + extent: Some(4096), + buffer: Some(64), + clip_geom: Some(true), + geometry_type: None, + properties: HashMap::new(), + }; + + let points3857 = TableSource { + id: "public.points3857".to_owned(), + schema: "public".to_owned(), + table: "points3857".to_owned(), + id_column: None, + geometry_column: "geom".to_owned(), + bounds: Some(vec![-180.0, -90.0, 180.0, 90.0]), + minzoom: Some(6), + maxzoom: None, + srid: 4326, + extent: Some(4096), + buffer: Some(64), + clip_geom: Some(true), + geometry_type: None, + properties: HashMap::new(), + }; + + let table_source = TableSource { + id: "public.table_source".to_owned(), + schema: "public".to_owned(), + table: "table_source".to_owned(), + id_column: None, + geometry_column: "geom".to_owned(), + bounds: Some(vec![-180.0, -90.0, 180.0, 90.0]), + minzoom: None, + maxzoom: Some(6), + srid: 4326, + extent: Some(4096), + buffer: Some(64), + clip_geom: Some(true), + geometry_type: None, + properties: HashMap::new(), + }; + + let state = dev::mock_state( + Some(dev::mock_table_sources(vec![ + points1, + points2, + points3857, + table_source, + ])), + None, + false, + ); + + let mut app = test::init_service(App::new().data(state).configure(router)).await; + + // zoom = 0 (nothing) + let req = test::TestRequest::get() + .uri("/public.points1/0/0/0.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert_eq!(response.status(), http::StatusCode::NOT_FOUND); + + // zoom = 6 (public.points1) + let req = test::TestRequest::get() + .uri("/public.points1/6/38/20.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 12 (public.points1) + let req = test::TestRequest::get() + .uri("/public.points1/12/2476/1280.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 13 (nothing) + let req = test::TestRequest::get() + .uri("/public.points1/13/4952/2560.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert_eq!(response.status(), http::StatusCode::NOT_FOUND); + + // zoom = 0 (public.points2) + let req = test::TestRequest::get() + .uri("/public.points2/0/0/0.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 6 (public.points2) + let req = test::TestRequest::get() + .uri("/public.points2/6/38/20.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 12 (public.points2) + let req = test::TestRequest::get() + .uri("/public.points2/12/2476/1280.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 13 (public.points2) + let req = test::TestRequest::get() + .uri("/public.points2/13/4952/2560.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 0 (nothing) + let req = test::TestRequest::get() + .uri("/public.points3857/0/0/0.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert_eq!(response.status(), http::StatusCode::NOT_FOUND); + + // zoom = 12 (public.points3857) + let req = test::TestRequest::get() + .uri("/public.points3857/12/2476/1280.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 0 (public.table_source) + let req = test::TestRequest::get() + .uri("/public.table_source/0/0/0.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 12 (nothing) + let req = test::TestRequest::get() + .uri("/public.table_source/12/2476/1280.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert_eq!(response.status(), http::StatusCode::NOT_FOUND); +} + #[actix_rt::test] async fn test_get_composite_source_ok() { init(); - let state = mock_state(mock_table_sources(), None, false); + let state = dev::mock_state(Some(dev::mock_default_table_sources()), None, false); let mut app = test::init_service(App::new().data(state).configure(router)).await; let req = test::TestRequest::get() @@ -113,7 +329,7 @@ async fn test_get_composite_source_ok() { async fn test_get_composite_source_tile_ok() { init(); - let state = mock_state(mock_table_sources(), None, false); + let state = dev::mock_state(Some(dev::mock_default_table_sources()), None, false); let mut app = test::init_service(App::new().data(state).configure(router)).await; let req = test::TestRequest::get() @@ -131,11 +347,116 @@ async fn test_get_composite_source_tile_ok() { assert!(response.status().is_success()); } +#[actix_rt::test] +async fn test_get_composite_source_tile_minmax_zoom_ok() { + init(); + + let public_points1 = TableSource { + id: "public.points1".to_owned(), + schema: "public".to_owned(), + table: "points1".to_owned(), + id_column: None, + geometry_column: "geom".to_owned(), + bounds: Some(vec![-180.0, -90.0, 180.0, 90.0]), + minzoom: Some(6), + maxzoom: Some(13), + srid: 4326, + extent: Some(4096), + buffer: Some(64), + clip_geom: Some(true), + geometry_type: None, + properties: HashMap::new(), + }; + + let public_points2 = TableSource { + id: "public.points2".to_owned(), + schema: "public".to_owned(), + table: "points2".to_owned(), + id_column: None, + geometry_column: "geom".to_owned(), + bounds: Some(vec![-180.0, -90.0, 180.0, 90.0]), + minzoom: Some(13), + maxzoom: Some(20), + srid: 4326, + extent: Some(4096), + buffer: Some(64), + clip_geom: Some(true), + geometry_type: None, + properties: HashMap::new(), + }; + + let state = dev::mock_state( + Some(dev::mock_table_sources(vec![ + public_points1, + public_points2, + ])), + None, + false, + ); + let mut app = test::init_service(App::new().data(state).configure(router)).await; + + // zoom = 0 (nothing) + let req = test::TestRequest::get() + .uri("/public.points1,public.points2/0/0/0.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert_eq!(response.status(), http::StatusCode::NOT_FOUND); + + // zoom = 6 (public.points1) + let req = test::TestRequest::get() + .uri("/public.points1,public.points2/6/38/20.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 12 (public.points1) + let req = test::TestRequest::get() + .uri("/public.points1,public.points2/12/2476/1280.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 13 (public.points1, public.points2) + let req = test::TestRequest::get() + .uri("/public.points1,public.points2/13/4952/2560.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 14 (public.points2) + let req = test::TestRequest::get() + .uri("/public.points1,public.points2/14/9904/5121.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 20 (public.points2) + let req = test::TestRequest::get() + .uri("/public.points1,public.points2/20/633856/327787.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 21 (nothing) + let req = test::TestRequest::get() + .uri("/public.points1,public.points2/21/1267712/655574.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert_eq!(response.status(), http::StatusCode::NOT_FOUND); +} + #[actix_rt::test] async fn test_get_function_sources_ok() { init(); - let state = mock_state(None, mock_function_sources(), false); + let state = dev::mock_state(None, Some(dev::mock_default_function_sources()), false); let mut app = test::init_service(App::new().data(state).configure(router)).await; let req = test::TestRequest::get().uri("/rpc/index.json").to_request(); @@ -151,7 +472,7 @@ async fn test_get_function_sources_ok() { async fn test_get_function_sources_watch_mode_ok() { init(); - let state = mock_state(None, mock_function_sources(), true); + let state = dev::mock_state(None, Some(dev::mock_default_function_sources()), true); let mut app = test::init_service(App::new().data(state).configure(router)).await; let req = test::TestRequest::get().uri("/rpc/index.json").to_request(); @@ -167,7 +488,7 @@ async fn test_get_function_sources_watch_mode_ok() { async fn test_get_function_source_ok() { init(); - let state = mock_state(None, mock_function_sources(), false); + let state = dev::mock_state(None, Some(dev::mock_default_function_sources()), false); let mut app = test::init_service(App::new().data(state).configure(router)).await; let req = test::TestRequest::get() @@ -189,7 +510,7 @@ async fn test_get_function_source_ok() { async fn test_get_function_source_tile_ok() { init(); - let state = mock_state(None, mock_function_sources(), false); + let state = dev::mock_state(None, Some(dev::mock_default_function_sources()), false); let mut app = test::init_service(App::new().data(state).configure(router)).await; let req = test::TestRequest::get() @@ -200,11 +521,109 @@ async fn test_get_function_source_tile_ok() { assert!(response.status().is_success()); } +#[actix_rt::test] +async fn test_get_function_source_tile_minmax_zoom_ok() { + init(); + + let function_source1 = FunctionSource { + id: "public.function_source1".to_owned(), + schema: "public".to_owned(), + function: "function_source".to_owned(), + minzoom: None, + maxzoom: None, + bounds: Some(vec![-180.0, -90.0, 180.0, 90.0]), + }; + + let function_source2 = FunctionSource { + id: "public.function_source2".to_owned(), + schema: "public".to_owned(), + function: "function_source".to_owned(), + minzoom: Some(6), + maxzoom: Some(12), + bounds: Some(vec![-180.0, -90.0, 180.0, 90.0]), + }; + + let state = dev::mock_state( + None, + Some(dev::mock_function_sources(vec![ + function_source1, + function_source2, + ])), + false, + ); + + let mut app = test::init_service(App::new().data(state).configure(router)).await; + + // zoom = 0 (public.function_source1) + let req = test::TestRequest::get() + .uri("/rpc/public.function_source1/0/0/0.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 6 (public.function_source1) + let req = test::TestRequest::get() + .uri("/rpc/public.function_source1/6/38/20.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 12 (public.function_source1) + let req = test::TestRequest::get() + .uri("/rpc/public.function_source1/12/2476/1280.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 13 (public.function_source1) + let req = test::TestRequest::get() + .uri("/rpc/public.function_source1/13/4952/2560.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 0 (nothing) + let req = test::TestRequest::get() + .uri("/rpc/public.function_source2/0/0/0.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert_eq!(response.status(), http::StatusCode::NOT_FOUND); + + // zoom = 6 (public.function_source2) + let req = test::TestRequest::get() + .uri("/rpc/public.function_source2/6/38/20.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 12 (public.function_source2) + let req = test::TestRequest::get() + .uri("/rpc/public.function_source2/12/2476/1280.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert!(response.status().is_success()); + + // zoom = 13 (nothing) + let req = test::TestRequest::get() + .uri("/rpc/public.function_source2/13/4952/2560.pbf") + .to_request(); + + let response = test::call_service(&mut app, req).await; + assert_eq!(response.status(), http::StatusCode::NOT_FOUND); +} + #[actix_rt::test] async fn test_get_function_source_query_params_ok() { init(); - let state = mock_state(None, mock_function_sources(), false); + let state = dev::mock_state(None, Some(dev::mock_default_function_sources()), false); let mut app = test::init_service(App::new().data(state).configure(router)).await; let req = test::TestRequest::get() @@ -227,7 +646,7 @@ async fn test_get_function_source_query_params_ok() { async fn test_get_health_returns_ok() { init(); - let state = mock_state(None, mock_function_sources(), false); + let state = dev::mock_state(None, Some(dev::mock_default_function_sources()), false); let mut app = test::init_service(App::new().data(state).configure(router)).await; let req = test::TestRequest::get().uri("/healthz").to_request();