diff --git a/stac-cli/README.md b/stac-cli/README.md index b69474fc..5edb8e9f 100644 --- a/stac-cli/README.md +++ b/stac-cli/README.md @@ -26,6 +26,14 @@ Use the cli `--help` flag to see all available options: stac --help ``` +### Item + +Create a STAC Item from an href: + +```shell +stac item https://storage.googleapis.com/open-cogs/stac-examples/20201211_223832_CS2.tif +``` + ### Search Search a STAC API: diff --git a/stac-cli/src/command.rs b/stac-cli/src/command.rs index c84d6374..f7ea9373 100644 --- a/stac-cli/src/command.rs +++ b/stac-cli/src/command.rs @@ -1,9 +1,43 @@ use crate::Result; use clap::Subcommand; use stac_api::GetSearch; +use std::path::Path; +use url::Url; #[derive(Debug, Subcommand)] pub enum Command { + /// Creates a STAC Item from an asset href. + Item { + /// The asset href. + href: String, + + /// The item id. + /// + /// If not provided, will be inferred from the filename in the href. + #[arg(short, long)] + id: Option, + + /// The asset key. + #[arg(short, long, default_value = "data")] + key: String, + + /// The asset roles. + /// + /// Can be provided multiple times. + #[arg(short, long)] + role: Vec, + + /// Allow relative paths. + /// + /// If false, paths will be canonicalized, which requires that the files actually exist on the filesystem. + #[arg(long)] + allow_relative_paths: bool, + + /// Use compact representation for the output. + #[arg(short, long)] + compact: bool, + }, + /// Searches a STAC API. Search { /// The href of the STAC API. @@ -102,6 +136,17 @@ impl Command { pub async fn execute(self) -> Result<()> { use Command::*; match self { + Item { + id, + href, + key, + role, + allow_relative_paths, + compact, + } => { + let id = id.unwrap_or_else(|| infer_id(&href)); + crate::commands::item(id, href, key, role, allow_relative_paths, compact) + } Search { href, max_items, @@ -141,3 +186,18 @@ impl Command { } } } + +fn infer_id(href: &str) -> String { + if let Ok(url) = Url::parse(href) { + url.path_segments() + .and_then(|path_segments| path_segments.last()) + .and_then(|path_segment| Path::new(path_segment).file_stem()) + .map(|file_stem| file_stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| href.to_string()) + } else { + Path::new(href) + .file_stem() + .map(|file_stem| file_stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| href.to_string()) + } +} diff --git a/stac-cli/src/commands/item.rs b/stac-cli/src/commands/item.rs new file mode 100644 index 00000000..508524fe --- /dev/null +++ b/stac-cli/src/commands/item.rs @@ -0,0 +1,24 @@ +use crate::Result; +use stac::{item::Builder, Asset}; + +pub fn item( + id: String, + href: String, + key: String, + roles: Vec, + allow_relative_paths: bool, + compact: bool, +) -> Result<()> { + let mut asset = Asset::new(href); + asset.roles = roles; + let item = Builder::new(id) + .asset(key, asset) + .canonicalize_paths(!allow_relative_paths) + .into_item()?; + if compact { + println!("{}", serde_json::to_string(&item)?); + } else { + println!("{}", serde_json::to_string_pretty(&item)?); + } + Ok(()) +} diff --git a/stac-cli/src/commands/mod.rs b/stac-cli/src/commands/mod.rs index 95b5d294..dc2145a6 100644 --- a/stac-cli/src/commands/mod.rs +++ b/stac-cli/src/commands/mod.rs @@ -1,5 +1,6 @@ +mod item; mod search; mod sort; mod validate; -pub use {search::search, sort::sort, validate::validate}; +pub use {item::item, search::search, sort::sort, validate::validate}; diff --git a/stac-cli/src/error.rs b/stac-cli/src/error.rs index 6ccb2022..b35691a5 100644 --- a/stac-cli/src/error.rs +++ b/stac-cli/src/error.rs @@ -16,6 +16,9 @@ pub enum Error { #[error(transparent)] StacApi(#[from] stac_api::Error), + #[error(transparent)] + Stac(#[from] stac::Error), + #[error(transparent)] StacAsync(#[from] stac_async::Error), diff --git a/stac/CHANGELOG.md b/stac/CHANGELOG.md index 73e74030..e1715eab 100644 --- a/stac/CHANGELOG.md +++ b/stac/CHANGELOG.md @@ -9,10 +9,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - The projection and raster extensions, the `Extension` trait, and the `Fields` trait ([#234](https://github.com/stac-utils/stac-rs/pull/234)) +- `stac::item::Builder` ([#237](https://github.com/stac-utils/stac-rs/pull/237)) ### Changed - The `extensions` attribute of catalogs, collections, and items is now non-optional ([#234](https://github.com/stac-utils/stac-rs/pull/234)) +- The `roles` attribute of assets is now non-optional ([#237](https://github.com/stac-utils/stac-rs/pull/237)) ## [0.5.3] - 2024-04-07 diff --git a/stac/assets/dataset.tif b/stac/assets/dataset.tif new file mode 100644 index 00000000..fcc73e58 Binary files /dev/null and b/stac/assets/dataset.tif differ diff --git a/stac/src/asset.rs b/stac/src/asset.rs index 74acd90c..abff44c0 100644 --- a/stac/src/asset.rs +++ b/stac/src/asset.rs @@ -29,8 +29,9 @@ pub struct Asset { pub r#type: Option, /// The semantic roles of the asset, similar to the use of rel in [Links](crate::Link). - #[serde(skip_serializing_if = "Option::is_none")] - pub roles: Option>, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default)] + pub roles: Vec, /// Creation date and time of the corresponding data, in UTC. /// @@ -107,13 +108,30 @@ impl Asset { title: None, description: None, r#type: None, - roles: None, + roles: Vec::new(), created: None, updated: None, additional_fields: Map::new(), extensions: Vec::new(), } } + + /// Adds a role to this asset, returning the modified asset. + /// + /// Useful for builder patterns. + /// + /// # Examples + /// + /// ``` + /// use stac::Asset; + /// let asset = Asset::new("asset/dataset.tif").role("data"); + /// assert_eq!(asset.roles, vec!["data"]); + /// ``` + pub fn role(mut self, role: impl ToString) -> Asset { + self.roles.push(role.to_string()); + self.roles.dedup(); + self + } } impl Fields for Asset { @@ -134,6 +152,18 @@ impl Extensions for Asset { } } +impl From for Asset { + fn from(value: String) -> Self { + Asset::new(value) + } +} + +impl<'a> From<&'a str> for Asset { + fn from(value: &'a str) -> Self { + Asset::new(value) + } +} + #[cfg(test)] mod tests { use super::Asset; @@ -145,7 +175,7 @@ mod tests { assert!(asset.title.is_none()); assert!(asset.description.is_none()); assert!(asset.r#type.is_none()); - assert!(asset.roles.is_none()); + assert!(asset.roles.is_empty()); } #[test] diff --git a/stac/src/item.rs b/stac/src/item.rs index 11f9a797..adbb8187 100644 --- a/stac/src/item.rs +++ b/stac/src/item.rs @@ -1,10 +1,13 @@ +//! STAC Items. + 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}; -use std::collections::HashMap; +use std::{collections::HashMap, path::Path}; +use url::Url; /// The type field for [Items](Item). pub const ITEM_TYPE: &str = "Feature"; @@ -156,6 +159,84 @@ pub struct Properties { pub additional_fields: Map, } +/// Builder for a STAC Item. +#[derive(Debug)] +pub struct Builder { + id: String, + canonicalize_paths: bool, + assets: HashMap, +} + +impl Builder { + /// Creates a new builder. + /// + /// # Examples + /// + /// ``` + /// use stac::item::Builder; + /// let builder = Builder::new("an-id"); + /// ``` + pub fn new(id: impl ToString) -> Builder { + Builder { + id: id.to_string(), + canonicalize_paths: true, + assets: HashMap::new(), + } + } + + /// Set to false to not canonicalize paths. + /// + /// Useful if you want relative paths, or the files don't actually exist. + /// + /// # Examples + /// + /// ``` + /// use stac::item::Builder; + /// let builder = Builder::new("an-id").canonicalize_paths(false); + /// ``` + pub fn canonicalize_paths(mut self, canonicalize_paths: bool) -> Builder { + self.canonicalize_paths = canonicalize_paths; + self + } + + /// Adds an asset by href to this builder. + /// + /// # Examples + /// + /// ``` + /// use stac::item::Builder; + /// let builder = Builder::new("an-id").asset("data", "assets/dataset.tif"); + /// ``` + pub fn asset(mut self, key: impl ToString, asset: impl Into) -> Builder { + let _ = self.assets.insert(key.to_string(), asset.into()); + self + } + + /// Creates an [Item] by consuming this builder. + /// + /// # Examples + /// + /// ``` + /// use stac::item::Builder; + /// let builder = Builder::new("an-id").asset("data", "assets/dataset.tif"); + /// let item = builder.into_item().unwrap(); + /// assert_eq!(item.assets.len(), 1); + /// ``` + pub fn into_item(self) -> Result { + let mut item = Item::new(self.id); + for (key, mut asset) in self.assets { + if Url::parse(&asset.href).is_err() && self.canonicalize_paths { + asset.href = Path::new(&asset.href) + .canonicalize()? + .to_string_lossy() + .into_owned(); + } + let _ = item.assets.insert(key, asset); + } + Ok(item) + } +} + impl Default for Properties { fn default() -> Properties { Properties { @@ -473,8 +554,8 @@ where #[cfg(test)] mod tests { - use super::Item; - use crate::STAC_VERSION; + use super::{Builder, Item}; + use crate::{Asset, STAC_VERSION}; use serde_json::Value; #[test] @@ -596,4 +677,35 @@ mod tests { Item ); } + + #[test] + fn builder() { + let builder = Builder::new("an-id").asset("data", "assets/dataset.tif"); + let item = builder.into_item().unwrap(); + assert_eq!(item.assets.len(), 1); + let asset = item.assets.get("data").unwrap(); + assert!(asset + .href + .ends_with(&format!("assets{}dataset.tif", std::path::MAIN_SEPARATOR))); + } + + #[test] + fn builder_relative_paths() { + let builder = Builder::new("an-id") + .canonicalize_paths(false) + .asset("data", "assets/dataset.tif"); + let item = builder.into_item().unwrap(); + let asset = item.assets.get("data").unwrap(); + assert_eq!(asset.href, "assets/dataset.tif"); + } + + #[test] + fn builder_asset_roles() { + let item = Builder::new("an-id") + .asset("data", Asset::new("assets/dataset.tif").role("data")) + .into_item() + .unwrap(); + let asset = item.assets.get("data").unwrap(); + assert_eq!(asset.roles, vec!["data"]); + } } diff --git a/stac/src/lib.rs b/stac/src/lib.rs index 23368012..cb554706 100644 --- a/stac/src/lib.rs +++ b/stac/src/lib.rs @@ -127,7 +127,7 @@ pub mod geo; mod geometry; mod href; mod io; -mod item; +pub mod item; mod item_collection; pub mod link; pub mod media_type;