diff --git a/stac/CHANGELOG.md b/stac/CHANGELOG.md index 7ab9449d..73e74030 100644 --- a/stac/CHANGELOG.md +++ b/stac/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- The projection and raster extensions, the `Extension` trait, and the `Fields` trait ([#234](https://github.com/stac-utils/stac-rs/pull/234)) + +### Changed + +- The `extensions` attribute of catalogs, collections, and items is now non-optional ([#234](https://github.com/stac-utils/stac-rs/pull/234)) + ## [0.5.3] - 2024-04-07 ### Added diff --git a/stac/src/asset.rs b/stac/src/asset.rs index 23ba5cfb..74acd90c 100644 --- a/stac/src/asset.rs +++ b/stac/src/asset.rs @@ -1,3 +1,4 @@ +use crate::{Extensions, Fields}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::collections::HashMap; @@ -54,6 +55,9 @@ pub struct Asset { /// Additional fields on the asset. #[serde(flatten)] pub additional_fields: Map, + + #[serde(skip)] + extensions: Vec, } /// Trait implemented by anything that has assets. @@ -107,10 +111,29 @@ impl Asset { created: None, updated: None, additional_fields: Map::new(), + extensions: Vec::new(), } } } +impl Fields for Asset { + fn fields(&self) -> &Map { + &self.additional_fields + } + fn fields_mut(&mut self) -> &mut Map { + &mut self.additional_fields + } +} + +impl Extensions for Asset { + fn extensions(&self) -> &Vec { + &self.extensions + } + fn extensions_mut(&mut self) -> &mut Vec { + &mut self.extensions + } +} + #[cfg(test)] mod tests { use super::Asset; diff --git a/stac/src/catalog.rs b/stac/src/catalog.rs index f4f2536e..adbe8d24 100644 --- a/stac/src/catalog.rs +++ b/stac/src/catalog.rs @@ -1,4 +1,4 @@ -use crate::{Error, Extensions, Href, Link, Links, Result, STAC_VERSION}; +use crate::{Error, Extensions, Fields, Href, Link, Links, Result, STAC_VERSION}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -33,8 +33,9 @@ pub struct Catalog { /// A list of extension identifiers the `Catalog` implements. #[serde(rename = "stac_extensions")] - #[serde(skip_serializing_if = "Option::is_none")] - pub extensions: Option>, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default)] + pub extensions: Vec, /// Identifier for the `Catalog`. pub id: String, @@ -74,7 +75,7 @@ impl Catalog { Catalog { r#type: CATALOG_TYPE.to_string(), version: STAC_VERSION.to_string(), - extensions: None, + extensions: Vec::new(), id: id.to_string(), title: None, description: description.to_string(), @@ -122,9 +123,21 @@ impl TryFrom> for Catalog { } } +impl Fields for Catalog { + fn fields(&self) -> &Map { + &self.additional_fields + } + fn fields_mut(&mut self) -> &mut Map { + &mut self.additional_fields + } +} + impl Extensions for Catalog { - fn extensions(&self) -> Option<&[String]> { - self.extensions.as_deref() + fn extensions(&self) -> &Vec { + &self.extensions + } + fn extensions_mut(&mut self) -> &mut Vec { + &mut self.extensions } } @@ -154,7 +167,7 @@ mod tests { assert_eq!(catalog.description, "a description"); assert_eq!(catalog.r#type, "Catalog"); assert_eq!(catalog.version, STAC_VERSION); - assert!(catalog.extensions.is_none()); + assert!(catalog.extensions.is_empty()); assert_eq!(catalog.id, "an-id"); assert!(catalog.links.is_empty()); } diff --git a/stac/src/collection.rs b/stac/src/collection.rs index d08ac5df..465c355c 100644 --- a/stac/src/collection.rs +++ b/stac/src/collection.rs @@ -1,4 +1,4 @@ -use crate::{Asset, Assets, Error, Extensions, Href, Link, Links, Result, STAC_VERSION}; +use crate::{Asset, Assets, Error, Extensions, Fields, Href, Link, Links, Result, STAC_VERSION}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::collections::HashMap; @@ -36,8 +36,9 @@ pub struct Collection { /// A list of extension identifiers the `Collection` implements. #[serde(rename = "stac_extensions")] - #[serde(skip_serializing_if = "Option::is_none")] - pub extensions: Option>, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default)] + pub extensions: Vec, /// Identifier for the `Collection` that is unique across the provider. pub id: String, @@ -171,7 +172,7 @@ impl Collection { Collection { r#type: COLLECTION_TYPE.to_string(), version: STAC_VERSION.to_string(), - extensions: None, + extensions: Vec::new(), id: id.to_string(), title: None, description: description.to_string(), @@ -253,9 +254,21 @@ impl Assets for Collection { } } +impl Fields for Collection { + fn fields(&self) -> &Map { + &self.additional_fields + } + fn fields_mut(&mut self) -> &mut Map { + &mut self.additional_fields + } +} + impl Extensions for Collection { - fn extensions(&self) -> Option<&[String]> { - self.extensions.as_deref() + fn extensions(&self) -> &Vec { + &self.extensions + } + fn extensions_mut(&mut self) -> &mut Vec { + &mut self.extensions } } @@ -311,7 +324,7 @@ mod tests { assert!(collection.assets.is_empty()); assert_eq!(collection.r#type, "Collection"); assert_eq!(collection.version, STAC_VERSION); - assert!(collection.extensions.is_none()); + assert!(collection.extensions.is_empty()); assert_eq!(collection.id, "an-id"); assert!(collection.links.is_empty()); } diff --git a/stac/src/error.rs b/stac/src/error.rs index 7547c41d..264858d8 100644 --- a/stac/src/error.rs +++ b/stac/src/error.rs @@ -60,6 +60,10 @@ pub enum Error { #[error("value is not a collection")] NotACollection(Value), + /// This value is not an object. + #[error("not an object")] + NotAnObject(serde_json::Value), + /// Returned when trying to read from a url but the `reqwest` feature is not enabled. #[error("reqwest is not enabled")] ReqwestNotEnabled, diff --git a/stac/src/extensions.rs b/stac/src/extensions.rs deleted file mode 100644 index 77acfdcc..00000000 --- a/stac/src/extensions.rs +++ /dev/null @@ -1,13 +0,0 @@ -/// A trait for objects that may have STAC extensions. -pub trait Extensions { - /// Returns a reference to this object's extensions. - /// - /// # Examples - /// - /// ``` - /// use stac::{Extensions, Item}; - /// let item = Item::new("an-id"); - /// assert!(item.extensions().is_none()); - /// ``` - fn extensions(&self) -> Option<&[String]>; -} diff --git a/stac/src/extensions/mod.rs b/stac/src/extensions/mod.rs new file mode 100644 index 00000000..1e8431c7 --- /dev/null +++ b/stac/src/extensions/mod.rs @@ -0,0 +1,239 @@ +//! Extensions describe how STAC can use extensions that extend the +//! functionality of the core spec or add fields for specific domains. +//! +//! Extensions can be published anywhere, although the preferred location for +//! public extensions is in the GitHub +//! [stac-extensions](https://github.com/stac-extensions/) organization. +//! This crate currently supports only a few extensions, though we plan to add more as we find the time. +//! See for the latest table of community extensions. +//! This table below lists all [stable](https://github.com/radiantearth/stac-spec/tree/master/extensions#extension-maturity) extensions, as well as any other extensions that are supported by **stac-rs**: +//! +//! | Extension | Maturity | **stac-rs** supported version | +//! | -- | -- | -- | +//! | [Electro-Optical](https://github.com/stac-extensions/eo) | Stable | n/a | +//! | [File Info](https://github.com/stac-extensions/file) | Stable | n/a | +//! | [Landsat](https://github.com/stac-extensions/landsat) | Stable | n/a | +//! | [Projection](https://github.com/stac-extensions/projection) | Stable | v1.1.0 | +//! | [Raster](https://github.com/stac-extensions/raster) | Candidate | v1.1.0 | +//! | [Scientific Citation](https://github.com/stac-extensions/scientific) | Stable | n/a | +//! | [View Geometry](https://github.com/stac-extensions/view) | Stable | n/a | +//! +//! ## Usage +//! +//! [Item](crate::Item), [Collection](crate::Collection), +//! [Catalog](crate::Catalog), and [Asset](crate::Asset) all implement the +//! [Extensions] trait, which provides methods to get, set, and remove extension information: +//! +//! ``` +//! use stac::{Item, Extensions, extensions::{Projection, projection::Centroid}}; +//! let mut item: Item = stac::read("data/extensions-collection/proj-example/proj-example.json").unwrap(); +//! assert!(item.has_extension::()); +//! +//! // Get extension information +//! let mut projection: Projection = item.extension().unwrap(); +//! println!("epsg: {}", projection.epsg.unwrap()); +//! +//! // Set extension information +//! projection.centroid = Some(Centroid { lat: 34.595302, lon: -101.344483 }); +//! item.set_extension(projection).unwrap(); +//! +//! // Remove an extension +//! item.remove_extension::(); +//! assert!(!item.has_extension::()); +//! ``` + +pub mod projection; +pub mod raster; + +use crate::{Fields, Result}; +use serde::{de::DeserializeOwned, Serialize}; +pub use {projection::Projection, raster::Raster}; + +/// A trait implemented by extensions. +/// +/// So far, all extensions are assumed to live in under +/// domain. +pub trait Extension: Serialize + DeserializeOwned { + /// The schema URI. + const IDENTIFIER: &'static str; + + /// The fiend name prefix. + const PREFIX: &'static str; + + /// Returns everything from the identifier up until the version. + /// + /// # Examples + /// + /// ``` + /// use stac::extensions::{Raster, Extension}; + /// assert_eq!(Raster::identifier_prefix(), "https://stac-extensions.github.io/raster/"); + /// ``` + fn identifier_prefix() -> &'static str { + assert!(Self::IDENTIFIER.starts_with("https://stac-extensions.github.io/")); + let index = Self::IDENTIFIER["https://stac-extensions.github.io/".len()..] + .find('/') + .expect("all identifiers should have a first path segment"); + &Self::IDENTIFIER[0.."https://stac-extensions.github.io/".len() + index + 1] + } +} + +/// A trait for objects that may have STAC extensions. +pub trait Extensions: Fields { + /// Returns a reference to this object's extensions. + /// + /// # Examples + /// + /// ``` + /// use stac::{Extensions, Item}; + /// let item = Item::new("an-id"); + /// assert!(item.extensions().is_empty()); + /// ``` + fn extensions(&self) -> &Vec; + + /// Returns a mutable reference to this object's extensions. + /// + /// # Examples + /// + /// ``` + /// use stac::{Extensions, Item}; + /// let mut item = Item::new("an-id"); + /// item.extensions_mut().push("https://stac-extensions.github.io/raster/v1.1.0/schema.json".to_string()); + /// ``` + fn extensions_mut(&mut self) -> &mut Vec; + + /// Returns true if this object has the given extension. + /// + /// # Examples + /// + /// ``` + /// use stac::{Item, extensions::{Projection, Extensions}}; + /// let mut item = Item::new("an-id"); + /// assert!(!item.has_extension::()); + /// let projection = Projection { epsg: Some(4326), ..Default::default() }; + /// item.set_extension(projection).unwrap(); + /// assert!(item.has_extension::()); + /// ``` + fn has_extension(&self) -> bool { + self.extensions() + .iter() + .any(|extension| extension.starts_with(E::identifier_prefix())) + } + + /// Gets an extension's data. + /// + /// # Examples + /// + /// ``` + /// use stac::{Item, extensions::{Projection, Extensions}}; + /// let item: Item = stac::read("data/extensions-collection/proj-example/proj-example.json").unwrap(); + /// let projection: Projection = item.extension().unwrap(); + /// assert_eq!(projection.epsg.unwrap(), 32614); + /// ``` + fn extension(&self) -> Result { + self.fields_with_prefix(E::PREFIX) + } + + /// Sets an extension's data and adds its schema to this object's `extensions`. + /// + /// This will remove any previous versions of this extension. + /// + /// # Examples + /// + /// ``` + /// use stac::{Item, extensions::{Projection, Extensions}}; + /// let mut item = Item::new("an-id"); + /// let projection = Projection { epsg: Some(4326), ..Default::default() }; + /// item.set_extension(projection).unwrap(); + /// ``` + fn set_extension(&mut self, extension: E) -> Result<()> { + self.remove_extension::(); + self.extensions_mut().push(E::IDENTIFIER.to_string()); + self.set_fields_with_prefix(E::PREFIX, extension) + } + + /// Removes this extension and all of its fields from this object. + /// + /// # Examples + /// + /// ``` + /// use stac::{Item, extensions::{Projection, Extensions}}; + /// let mut item: Item = stac::read("data/extensions-collection/proj-example/proj-example.json").unwrap(); + /// assert!(item.has_extension::()); + /// item.remove_extension::(); + /// assert!(!item.has_extension::()); + /// ``` + fn remove_extension(&mut self) { + // TODO how do we handle removing from assets when this is done on an item? + self.remove_fields_with_prefix(E::PREFIX); + self.extensions_mut() + .retain(|extension| !extension.starts_with(E::identifier_prefix())) + } +} + +#[cfg(test)] +mod tests { + use super::Extensions; + use crate::{ + extensions::{ + raster::{Band, Raster}, + Projection, + }, + Asset, Extension, Item, + }; + use serde_json::json; + + #[test] + fn identifer_prefix() { + assert_eq!( + Raster::identifier_prefix(), + "https://stac-extensions.github.io/raster/" + ); + assert_eq!( + Projection::identifier_prefix(), + "https://stac-extensions.github.io/projection/" + ); + } + + #[test] + fn set_extension_on_asset() { + let mut asset = Asset::new("a/href.tif"); + assert!(!asset.has_extension::()); + let mut band = Band::default(); + band.unit = Some("parsecs".to_string()); + let raster = Raster { bands: vec![band] }; + asset.set_extension(raster).unwrap(); + assert!(asset.has_extension::()); + let mut item = Item::new("an-id"); + let _ = item.assets.insert("data".to_string(), asset); + + // TODO how do we let items know about what their assets are doing? + // Maybe we don't? + // assert!(item.has_extension::()); + // let item = serde_json::to_value(item).unwrap(); + // assert_eq!( + // item.as_object() + // .unwrap() + // .get("stac_extensions") + // .unwrap() + // .as_array() + // .unwrap(), + // &vec!["https://stac-extensions.github.io/raster/v1.1.0/schema.json"] + // ); + } + + #[test] + fn remove_extension() { + let mut item = Item::new("an-id"); + item.extensions + .push("https://stac-extensions.github.io/projection/v1.1.0/schema.json".to_string()); + let _ = item + .properties + .additional_fields + .insert("proj:epsg".to_string(), json!(4326)); + assert!(item.has_extension::()); + item.remove_extension::(); + assert!(!item.has_extension::()); + assert!(item.extensions.is_empty()); + assert!(item.properties.additional_fields.is_empty()); + } +} diff --git a/stac/src/extensions/projection.rs b/stac/src/extensions/projection.rs new file mode 100644 index 00000000..5ef9fb83 --- /dev/null +++ b/stac/src/extensions/projection.rs @@ -0,0 +1,61 @@ +//! The Projection extension. + +use crate::Geometry; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::Extension; + +/// The projection extension fields. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Projection { + /// EPSG code of the datasource + #[serde(skip_serializing_if = "Option::is_none")] + pub epsg: Option, + + /// WKT2 string representing the Coordinate Reference System (CRS) that the + /// proj:geometry and proj:bbox fields represent + #[serde(skip_serializing_if = "Option::is_none")] + pub wkt2: Option, + + /// PROJJSON object representing the Coordinate Reference System (CRS) that + /// the proj:geometry and proj:bbox fields represent + #[serde(skip_serializing_if = "Option::is_none")] + pub projjson: Option>, + + /// Defines the footprint of this Item. + #[serde(skip_serializing_if = "Option::is_none")] + pub geometry: Option, + + /// Bounding box of the Item in the asset CRS in 2 or 3 dimensions. + #[serde(skip_serializing_if = "Option::is_none")] + pub bbox: Option>, + + /// Coordinates representing the centroid of the Item (in lat/long) + #[serde(skip_serializing_if = "Option::is_none")] + pub centroid: Option, + + /// Number of pixels in Y and X directions for the default grid + #[serde(skip_serializing_if = "Option::is_none")] + pub shape: Option>, + + /// The affine transformation coefficients for the default grid + #[serde(skip_serializing_if = "Option::is_none")] + pub transform: Option>, +} + +/// This object represents the centroid of the Item Geometry. +#[derive(Debug, Serialize, Deserialize)] +pub struct Centroid { + /// The latitude of the centroid. + pub lat: f64, + + /// The longitude of the centroid. + pub lon: f64, +} + +impl Extension for Projection { + const IDENTIFIER: &'static str = + "https://stac-extensions.github.io/projection/v1.1.0/schema.json"; + const PREFIX: &'static str = "proj"; +} diff --git a/stac/src/extensions/raster.rs b/stac/src/extensions/raster.rs new file mode 100644 index 00000000..b7ed0182 --- /dev/null +++ b/stac/src/extensions/raster.rs @@ -0,0 +1,197 @@ +//! The [Raster](https://github.com/stac-extensions/raster) extnesion. +//! +//! An item can describe assets that are rasters of one or multiple bands with +//! some information common to them all (raster size, projection) and also +//! specific to each of them (data type, unit, number of bits used, nodata). A +//! raster is often strongly linked with the georeferencing transform and +//! coordinate system definition of all bands (using the +//! [projection](https://github.com/stac-extensions/projection) extension). In +//! many applications, it is interesting to have some metadata about the rasters +//! in the asset (values statistics, value interpretation, transforms). + +use serde::{Deserialize, Serialize}; + +use super::Extension; + +/// The raster extension. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Raster { + /// An array of available bands where each object is a [Band]. + /// + /// If given, requires at least one band. + pub bands: Vec, +} + +/// The bands of a raster asset. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Band { + /// Pixel values used to identify pixels that are nodata in the band either + /// by the pixel value as a number or nan, inf or -inf (all strings). + /// + /// The extension specifies that this can be a number or a string, but we + /// just use a f64 with a custom (de)serializer. + /// + /// TODO write custom (de)serializer. + #[serde(skip_serializing_if = "Option::is_none")] + pub nodata: Option, + + /// One of area or point. + /// + /// Indicates whether a pixel value should be assumed to represent a + /// sampling over the region of the pixel or a point sample at the center of + /// the pixel. + #[serde(skip_serializing_if = "Option::is_none")] + pub sampling: Option, + + /// The data type of the pixels in the band. + #[serde(skip_serializing_if = "Option::is_none")] + pub data_type: Option, + + /// The actual number of bits used for this band. + /// + /// Normally only present when the number of bits is non-standard for the + /// datatype, such as when a 1 bit TIFF is represented as byte. + #[serde(skip_serializing_if = "Option::is_none")] + pub bits_per_sample: Option, + + /// Average spatial resolution (in meters) of the pixels in the band. + #[serde(skip_serializing_if = "Option::is_none")] + pub spatial_resolution: Option, + + /// Statistics of all the pixels in the band. + #[serde(skip_serializing_if = "Option::is_none")] + pub statistics: Option, + + /// Unit denomination of the pixel value. + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option, + + /// Multiplicator factor of the pixel value to transform into the value + /// (i.e. translate digital number to reflectance). + #[serde(skip_serializing_if = "Option::is_none")] + pub scale: Option, + + /// Number to be added to the pixel value (after scaling) to transform into + /// the value (i.e. translate digital number to reflectance). + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + + /// Histogram distribution information of the pixels values in the band. + #[serde(skip_serializing_if = "Option::is_none")] + pub histogram: Option, +} + +/// Indicates whether a pixel value should be assumed +/// to represent a sampling over the region of the pixel or a point sample +/// at the center of the pixel. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Sampling { + /// The pixel value is a sampling over the region. + Area, + + /// The pixel value is a point sample at the center of the pixel. + Point, +} + +/// The data type gives information about the values in the file. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DataType { + /// 8-bit integer + Int8, + + /// 16-bit integer + Int16, + + /// 32-bit integer + Int32, + + /// 64-bit integer + Int64, + + /// Unsigned 8-bit integer (common for 8-bit RGB PNG's) + UInt8, + + /// Unsigned 16-bit integer + UInt16, + + /// Unsigned 32-bit integer + UInt32, + + /// Unsigned 64-bit integer + UInt64, + + /// 16-bit float + Float16, + + /// 32-bit float + Float32, + + /// 64-bit float + Float64, + + /// 16-bit complex integer + CInt16, + + /// 32-bit complex integer + CInt32, + + /// 32-bit complex float + CFloat32, + + /// 64-bit complex float + CFloat64, + + /// Other data type than the ones listed above (e.g. boolean, string, higher precision numbers) + Other, +} + +/// Statistics of all pixels in the band. +#[derive(Debug, Serialize, Deserialize)] +pub struct Statistics { + /// Mean value of all the pixels in the band + #[serde(skip_serializing_if = "Option::is_none")] + pub mean: Option, + + /// Minimum value of all the pixels in the band + #[serde(skip_serializing_if = "Option::is_none")] + pub minimum: Option, + + /// Maximum value of all the pixels in the band + #[serde(skip_serializing_if = "Option::is_none")] + pub maximum: Option, + + /// Standard deviation value of all the pixels in the band + #[serde(skip_serializing_if = "Option::is_none")] + pub stddev: Option, + + /// Percentage of valid (not nodata) pixel + #[serde(skip_serializing_if = "Option::is_none")] + pub valid_percent: Option, +} + +/// The distribution of pixel values of a band can be provided with a histogram +/// object. +/// +/// Those values are sampled in buckets. A histogram object is atomic and all +/// fields are REQUIRED. +#[derive(Debug, Serialize, Deserialize)] +pub struct Histogram { + /// Number of buckets of the distribution. + pub count: u64, + + /// Minimum value of the distribution. Also the mean value of the first bucket. + pub min: f64, + + /// Maximum value of the distribution. Also the mean value of the last bucket. + pub max: f64, + + /// Array of integer indicating the number of pixels included in the bucket. + pub buckets: Vec, +} + +impl Extension for Raster { + const IDENTIFIER: &'static str = "https://stac-extensions.github.io/raster/v1.1.0/schema.json"; + const PREFIX: &'static str = "raster"; +} diff --git a/stac/src/fields.rs b/stac/src/fields.rs new file mode 100644 index 00000000..6c5dcd37 --- /dev/null +++ b/stac/src/fields.rs @@ -0,0 +1,115 @@ +use crate::{Error, Result}; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::{json, Map, Value}; + +/// Trait for structures that have gettable and settable fields. +pub trait Fields { + /// Gets the fields value. + /// + /// # Examples + /// + /// ``` + /// use stac::{Item, Fields}; + /// let item = Item::new("an-id"); + /// assert!(item.fields().is_empty()); + /// ``` + fn fields(&self) -> &Map; + + /// Gets a mutable reference to the fields value. + /// + /// # Examples + /// + /// ``` + /// use stac::{Item, Fields}; + /// let mut item = Item::new("an-id"); + /// item.fields_mut().insert("foo".to_string(), "bar".into()); + /// ``` + fn fields_mut(&mut self) -> &mut Map; + + /// Gets the value of a field. + /// + /// # Examples + /// + /// ``` + /// use stac::{Item, Fields}; + /// let mut item = Item::new("an-id"); + /// item.set_field("foo", "bar").unwrap(); + /// assert_eq!(item.properties.additional_fields.get("foo"), item.field("foo")); + /// ``` + fn field(&self, key: &str) -> Option<&Value> { + self.fields().get(key) + } + + /// Sets the value of a field. + /// + /// # Examples + /// + /// ``` + /// use stac::{Item, Fields}; + /// let mut item = Item::new("an-id"); + /// item.set_field("foo", "bar").unwrap(); + /// assert_eq!(item.properties.additional_fields["foo"], "bar"); + /// ``` + fn set_field(&mut self, key: impl ToString, value: S) -> Result> { + let value = serde_json::to_value(value)?; + Ok(self.fields_mut().insert(key.to_string(), value)) + } + + /// Gets values with a prefix. + /// + /// # Examples + /// + /// ``` + /// use stac::{Fields, Item, extensions::Projection}; + /// let item: Item = stac::read("data/extensions-collection/proj-example/proj-example.json").unwrap(); + /// let projection: Projection = item.fields_with_prefix("proj").unwrap(); // Prefer `Extensions::extension` + /// ``` + fn fields_with_prefix(&self, prefix: &str) -> Result { + let mut map = Map::new(); + let prefix = format!("{}:", prefix); + for (key, value) in self.fields().iter() { + if key.starts_with(&prefix) && key.len() > prefix.len() { + let _ = map.insert(key[prefix.len()..].to_string(), value.clone()); + } + } + serde_json::from_value(json!(map)).map_err(Error::from) + } + + /// Sets values with a prefix. + /// + /// # Examples + /// + /// ``` + /// use stac::{Fields, Item, extensions::Projection}; + /// let projection = Projection { epsg: Some(4326), ..Default::default() }; + /// let mut item = Item::new("an-id"); + /// item.set_fields_with_prefix("proj", projection); // Prefer `Extensions::set_extension` + /// ``` + fn set_fields_with_prefix(&mut self, prefix: &str, value: S) -> Result<()> { + let value = serde_json::to_value(value)?; + if let Value::Object(object) = value { + for (key, value) in object.into_iter() { + let _ = self.set_field(format!("{}:{}", prefix, key), value); + } + Ok(()) + } else { + Err(Error::NotAnObject(value)) + } + } + + /// Removes values with a prefix. + /// + /// # Examples + /// + /// ``` + /// use stac::{Fields, Item, extensions::Projection}; + /// let projection = Projection { epsg: Some(4326), ..Default::default() }; + /// let mut item = Item::new("an-id"); + /// item.remove_fields_with_prefix("proj"); // Prefer `Extensions::remove_extension` + /// ``` + fn remove_fields_with_prefix(&mut self, prefix: &str) { + let prefix = format!("{}:", prefix); + self.fields_mut() + .retain(|key, _| !(key.starts_with(&prefix) && key.len() > prefix.len())); + } +} diff --git a/stac/src/item.rs b/stac/src/item.rs index 421e47c9..11f9a797 100644 --- a/stac/src/item.rs +++ b/stac/src/item.rs @@ -1,4 +1,6 @@ -use crate::{Asset, Assets, Error, Extensions, Geometry, Href, Link, Links, Result, STAC_VERSION}; +use crate::{ + Asset, Assets, Error, Extensions, Fields, Geometry, Href, Link, Links, Result, STAC_VERSION, +}; use chrono::{DateTime, FixedOffset, Utc}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -29,8 +31,9 @@ pub struct Item { /// A list of extensions the `Item` implements. #[serde(rename = "stac_extensions")] - #[serde(skip_serializing_if = "Option::is_none")] - pub extensions: Option>, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default)] + pub extensions: Vec, /// Provider identifier. /// @@ -185,7 +188,7 @@ impl Item { Item { r#type: ITEM_TYPE.to_string(), version: STAC_VERSION.to_string(), - extensions: None, + extensions: Vec::new(), id: id.to_string(), geometry: None, bbox: None, @@ -418,9 +421,21 @@ impl Assets for Item { } } +impl Fields for Item { + fn fields(&self) -> &Map { + &self.properties.additional_fields + } + fn fields_mut(&mut self) -> &mut Map { + &mut self.properties.additional_fields + } +} + impl Extensions for Item { - fn extensions(&self) -> Option<&[String]> { - self.extensions.as_deref() + fn extensions(&self) -> &Vec { + &self.extensions + } + fn extensions_mut(&mut self) -> &mut Vec { + &mut self.extensions } } @@ -471,7 +486,7 @@ mod tests { assert!(item.collection.is_none()); assert_eq!(item.r#type, "Feature"); assert_eq!(item.version, STAC_VERSION); - assert!(item.extensions.is_none()); + assert!(item.extensions.is_empty()); assert_eq!(item.id, "an-id"); assert!(item.links.is_empty()); } diff --git a/stac/src/lib.rs b/stac/src/lib.rs index 7b001892..23368012 100644 --- a/stac/src/lib.rs +++ b/stac/src/lib.rs @@ -78,6 +78,11 @@ //! let item: Item = stac::read("data/simple-item.json").unwrap(); //! assert!(item.href().as_deref().unwrap().ends_with("data/simple-item.json")); //! ``` +//! +//! # Extensions +//! +//! STAC is intentionally designed with a minimal core and flexible extension mechanism to support a broad set of use cases. +//! See [the extensions module](extensions) for more information. #![cfg_attr(docsrs, feature(doc_auto_cfg))] #![deny( @@ -115,7 +120,8 @@ mod catalog; mod collection; pub mod datetime; mod error; -mod extensions; +pub mod extensions; +mod fields; #[cfg(feature = "geo")] pub mod geo; mod geometry; @@ -132,7 +138,8 @@ pub use { catalog::{Catalog, CATALOG_TYPE}, collection::{Collection, Extent, Provider, SpatialExtent, TemporalExtent, COLLECTION_TYPE}, error::Error, - extensions::Extensions, + extensions::{Extension, Extensions}, + fields::Fields, geometry::Geometry, href::{href_to_url, Href}, io::{read, read_json}, @@ -207,7 +214,17 @@ mod tests { let file = File::open($filename).unwrap(); let buf_reader = BufReader::new(file); - let before: Value = serde_json::from_reader(buf_reader).unwrap(); + let mut before: Value = serde_json::from_reader(buf_reader).unwrap(); + if let Some(object) = before.as_object_mut() { + if object + .get("stac_extensions") + .and_then(|value| value.as_array()) + .map(|array| array.is_empty()) + .unwrap_or_default() + { + let _ = object.remove("stac_extensions"); + } + } let object: $object = serde_json::from_value(before.clone()).unwrap(); let after = serde_json::to_value(object).unwrap(); assert_json_eq!(before, after);