From c826581f5cc0cbcc51f4ad42b1d6eeb0d4b6a0f8 Mon Sep 17 00:00:00 2001 From: Michael Kreil Date: Thu, 2 Jan 2025 01:48:51 +0100 Subject: [PATCH] test: tilejson --- .../src/container/mbtiles/reader.rs | 3 - .../src/types/tile_bbox_pyramid.rs | 2 +- versatiles_core/src/utils/tilejson/mod.rs | 234 ++++++++-- versatiles_core/src/utils/tilejson/value.rs | 225 ++++++++-- .../src/utils/tilejson/vector_layer.rs | 404 ++++++++++++++---- 5 files changed, 710 insertions(+), 158 deletions(-) diff --git a/versatiles_container/src/container/mbtiles/reader.rs b/versatiles_container/src/container/mbtiles/reader.rs index 58067501..84584854 100644 --- a/versatiles_container/src/container/mbtiles/reader.rs +++ b/versatiles_container/src/container/mbtiles/reader.rs @@ -164,9 +164,6 @@ impl MBTilesReader { let vector_layers = object .get("vector_layers") .with_context(|| anyhow!("expected 'vector_layers'"))?; - let vector_layers = vector_layers - .as_array() - .with_context(|| anyhow!("expected 'vector_layers' as array"))?; self.tilejson.set_vector_layers(vector_layers)?; } _ => {} diff --git a/versatiles_core/src/types/tile_bbox_pyramid.rs b/versatiles_core/src/types/tile_bbox_pyramid.rs index fd9feb15..390b1b47 100644 --- a/versatiles_core/src/types/tile_bbox_pyramid.rs +++ b/versatiles_core/src/types/tile_bbox_pyramid.rs @@ -55,7 +55,7 @@ impl TileBBoxPyramid { /// * `geo_bbox` - A reference to an array of four `f64` values representing the geographical bounding box. pub fn from_geo_bbox(zoom_level_min: u8, zoom_level_max: u8, bbox: &GeoBBox) -> TileBBoxPyramid { let mut pyramid = TileBBoxPyramid::new_empty(); - for z in zoom_level_min..zoom_level_max { + for z in zoom_level_min..=zoom_level_max { pyramid.set_level_bbox(TileBBox::from_geo(z, bbox).unwrap()); } pyramid diff --git a/versatiles_core/src/utils/tilejson/mod.rs b/versatiles_core/src/utils/tilejson/mod.rs index 8fc67eda..975a3056 100644 --- a/versatiles_core/src/utils/tilejson/mod.rs +++ b/versatiles_core/src/utils/tilejson/mod.rs @@ -1,19 +1,16 @@ mod value; mod vector_layer; -use std::fmt::Debug; - use crate::{ types::{Blob, GeoBBox, GeoCenter, TileBBoxPyramid}, utils::{parse_json_str, JsonObject, JsonValue}, }; -use anyhow::{ensure, Result}; +use anyhow::{anyhow, ensure, Result}; use regex::Regex; +use std::fmt::Debug; use value::TileJsonValues; use vector_layer::VectorLayers; -use super::JsonArray; - /// A struct representing a TileJSON object. /// /// Fields: @@ -23,21 +20,26 @@ use super::JsonArray; /// - `vector_layers`: A structured set of vector layer definitions. #[derive(Clone, PartialEq, Default)] pub struct TileJSON { + /// Geographic bounding box. If `Some`, `[west, south, east, north]`. pub bounds: Option, + /// Geographic center. If `Some`, `[longitude, latitude, zoom_level]`. pub center: Option, + /// Other TileJSON fields not explicitly tracked by `TileJSON`. pub values: TileJsonValues, + /// The collection of vector layers, if any. pub vector_layers: VectorLayers, } impl TileJSON { /// Constructs a `TileJSON` from a [`JsonObject`]. /// - /// The method looks for special keys: `"bounds"`, `"center"`, and `"vector_layers"`. - /// All other keys are placed into `self.values`. + /// Looks for special keys: `"bounds"`, `"center"`, and `"vector_layers"`. + /// All other keys go into `self.values`. /// /// # Errors /// - /// Returns an error if any required fields are invalid or cannot be converted. + /// Returns an error if any of these keys are present but invalid + /// (e.g. cannot be parsed to `GeoBBox`), or if required fields are missing. pub fn from_object(object: &JsonObject) -> Result { let mut r = TileJSON::default(); for (k, v) in object.iter() { @@ -53,8 +55,9 @@ impl TileJSON { r.center = Some(GeoCenter::try_from(arr)?); } "vector_layers" => { - // Convert the "vector_layers" array to a VectorLayers - r.vector_layers = VectorLayers::from_json_array(v.as_array()?)?; + // Convert the "vector_layers" array/object into VectorLayers + r.vector_layers = + VectorLayers::from_json(v).map_err(|e| anyhow!("Failed to parse 'vector_layers': {e}"))?; } _ => { // Everything else goes into `values` @@ -83,14 +86,21 @@ impl TileJSON { obj } + /// Converts this `TileJSON` to a pretty-printed JSON string. pub fn as_string(&self) -> String { self.as_object().stringify() } + /// Converts this `TileJSON` to a `Blob`. pub fn as_blob(&self) -> Blob { Blob::from(self.as_string()) } + /// Updates this `TileJSON` based on a [`TileBBoxPyramid`]. + /// + /// - If `pyramid` includes a `GeoBBox`, calls [`limit_bbox`]. + /// - If `pyramid` includes `zoom_min`, calls [`limit_min_zoom`]. + /// - If `pyramid` includes `zoom_max`, calls [`limit_max_zoom`]. pub fn update_from_pyramid(&mut self, pyramid: &TileBBoxPyramid) { if let Some(bbox) = pyramid.get_geo_bbox() { self.limit_bbox(bbox); @@ -105,31 +115,42 @@ impl TileJSON { } } + /// Returns a `String` value from `self.values`, if available. pub fn get_string(&self, key: &str) -> Option { self.values.get_string(key) } + /// Returns a string slice from `self.values`, if available. pub fn get_str(&self, key: &str) -> Option<&str> { self.values.get_str(key) } + /// Sets a byte (`u8`) value in `self.values`. pub fn set_byte(&mut self, key: &str, value: u8) -> Result<()> { self.values.insert(key, &JsonValue::from(value)) } + /// Sets a list (`Vec`) value in `self.values`. pub fn set_list(&mut self, key: &str, value: Vec) -> Result<()> { self.values.insert(key, &JsonValue::from(value)) } + /// Sets a string value in `self.values`. pub fn set_string(&mut self, key: &str, value: &str) -> Result<()> { self.values.insert(key, &JsonValue::from(value)) } - pub fn set_vector_layers(&mut self, vector_layers: &JsonArray) -> Result<()> { - self.vector_layers = VectorLayers::from_json_array(vector_layers).expect("when parsing `vector_layers`"); + /// Parses and sets vector layers from a [`JsonValue`]. + /// + /// # Errors + /// + /// Fails if the `JsonValue` cannot be converted to valid `VectorLayers`. + pub fn set_vector_layers(&mut self, json: &JsonValue) -> Result<()> { + self.vector_layers = VectorLayers::from_json(json).map_err(|e| anyhow!("Failed to parse vector layers: {e}"))?; Ok(()) } + /// Intersects the current bounding box with `bbox`, if one exists; otherwise sets it. pub fn limit_bbox(&mut self, bbox: GeoBBox) { if let Some(ref mut b) = self.bounds { b.intersect(&bbox); @@ -138,29 +159,39 @@ impl TileJSON { } } + /// Raises the `minzoom` value to `z` if the current `minzoom` is lower (or absent). + /// + /// Example: if `minzoom` was 3 and `z=5`, then new `minzoom` becomes 5. pub fn limit_min_zoom(&mut self, z: u8) { self.values.update_byte("minzoom", |mz| mz.map_or(z, |mz| mz.max(z))); } + /// Lowers the `maxzoom` value to `z` if the current `maxzoom` is higher (or absent). + /// + /// Example: if `maxzoom` was 15 and `z=10`, then new `maxzoom` becomes 10. pub fn limit_max_zoom(&mut self, z: u8) { self.values.update_byte("maxzoom", |mz| mz.map_or(z, |mz| mz.min(z))); } - /// **Fixed merge method** that merges another `TileJSON` into this one. + /// Merges another `TileJSON` into this one. /// /// - **Bounds:** - /// - If `other` has a `bounds`, extends or sets ours accordingly. + /// If `other` has a `bounds`, extended or sets ours. /// - **Center:** - /// - If `other.center` is `Some`, it overwrites ours. + /// Overwrites if `other.center` is `Some`. /// - **minzoom/maxzoom:** - /// - These are stored in `self.values` under the keys `"minzoom"` and `"maxzoom"`. - /// - Merges them by taking the min or max respectively. + /// Take the min or max of the two. (By spec, these are `[0..30]`.) /// - **Other `values`:** - /// - Merges all other key-value pairs, overwriting if there is a conflict. + /// Merge everything else, overwriting where conflicts arise. /// - **Vector layers:** - /// - Merges all vector layers from `other`. If a layer `id` already exists, it will be overwritten by the `other` one. + /// Merges all vector layers from `other`. Overwrites existing layers if IDs match. + /// + /// # Errors + /// + /// Returns any error that might arise from inserting the merged values + /// into `self.values`. pub fn merge(&mut self, other: &TileJSON) -> Result<()> { - // 1. Merge bounds: extend or set if we or `other` has them + // 1. Merge bounds if let Some(ob) = &other.bounds { self.bounds = match &self.bounds { Some(sb) => Some(sb.extended(ob)), @@ -168,27 +199,25 @@ impl TileJSON { }; } - // 2. Overwrite center if `other` has it + // 2. Overwrite center if other.center.is_some() { self.center = other.center; } - // 3. Merge minzoom & maxzoom from `values` - // By spec, these are bytes (0..=30). - if let Some(omz) = other.values.get_byte("minzoom") { - let new_mz = self.values.get_byte("minzoom").map_or(omz, |mz| mz.min(omz)); - self.values.insert("minzoom", &JsonValue::from(new_mz))?; + // 3. Merge minzoom / maxzoom + if let Some(omiz) = other.values.get_byte("minzoom") { + let miz = self.values.get_byte("minzoom").map_or(omiz, |mz| mz.min(omiz)); + self.values.insert("minzoom", &JsonValue::from(miz))?; } - if let Some(omz) = other.values.get_byte("maxzoom") { - let new_mz = self.values.get_byte("maxzoom").map_or(omz, |mz| mz.max(omz)); - self.values.insert("maxzoom", &JsonValue::from(new_mz))?; + if let Some(omaz) = other.values.get_byte("maxzoom") { + let maz = self.values.get_byte("maxzoom").map_or(omaz, |mz| mz.max(omaz)); + self.values.insert("maxzoom", &JsonValue::from(maz))?; } - // 4. Merge all other values from `other`, overwriting conflicts. - // Exclude "minzoom"/"maxzoom" since we already handled those. + // 4. Merge everything else for (k, v) in other.values.iter_json_values() { if k != "minzoom" && k != "maxzoom" { - let _ = self.values.insert(&k, &v); + self.values.insert(&k, &v)?; } } @@ -203,15 +232,19 @@ impl TileJSON { self.as_object().stringify() } - /// Performs basic TileJSON checks (common to both raster and vector) based on the spec. + /// Performs basic checks (common to both raster and vector) based on the TileJSON 3.0.0 spec. + /// + /// Ensures that: + /// - `"tilejson"` exists and matches the pattern `^[123]\.[012]\.[01]$` + /// - `"tiles"`, `"attribution"`, `"data"`, `"description"`, `"grids"`, `"legend"`, + /// `"name"`, `"scheme"`, `"template"` and others are optionally valid if present. + /// - `bounds` and `center` are in valid range if present. fn check_basics(&self) -> Result<()> { - // TileJSON 3.0.0: https://github.com/mapbox/tilejson-spec/tree/master/3.0.0 - // 3.1 tilejson - required let version = self .values .get_string("tilejson") - .ok_or_else(|| anyhow::anyhow!("Missing tilejson"))?; + .ok_or_else(|| anyhow!("Missing tilejson"))?; ensure!( Regex::new(r"^[123]\.[012]\.[01]$")?.is_match(&version), "Invalid tilejson version" @@ -220,7 +253,7 @@ impl TileJSON { // 3.2 tiles - optional self.values.check_optional_list("tiles")?; - // 3.3 vector_layers - is checked in check_vector() or check_raster() + // 3.3 vector_layers - is validated separately in check_vector() or check_raster() // 3.4 attribution - optional self.values.check_optional_string("attribution")?; @@ -289,7 +322,7 @@ impl TileJSON { /// Checks that this `TileJSON` represents a valid **vector** tileset. /// /// - Must pass `check_basics()`. - /// - Must have at least one `vector_layer`. + /// - Must have at least one vector layer. /// - The layers themselves must pass their checks. pub fn check_vector(&self) -> Result<()> { self.check_basics()?; @@ -352,6 +385,131 @@ impl From<&TileJSON> for Blob { impl Debug for TileJSON { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Provide a short debug containing the JSON representation write!(f, "TileJSON({})", self.as_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper function to build a basic TileJSON structure as a JSON object. + fn make_test_json_object() -> JsonObject { + let mut obj = JsonObject::default(); + // Must have "tilejson" + obj.set("tilejson", JsonValue::from("3.0.0")); + // Minimal valid fields + obj.set("center", JsonValue::from(vec![100.0, 50.0, 3.0])); + obj.set("bounds", JsonValue::from(vec![-180.0, -90.0, 180.0, 90.0])); + obj + } + + #[test] + fn test_from_object_basic() -> Result<()> { + let obj = make_test_json_object(); + let tj = TileJSON::from_object(&obj)?; + assert!(tj.bounds.is_some()); + assert!(tj.center.is_some()); + assert_eq!(tj.values.get_string("tilejson"), Some("3.0.0".to_string())); + Ok(()) + } + + #[test] + fn test_check_raster_ok() -> Result<()> { + // Raster TileJSON must have tilejson and no vector_layers + let obj = make_test_json_object(); + let tj = TileJSON::from_object(&obj)?; + // By default, vector_layers is empty, so check_raster should pass + assert!(tj.check_raster().is_ok()); + Ok(()) + } + + #[test] + fn test_check_vector_fails_if_no_layers() -> Result<()> { + // Vector must have layers -> fails + let obj = make_test_json_object(); + let tj = TileJSON::from_object(&obj)?; + assert!(tj.check_vector().is_err()); + Ok(()) + } + + #[test] + fn test_merge_minmaxzoom() -> Result<()> { + let mut tj1 = TileJSON::default(); + tj1.set_byte("minzoom", 5)?; + tj1.set_byte("maxzoom", 15)?; + + let mut tj2 = TileJSON::default(); + tj2.set_byte("minzoom", 2)?; + tj2.set_byte("maxzoom", 20)?; + + tj1.merge(&tj2)?; + // minzoom should be min(5,2)=2, maxzoom= max(15,20)=20 + assert_eq!(tj1.values.get_byte("minzoom"), Some(2)); + assert_eq!(tj1.values.get_byte("maxzoom"), Some(20)); + Ok(()) + } + + #[test] + fn test_limit_bbox() { + let mut tj = TileJSON::default(); + let existing = GeoBBox(-10.0, -5.0, 10.0, 5.0); + let newbox = GeoBBox(-15.0, -10.0, 0.0, 2.0); + tj.bounds = Some(existing); + tj.limit_bbox(newbox); + // Intersection of existing and new => [-10, -5, 0, 2] + let b = tj.bounds.unwrap(); + assert_eq!(b.as_array(), [-10.0, -5.0, 0.0, 2.0]); + } + + #[test] + fn test_update_from_pyramid() { + let mut tj = TileJSON::default(); + // Suppose we have no bounds, so we expect it to be set from the pyramid. + let bbox_pyramid = TileBBoxPyramid::from_geo_bbox(2, 12, &GeoBBox(-180.0, -90.0, 180.0, 90.0)); + tj.update_from_pyramid(&bbox_pyramid); + assert_eq!( + tj.bounds.unwrap().as_array(), + [-180.0, -85.05112877980659, 180.0, 85.05112877980659] + ); + assert_eq!(tj.values.get_byte("minzoom"), Some(2)); + assert_eq!(tj.values.get_byte("maxzoom"), Some(12)); + } + + #[test] + fn test_try_from_str_valid() -> Result<()> { + let json_text = r#" + { + "tilejson": "3.0.0", + "bounds": [-180, -90, 180, 90], + "center": [0.0, 0.0, 3.0] + } + "#; + let tj = TileJSON::try_from(json_text)?; + assert!(tj.bounds.is_some()); + assert!(tj.center.is_some()); + assert_eq!(tj.values.get_string("tilejson"), Some("3.0.0".to_string())); + Ok(()) + } + + #[test] + fn test_check_basics_raster_tilejson() { + let mut obj = JsonObject::default(); + // Provide some other field + obj.set("bounds", JsonValue::from(vec![0.0, 0.0, 1.0, 1.0])); + + let tj = TileJSON::from_object(&obj).unwrap(); + let result = tj.check_raster(); + assert!(result.is_ok()); + } + + #[test] + fn test_debug_implementation() { + let tj = TileJSON::default(); + let debug_str = format!("{:?}", tj); + // Contains "TileJSON" and the JSON output + assert!(debug_str.contains("TileJSON(")); + assert!(debug_str.contains("\"tilejson\":\"3.0.0\"")); + } +} diff --git a/versatiles_core/src/utils/tilejson/value.rs b/versatiles_core/src/utils/tilejson/value.rs index 91546c63..8607115e 100644 --- a/versatiles_core/src/utils/tilejson/value.rs +++ b/versatiles_core/src/utils/tilejson/value.rs @@ -3,37 +3,48 @@ use anyhow::{bail, ensure, Result}; use std::collections::BTreeMap; /// A map storing string keys and their associated typed JSON values. +/// +/// By default, this map includes the key `"tilejson"` with a default value of +/// `"3.0.0"`, mirroring a typical TileJSON requirement. #[derive(Clone, Debug, PartialEq)] pub struct TileJsonValues(BTreeMap); impl TileJsonValues { - /// Inserts a key-value pair into the internal BTreeMap, converting the `JsonValue` into a `TileJsonValue`. + /// Inserts a key-value pair into the internal `BTreeMap`, + /// converting the [`JsonValue`] into a [`TileJsonValue`]. /// /// # Errors /// - /// Returns an error if the provided `JsonValue` cannot be converted into a `TileJsonValue`. + /// Returns an error if the provided `JsonValue` cannot be converted into + /// a `TileJsonValue` (e.g., out-of-range numeric value). pub fn insert(&mut self, key: &str, value: &JsonValue) -> Result<()> { - // Convert JsonValue into a typed TileJsonValue, and insert into the map. self.0.insert(key.to_owned(), TileJsonValue::try_from(value)?); Ok(()) } - /// Gets a cloned `String` value for a given key, if that key exists and is stored as a string. + /// Returns a reference to the inner `str` value if this key exists as a string variant, + /// otherwise returns `None`. + /// + /// This method does **not** copy or clone data, returning a `&str` slice instead. pub fn get_str(&self, key: &str) -> Option<&str> { self.0.get(key).and_then(|v| v.get_str()) } - /// Gets a cloned `String` value for a given key, if that key exists and is stored as a string. + /// Returns a cloned `String` if this key exists as a string variant, + /// otherwise returns `None`. + /// + /// This method *does* allocate, returning an owned `String`. pub fn get_string(&self, key: &str) -> Option { - self.0.get(key).and_then(|v| v.get_str().map(|s| s.to_owned())) + self.0.get(key).and_then(|v| v.get_str().map(ToOwned::to_owned)) } - /// Gets a `Byte` value for a given key, if that key exists and is stored as a byte. + /// Returns a `u8` if this key exists as a byte variant, otherwise returns `None`. pub fn get_byte(&self, key: &str) -> Option { self.0.get(key).and_then(|v| v.get_byte()) } - /// Checks if the given key is either absent or references a list. Returns an error if it is present but not a list. + /// Checks if the given `key` is either absent or references a list (`Vec`). + /// Returns an error if it is present but not a list. pub fn check_optional_list(&self, key: &str) -> Result<()> { if let Some(value) = self.0.get(key) { if !value.is_list() { @@ -43,7 +54,8 @@ impl TileJsonValues { Ok(()) } - /// Checks if the given key is either absent or references a string. Returns an error if it is present but not a string. + /// Checks if the given `key` is either absent or references a string. + /// Returns an error if it is present but not a string. pub fn check_optional_string(&self, key: &str) -> Result<()> { if let Some(value) = self.0.get(key) { if !value.is_string() { @@ -53,7 +65,8 @@ impl TileJsonValues { Ok(()) } - /// Checks if the given key is either absent or references a byte. Returns an error if it is present but not a byte. + /// Checks if the given `key` is either absent or references a byte (`u8`). + /// Returns an error if it is present but not a byte. pub fn check_optional_byte(&self, key: &str) -> Result<()> { if let Some(value) = self.0.get(key) { if !value.is_byte() { @@ -63,30 +76,47 @@ impl TileJsonValues { Ok(()) } - /// Creates an iterator over the internal key-value pairs, where the value is returned as `JsonValue`. + /// Returns an iterator over `(String, JsonValue)` pairs, where + /// each `JsonValue` is the generic form of the stored [`TileJsonValue`]. /// - /// The returned iterator yields `(String, JsonValue)` tuples. + /// Use this to transform `TileJsonValues` back into a generic JSON structure. pub fn iter_json_values(&self) -> impl Iterator + '_ { - self.0.iter().map(|(k, v)| (k.to_owned(), v.as_json_value())) + self.0.iter().map(|(k, v)| (k.clone(), v.as_json_value())) } + /// Updates or inserts a byte (`u8`) for the given `key`. + /// The provided `update` closure receives the current value (if any) + /// and returns the new byte value to be stored. + /// + /// ``` + /// # use crate::utils::JsonValue; + /// # use anyhow::Result; + /// # use crate::TileJsonValues; // hypothetical + /// let mut values = TileJsonValues::default(); + /// + /// // If there's no current value for "maxzoom", use 0, + /// // otherwise pick the larger of the existing and new. + /// values.update_byte("maxzoom", |maybe_current| { + /// let current = maybe_current.unwrap_or(0); + /// current.max(10) + /// }); + /// ``` pub fn update_byte(&mut self, key: &str, update: T) where T: FnOnce(Option) -> u8, { - self.0.insert( - key.to_owned(), - TileJsonValue::Byte(update(self.0.get(key).and_then(|v| v.get_byte()))), - ); + let new_val = update(self.0.get(key).and_then(|v| v.get_byte())); + self.0.insert(key.to_owned(), TileJsonValue::Byte(new_val)); } } impl Default for TileJsonValues { + /// By default, we create a map with one key: `"tilejson"`, + /// initialized to the string `"3.0.0"`. fn default() -> Self { - TileJsonValues(BTreeMap::from([( - String::from("tilejson"), - TileJsonValue::String("3.0.0".to_owned()), - )])) + let mut map = BTreeMap::new(); + map.insert("tilejson".to_string(), TileJsonValue::String("3.0.0".to_owned())); + TileJsonValues(map) } } @@ -97,7 +127,7 @@ pub enum TileJsonValue { List(Vec), /// A single string (originally from a JSON string). String(String), - /// A single byte (stored as `u8`). + /// A single byte (stored as `u8`). Must be in `[0, 255]`. Byte(u8), } @@ -118,35 +148,35 @@ impl TileJsonValue { } } - /// Converts this `TileJsonValue` to a generic `JsonValue`. + /// Converts this `TileJsonValue` into a generic [`JsonValue`]. pub fn as_json_value(&self) -> JsonValue { match self { - TileJsonValue::String(s) => JsonValue::from(s), - TileJsonValue::List(l) => JsonValue::from(l), TileJsonValue::Byte(b) => JsonValue::from(*b), + TileJsonValue::List(l) => JsonValue::from(l), + TileJsonValue::String(s) => JsonValue::from(s), } } - /// Returns a string describing the variant of this `TileJsonValue`. + /// Returns a string describing which variant this `TileJsonValue` is (`"List"`, `"String"`, or `"Byte"`). pub fn get_type(&self) -> &str { match self { - TileJsonValue::String(_) => "String", - TileJsonValue::List(_) => "List", TileJsonValue::Byte(_) => "Byte", + TileJsonValue::List(_) => "List", + TileJsonValue::String(_) => "String", } } - /// Returns `true` if this value is a list. + /// Returns `true` if this value is a `TileJsonValue::List`. pub fn is_list(&self) -> bool { matches!(self, TileJsonValue::List(_)) } - /// Returns `true` if this value is a string. + /// Returns `true` if this value is a `TileJsonValue::String`. pub fn is_string(&self) -> bool { matches!(self, TileJsonValue::String(_)) } - /// Returns `true` if this value is a string. + /// Returns `true` if this value is a `TileJsonValue::Byte`. pub fn is_byte(&self) -> bool { matches!(self, TileJsonValue::Byte(_)) } @@ -155,20 +185,135 @@ impl TileJsonValue { impl TryFrom<&JsonValue> for TileJsonValue { type Error = anyhow::Error; - /// Attempts to convert a reference to a `JsonValue` into a `TileJsonValue`. + /// Attempts to convert a reference to a [`JsonValue`] into a [`TileJsonValue`]. /// /// # Errors /// - /// Returns an error if the conversion is not possible (e.g., invalid type). + /// Returns an error if: + /// - The `JsonValue` is out of range for a byte (`u8`). + /// - The `JsonValue` is some other type not supported by [`TileJsonValue`]. fn try_from(value: &JsonValue) -> Result { - Ok(match value { - JsonValue::String(s) => TileJsonValue::String(s.to_owned()), - JsonValue::Array(a) => TileJsonValue::List(a.as_string_vec()?), + match value { + JsonValue::String(s) => Ok(TileJsonValue::String(s.to_owned())), + JsonValue::Array(a) => Ok(TileJsonValue::List(a.as_string_vec()?)), JsonValue::Number(n) => { - ensure!((&0.0..=&255.0).contains(&n), "Number out of byte range: {}", n); - TileJsonValue::Byte(*n as u8) + ensure!((0.0..=255.0).contains(n), "Number out of byte range: {}", n); + Ok(TileJsonValue::Byte(*n as u8)) } - _ => bail!("Invalid value type"), - }) + _ => bail!("Invalid value type: only string, array, or byte allowed"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_includes_tilejson() { + let default_values = TileJsonValues::default(); + assert_eq!(default_values.get_string("tilejson"), Some("3.0.0".to_owned())); + } + + #[test] + fn insert_and_retrieve_string() -> Result<()> { + let mut tv = TileJsonValues::default(); + tv.insert("name", &JsonValue::from("Test Layer"))?; + assert_eq!(tv.get_string("name"), Some("Test Layer".to_owned())); + Ok(()) + } + + #[test] + fn insert_and_retrieve_byte() -> Result<()> { + let mut tv = TileJsonValues::default(); + tv.insert("maxzoom", &JsonValue::from(12.9_f64))?; + assert_eq!(tv.get_byte("maxzoom"), Some(12)); + Ok(()) + } + + #[test] + fn insert_out_of_range_byte() { + let mut tv = TileJsonValues::default(); + // 999.0 is out of byte range + let result = tv.insert("zoom", &JsonValue::from(999_f64)); + assert!(result.is_err()); + } + + #[test] + fn insert_and_retrieve_list() -> Result<()> { + let mut tv = TileJsonValues::default(); + let json_array = JsonValue::from(vec!["field1", "field2"]); + + tv.insert("fields", &json_array)?; + let val = tv.0.get("fields").unwrap(); + assert!(val.is_list()); + + match val { + TileJsonValue::List(list) => assert_eq!(list, &["field1", "field2"]), + _ => panic!("Expected a list"), + } + Ok(()) + } + + #[test] + fn check_optional_list() { + let mut tv = TileJsonValues::default(); + tv.insert("mylist", &JsonValue::from(vec!["a", "b"])).unwrap(); + assert!(tv.check_optional_list("mylist").is_ok()); + + // If we overwrite "mylist" with a string, it should fail + tv.insert("mylist", &JsonValue::from("not a list")).unwrap(); + assert!(tv.check_optional_list("mylist").is_err()); + } + + #[test] + fn check_optional_string() { + let mut tv = TileJsonValues::default(); + tv.insert("desc", &JsonValue::from("description")).unwrap(); + assert!(tv.check_optional_string("desc").is_ok()); + + // If we overwrite "desc" with a list, it should fail + tv.insert("desc", &JsonValue::from(vec!["oops"])).unwrap(); + assert!(tv.check_optional_string("desc").is_err()); + } + + #[test] + fn check_optional_byte() { + let mut tv = TileJsonValues::default(); + tv.insert("opacity", &JsonValue::from(123_f64)).unwrap(); + assert!(tv.check_optional_byte("opacity").is_ok()); + + // Overwrite with a string -> fails + tv.insert("opacity", &JsonValue::from("should be a number")).unwrap(); + assert!(tv.check_optional_byte("opacity").is_err()); + } + + #[test] + fn update_byte_test() { + let mut tv = TileJsonValues::default(); + // No existing value => default to 0 + tv.update_byte("zoom", |maybe| maybe.unwrap_or(0).max(10)); + assert_eq!(tv.get_byte("zoom"), Some(10)); + + // Existing value => modify existing + tv.update_byte("zoom", |maybe| maybe.unwrap_or(0).max(20)); + assert_eq!(tv.get_byte("zoom"), Some(20)); + } + + #[test] + fn iter_json_values_test() -> Result<()> { + let mut tv = TileJsonValues::default(); + tv.insert("alpha", &JsonValue::from(1_f64))?; + tv.insert("name", &JsonValue::from("Layer"))?; + + let json_values: BTreeMap = tv.iter_json_values().collect(); + + // "tilejson" is always present by default + assert!(json_values.contains_key("tilejson")); + assert_eq!(json_values["tilejson"], JsonValue::from("3.0.0")); + assert_eq!(json_values["alpha"], JsonValue::from(1_f64)); + assert_eq!(json_values["name"], JsonValue::from("Layer")); + + Ok(()) } } diff --git a/versatiles_core/src/utils/tilejson/vector_layer.rs b/versatiles_core/src/utils/tilejson/vector_layer.rs index 5b7dbb37..1e2498be 100644 --- a/versatiles_core/src/utils/tilejson/vector_layer.rs +++ b/versatiles_core/src/utils/tilejson/vector_layer.rs @@ -1,28 +1,39 @@ -use crate::utils::{JsonArray, JsonObject, JsonValue}; +use crate::utils::{JsonObject, JsonValue}; use anyhow::{anyhow, ensure, Context, Result}; use std::{collections::BTreeMap, fmt::Debug}; -/// A collection of [VectorLayer], keyed by `id`. +/// A collection of [`VectorLayer`]s keyed by their `id` string. /// -/// Corresponds to the "vector_layers" array in the TileJSON specification. -/// https://github.com/mapbox/tilejson-spec/tree/master/3.0.0#33-vector_layers +/// Corresponds to the "vector_layers" array in the TileJSON specification: +/// #[derive(Clone, Default, Debug, PartialEq)] pub struct VectorLayers(pub BTreeMap); impl VectorLayers { - /// Creates a [VectorLayers] from a [JsonArray]. + /// Constructs a [`VectorLayers`] instance from a [`JsonArray`]. /// - /// Expects each array element to be an object with keys: - /// - `"id"` (string, required) - /// - `"description"` (string, optional) - /// - `"minzoom"` (number, optional) - /// - `"maxzoom"` (number, optional) - /// - `"fields"` (object, required) + /// # JSON Structure /// - /// Returns an error if any required field is missing or of an invalid type. - pub fn from_json_array(array: &JsonArray) -> Result { + /// Each element in the array is expected to be a JSON object with the following keys: + /// - `"id"`: Required `string`. The identifier for the vector layer. + /// - `"description"`: Optional `string`. A description of the layer. + /// - `"minzoom"`: Optional `number` in the range `[0..30]`. Minimum zoom level. + /// - `"maxzoom"`: Optional `number` in the range `[0..30]`. Maximum zoom level. + /// - `"fields"`: Required `object`, each key is a field name, and its value is a `string`. + /// + /// # Errors + /// + /// Fails if: + /// - The `"id"` key is missing or invalid. + /// - The `"fields"` key is missing or invalid. + /// - The associated values fail to convert to the expected types (`string`, `number`). + pub fn from_json(json: &JsonValue) -> Result { + let array = json + .as_array() + .with_context(|| anyhow!("expected 'vector_layers' is an array"))?; + let mut map = BTreeMap::new(); - for entry in array.0.iter() { + for entry in &array.0 { // Convert each entry to an object let object = entry.as_object()?; @@ -34,7 +45,7 @@ impl VectorLayers { let minzoom = object.get_number("minzoom")?; let maxzoom = object.get_number("maxzoom")?; - // Convert each entry in "fields" to a (String, String) pair + // Required: "fields" object let mut fields = BTreeMap::::new(); if let Some(value) = object.get("fields") { for (k, v) in value.as_object()?.iter() { @@ -42,21 +53,23 @@ impl VectorLayers { } } - // Build the [VectorLayer] and insert into the map + // Build the [`VectorLayer`] and insert into the map let layer = VectorLayer { description, + fields, minzoom, maxzoom, - fields, }; map.insert(id, layer); } + Ok(VectorLayers(map)) } - /// Converts this collection to an [Option]. + /// Converts this collection of layers to an [`Option`]. /// - /// Returns [None] if the map is empty. + /// Returns `None` if the collection is empty, or `Some(JsonValue::Array(...))` + /// otherwise. pub fn as_json_value_option(&self) -> Option { if self.0.is_empty() { None @@ -65,40 +78,45 @@ impl VectorLayers { } } - /// Converts this collection to a [JsonValue] (an array of objects). + /// Converts this collection to a [`JsonValue::Array`], where each array element + /// is a layer represented as a [`JsonObject`]. /// - /// Each object has: - /// - `"id"`: `String` + /// Each object contains: + /// - `"id"` (string), /// - `"fields"`, `"description"`, `"minzoom"`, `"maxzoom"` if present in the layer pub fn as_json_value(&self) -> JsonValue { JsonValue::from( self .0 .iter() - .map(|(key, value)| { - // Construct a JsonObject from the layer - let mut obj = value.as_json_object(); - obj.set("id", JsonValue::from(key)); + .map(|(id, layer)| { + // Construct the object from the layer + let mut obj = layer.as_json_object(); + // Insert "id" + obj.set("id", JsonValue::from(id)); JsonValue::Object(obj) }) .collect::>(), ) } - /// Checks that all layers follow the TileJSON spec requirements: - /// 1. The `id` is not empty, no longer than 255 chars, and alphanumeric. - /// 2. Each layer also passes its own [VectorLayer::check] validation. + /// Checks that all layers conform to the TileJSON 3.0.0 spec: + /// + /// - `id` is non-empty, <= 255 chars, alphanumeric. + /// - The layer itself passes [`VectorLayer::check`]. + /// + /// # Errors /// - /// Returns an error if any checks fail. + /// Returns an error if any constraints are violated. pub fn check(&self) -> Result<()> { - // https://github.com/mapbox/tilejson-spec/tree/master/3.0.0#33-vector_layers + // See: https://github.com/mapbox/tilejson-spec/tree/master/3.0.0#33-vector_layers for (id, layer) in &self.0 { // 3.3.1 id - required - ensure!(!id.is_empty(), "Empty key"); - ensure!(id.len() <= 255, "Key too long"); + ensure!(!id.is_empty(), "Empty layer id"); + ensure!(id.len() <= 255, "Layer id too long: '{id}'"); ensure!( id.chars().all(|c| c.is_ascii_alphanumeric()), - "Invalid key: must be alphanumeric" + "Invalid layer id '{id}': must be alphanumeric" ); layer.check().with_context(|| format!("layer '{id}'"))?; @@ -107,27 +125,34 @@ impl VectorLayers { } /// Merges all layers from `other` into this collection. - /// If a layer `id` already exists, the fields will be merged or overwritten. + /// + /// - If a layer `id` does not exist in `self`, it is inserted outright. + /// - If a layer `id` already exists, their contents are merged via [`VectorLayer::merge`]. + /// + /// # Errors + /// + /// Currently does not fail. The `Result<()>` return type allows + /// expansion if you want to validate merges or handle conflicts. pub fn merge(&mut self, other: &VectorLayers) -> Result<()> { - for (id, layer) in other.0.iter() { - if self.0.contains_key(id) { - // If the layer already exists, merge the fields - self.0.get_mut(id).unwrap().merge(layer); + for (id, layer) in &other.0 { + // If the layer already exists, merge the fields + if let Some(existing) = self.0.get_mut(id) { + existing.merge(layer); } else { - // Otherwise, insert the layer - self.0.insert(id.to_owned(), layer.clone()); + // Otherwise, insert the whole layer + self.0.insert(id.clone(), layer.clone()); } } Ok(()) } } -/// Represents a single entry in "vector_layers" from TileJSON. +/// Represents a single layer entry within "vector_layers" in the TileJSON spec. /// -/// Fields: -/// - `fields`: A mapping of field name -> field type -/// - `description`: Optional layer description -/// - `minzoom`, `maxzoom`: Optional zoom bounds +/// Each layer has: +/// - `fields`: A mapping from field names -> field types (both `String`). +/// - `description`: An optional textual description of the layer. +/// - `minzoom`, `maxzoom`: Optional `u8` values (0..=30). #[derive(Clone, Debug, PartialEq)] pub struct VectorLayer { pub fields: BTreeMap, @@ -137,9 +162,9 @@ pub struct VectorLayer { } impl VectorLayer { - /// Converts this [VectorLayer] into a [JsonObject]. + /// Converts this [`VectorLayer`] into a [`JsonObject`]. /// - /// Output object includes: + /// The object will include: /// - `"fields"` (object) /// - `"description"` (string, if present) /// - `"minzoom"` (number, if present) @@ -159,7 +184,6 @@ impl VectorLayer { ), ); - // Optionally include other fields if they're present if let Some(desc) = &self.description { obj.set("description", JsonValue::from(desc)); } @@ -173,60 +197,288 @@ impl VectorLayer { obj } - /// Performs checks that ensure the layer follows the TileJSON spec. + /// Validates the layer according to the TileJSON 3.0.0 spec: + /// + /// - 3.3.2 fields: required; each key is non-empty, <= 255 chars, alphanumeric + /// - 3.3.3 description: optional + /// - 3.3.4 minzoom, maxzoom: optional; must be <= 30, and `minzoom <= maxzoom` /// - /// - 3.3.2 fields - required; each key must be non-empty, <= 255 chars, and alphanumeric - /// - 3.3.3 description - optional - /// - 3.3.4 minzoom, maxzoom - optional; must be <= 30, and minzoom <= maxzoom if both set + /// # Errors /// - /// Returns an error if any checks fail. + /// Returns an error if any constraints fail. pub fn check(&self) -> Result<()> { - // https://github.com/mapbox/tilejson-spec/tree/master/3.0.0#33-vector_layers + // See: https://github.com/mapbox/tilejson-spec/tree/master/3.0.0#33-vector_layers // 3.3.2 fields - required for key in self.fields.keys() { - ensure!(!key.is_empty(), "Empty key in 'fields'"); - ensure!(key.len() <= 255, "Key in 'fields' too long"); + ensure!(!key.is_empty(), "Empty field name"); + ensure!(key.len() <= 255, "Field name too long: '{key}'"); ensure!( key.chars().all(|c| c.is_ascii_alphanumeric()), - "Invalid key in 'fields': must be alphanumeric" + "Invalid field name '{key}': must be alphanumeric" ); } - // 3.3.3 description - optional, no explicit constraints in the spec + // 3.3.3 description - optional, no explicit constraints. // 3.3.4 minzoom, maxzoom - optional, must be <= 30 - if let Some(v0) = self.minzoom { - ensure!(v0 <= 30, "minzoom too high"); + if let Some(mz) = self.minzoom { + ensure!(mz <= 30, "minzoom too high: {mz}"); } - if let Some(v1) = self.maxzoom { - ensure!(v1 <= 30, "maxzoom too high"); - - if let Some(v0) = self.minzoom { - ensure!(v0 <= v1, "minzoom must be less than or equal to maxzoom"); + if let Some(mz) = self.maxzoom { + ensure!(mz <= 30, "maxzoom too high: {mz}"); + if let Some(minz) = self.minzoom { + ensure!(minz <= mz, "minzoom must be <= maxzoom, found min={minz}, max={mz}"); } } - Ok(()) } - /// Merges the fields from `other` into this layer. - /// If a field already exists, it will be overwritten. + /// Merges the fields from `other` into this layer, overwriting existing data where conflicts arise. + /// + /// - `fields`: All fields from `other` are inserted (overwriting any existing). + /// - `description`: Overwrites if `other` has one. + /// - `minzoom`: Takes the smaller of `self`'s and `other`'s (if both exist), else whichever is present. + /// - `maxzoom`: Takes the larger of `self`'s and `other`'s (if both exist), else whichever is present. pub fn merge(&mut self, other: &VectorLayer) { + // Merge fields for (key, value) in &other.fields { - self.fields.insert(key.to_owned(), value.to_owned()); + self.fields.insert(key.clone(), value.clone()); } - if other.description.is_some() { - self.description = other.description.clone(); + // Overwrite description if present + if let Some(desc) = &other.description { + self.description = Some(desc.clone()); } - if let Some(minzoom) = other.minzoom { - self.minzoom = Some(self.minzoom.map_or(minzoom, |mz| mz.min(minzoom))); + // Merge minzoom + if let Some(other_min) = other.minzoom { + self.minzoom = Some(match self.minzoom { + Some(m) => m.min(other_min), + None => other_min, + }); } - if let Some(maxzoom) = other.maxzoom { - self.maxzoom = Some(self.maxzoom.map_or(maxzoom, |mz| mz.max(maxzoom))); + // Merge maxzoom + if let Some(other_max) = other.maxzoom { + self.maxzoom = Some(match self.maxzoom { + Some(m) => m.max(other_max), + None => other_max, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_json_array_basic() -> Result<()> { + // Create a JSON array with one valid layer object. + // The layer must have "id" and "fields". + let json = JsonValue::from(vec![vec![ + ("id", JsonValue::from("myLayer")), + ("fields", JsonValue::from(vec![("name", "String")])), + ]]); + let vector_layers = VectorLayers::from_json(&json)?; + assert_eq!(vector_layers.0.len(), 1); + assert!(vector_layers.0.contains_key("myLayer")); + Ok(()) + } + + #[test] + fn test_from_json_array_missing_id() { + // "id" is required, so missing "id" should fail. + let json = JsonValue::from(vec![vec![("fields", vec![("name", "String")])]]); + let result = VectorLayers::from_json(&json); + assert_eq!(result.unwrap_err().to_string(), "missing `id`"); + } + + #[test] + fn test_from_json_array_missing_fields() { + // "fields" is required, so missing "fields" should fail. + let json = JsonValue::from(vec![vec![("id", JsonValue::from("layer1"))]]); + let result = VectorLayers::from_json(&json).unwrap(); + assert_eq!( + result.as_json_value().stringify(), + "[{\"fields\":{},\"id\":\"layer1\"}]" + ); + } + + #[test] + fn test_check_valid() -> Result<()> { + let mut map = BTreeMap::new(); + map.insert( + "myLayer".to_owned(), + VectorLayer { + fields: BTreeMap::from([("field1".to_owned(), "String".to_owned())]), + description: Some("A simple layer".to_owned()), + minzoom: Some(0), + maxzoom: Some(10), + }, + ); + let vl = VectorLayers(map); + assert!(vl.check().is_ok()); + Ok(()) + } + + #[test] + fn test_check_invalid_id() { + // Non-alphanumeric ID should fail check() + let mut map = BTreeMap::new(); + map.insert( + "my.layer!".to_owned(), + VectorLayer { + fields: BTreeMap::new(), + description: None, + minzoom: None, + maxzoom: None, + }, + ); + let vl = VectorLayers(map); + assert_eq!( + vl.check().unwrap_err().to_string(), + "Invalid layer id 'my.layer!': must be alphanumeric" + ); + } + + #[test] + fn test_layer_merge() { + let mut layer1 = VectorLayer { + fields: BTreeMap::from([ + ("name".to_owned(), "String".to_owned()), + ("count".to_owned(), "Integer".to_owned()), + ]), + description: Some("Layer 1 description".to_owned()), + minzoom: Some(5), + maxzoom: Some(10), + }; + + let layer2 = VectorLayer { + fields: BTreeMap::from([ + ("name".to_owned(), "String".to_owned()), + ("type".to_owned(), "String".to_owned()), + ]), + description: Some("Layer 2 override".to_owned()), + minzoom: Some(3), + maxzoom: Some(15), + }; + + layer1.merge(&layer2); + // Expect "count" and "type" to both exist, "description" to be overwritten + // and minzoom = min(5,3) = 3, maxzoom = max(10,15) = 15 + + assert_eq!(layer1.fields["count"], "Integer"); + assert_eq!(layer1.fields["type"], "String"); + assert_eq!(layer1.description.as_deref(), Some("Layer 2 override")); + assert_eq!(layer1.minzoom, Some(3)); + assert_eq!(layer1.maxzoom, Some(15)); + } + + #[test] + fn test_vector_layers_merge() -> Result<()> { + let mut vl1 = VectorLayers(BTreeMap::from([ + ( + "layerA".to_owned(), + VectorLayer { + fields: BTreeMap::from([("fieldA".to_string(), "String".to_string())]), + description: Some("First layer".to_string()), + minzoom: Some(2), + maxzoom: Some(6), + }, + ), + ( + "layerB".to_owned(), + VectorLayer { + fields: BTreeMap::from([("fieldB".to_string(), "String".to_string())]), + description: Some("Second layer".to_string()), + minzoom: Some(4), + maxzoom: Some(8), + }, + ), + ])); + + let vl2 = VectorLayers(BTreeMap::from([ + ( + "layerB".to_owned(), + VectorLayer { + fields: BTreeMap::from([ + ("fieldB".to_string(), "String".to_string()), + ("fieldC".to_string(), "Integer".to_string()), + ]), + description: Some("Overridden second".to_string()), + minzoom: Some(1), + maxzoom: Some(9), + }, + ), + ( + "layerC".to_owned(), + VectorLayer { + fields: BTreeMap::from([("fieldD".to_string(), "String".to_string())]), + description: None, + minzoom: None, + maxzoom: None, + }, + ), + ])); + + vl1.merge(&vl2)?; + + // Expect that "layerA" is untouched + assert!(vl1.0.contains_key("layerA")); + // Expect that "layerB" is merged + assert!(vl1.0.contains_key("layerB")); + // Expect that "layerC" is newly inserted + assert!(vl1.0.contains_key("layerC")); + + let layer_b_merged = vl1.0.get("layerB").unwrap(); + // "fieldB" remains, "fieldC" added, new description, zoom min=1, max=9 + assert!(layer_b_merged.fields.contains_key("fieldB")); + assert!(layer_b_merged.fields.contains_key("fieldC")); + assert_eq!(layer_b_merged.description.as_deref(), Some("Overridden second")); + assert_eq!(layer_b_merged.minzoom, Some(1)); + assert_eq!(layer_b_merged.maxzoom, Some(9)); + + Ok(()) + } + + #[test] + fn test_as_json_value_option() -> Result<()> { + let mut layers_map = BTreeMap::new(); + let layer = VectorLayer { + fields: BTreeMap::from([("key".to_string(), "String".to_string())]), + description: Some("A layer".to_owned()), + minzoom: Some(0), + maxzoom: Some(5), + }; + layers_map.insert("myLayer".to_owned(), layer); + let layers = VectorLayers(layers_map); + + let json_val_option = layers.as_json_value_option(); + assert!(json_val_option.is_some()); + + let json_val = json_val_option.unwrap(); + // Should be an array of length 1 + match &json_val { + JsonValue::Array(arr) => { + assert_eq!(arr.0.len(), 1); + if let JsonValue::Object(obj) = &arr.0[0] { + // Expect 'id' == 'myLayer' + let id = obj.get("id").ok_or_else(|| anyhow!("missing 'id'"))?; + assert_eq!(id.as_string()?, "myLayer"); + } else { + panic!("Expected a JsonObject in the array."); + } + } + _ => panic!("Expected a JsonValue::Array."), } + Ok(()) + } + + #[test] + fn test_as_json_value_empty() { + let empty_layers = VectorLayers::default(); + assert!(empty_layers.as_json_value_option().is_none()); } }