diff --git a/README.md b/README.md index 18a6802a..5d96e500 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ $ stacrs search items.parquet # Server $ stacrs serve items.parquet # Opens a STAC API server on http://localhost:7822 + +# Validate +$ stacrs validate item.json ``` ## Python diff --git a/crates/cli/README.md b/crates/cli/README.md index 44079160..6a63c68b 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -43,6 +43,9 @@ $ stac search items.parquet # Server $ stacrs serve items.parquet # Opens a STAC API server on http://localhost:7822 + +# Validate +$ stacrs validate item.json ``` ## Usage @@ -52,6 +55,7 @@ $ stacrs serve items.parquet # Opens a STAC API server on http://localhost:7822 - `stacrs search`: searches STAC APIs and geoparquet files - `stacrs serve`: serves a STAC API - `stacrs translate`: converts STAC from one format to another +- `stacrs validate`: validates a STAC value Use the `--help` flag to see all available options for the CLI and the subcommands: diff --git a/crates/cli/data/invalid-item.json b/crates/cli/data/invalid-item.json new file mode 100644 index 00000000..b800a9bc --- /dev/null +++ b/crates/cli/data/invalid-item.json @@ -0,0 +1,81 @@ +{ + "stac_version": "1.1.0", + "stac_extensions": [], + "type": "Feature", + "id": "", + "bbox": [ + 172.91173669923782, + 1.3438851951615003, + 172.95469614953714, + 1.3690476620161975 + ], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 172.91173669923782, + 1.3438851951615003 + ], + [ + 172.95469614953714, + 1.3438851951615003 + ], + [ + 172.95469614953714, + 1.3690476620161975 + ], + [ + 172.91173669923782, + 1.3690476620161975 + ], + [ + 172.91173669923782, + 1.3438851951615003 + ] + ] + ] + }, + "properties": { + "datetime": "2020-12-11T22:38:32.125000Z" + }, + "collection": "simple-collection", + "links": [ + { + "rel": "collection", + "href": "./collection.json", + "type": "application/json", + "title": "Simple Example Collection" + }, + { + "rel": "root", + "href": "./collection.json", + "type": "application/json", + "title": "Simple Example Collection" + }, + { + "rel": "parent", + "href": "./collection.json", + "type": "application/json", + "title": "Simple Example Collection" + } + ], + "assets": { + "visual": { + "href": "https://storage.googleapis.com/open-cogs/stac-examples/20201211_223832_CS2.tif", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "3-Band Visual", + "roles": [ + "visual" + ] + }, + "thumbnail": { + "href": "https://storage.googleapis.com/open-cogs/stac-examples/20201211_223832_CS2.jpg", + "title": "Thumbnail", + "type": "image/jpeg", + "roles": [ + "thumbnail" + ] + } + } +} \ No newline at end of file diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 69c3f55f..bddcf200 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,10 +1,10 @@ use anyhow::{anyhow, Error, Result}; use clap::{Parser, Subcommand}; -use stac::{geoparquet::Compression, Collection, Format, Item, Links, Migrate}; +use stac::{geoparquet::Compression, Collection, Format, Item, Links, Migrate, Validate}; use stac_api::{GetItems, GetSearch, Search}; use stac_server::Backend; use std::{collections::HashMap, io::Write, str::FromStr}; -use tokio::{io::AsyncReadExt, net::TcpListener}; +use tokio::{io::AsyncReadExt, net::TcpListener, runtime::Handle}; /// stacrs: A command-line interface for the SpatioTemporal Asset Catalog (STAC) #[derive(Debug, Parser)] @@ -193,6 +193,17 @@ pub enum Command { #[arg(long, default_value_t = true)] create_collections: bool, }, + + /// Validates a STAC value. + /// + /// The default output format is plain text — use `--output-format=json` to + /// get structured output. + Validate { + /// The input file. + /// + /// To read from standard input, pass `-` or don't provide an argument at all. + infile: Option, + }, } #[derive(Debug)] @@ -336,6 +347,40 @@ impl Stacrs { load_and_serve(addr, backend, collections, items, create_collections).await } } + Command::Validate { ref infile } => { + let value = self.get(infile.as_deref()).await?; + let result = Handle::current() + .spawn_blocking(move || value.validate()) + .await?; + if let Err(error) = result { + if let stac::Error::Validation(errors) = error { + if let Some(format) = self.output_format { + if let Format::Json(_) = format { + let value = errors + .into_iter() + .map(|error| error.into_json()) + .collect::>(); + if self.compact_json.unwrap_or_default() { + serde_json::to_writer(std::io::stdout(), &value)?; + } else { + serde_json::to_writer_pretty(std::io::stdout(), &value)?; + } + println!(""); + } else { + return Err(anyhow!("invalid output format: {}", format)); + } + } else { + for error in errors { + println!("{}", error); + } + } + } + std::io::stdout().flush()?; + Err(anyhow!("one or more validation errors")) + } else { + Ok(()) + } + } } } @@ -591,4 +636,18 @@ mod tests { Format::Geoparquet(Some(Compression::LZO)) ); } + + #[rstest] + fn validate(mut command: Command) { + command + .arg("validate") + .arg("examples/simple-item.json") + .assert() + .success(); + command + .arg("validate") + .arg("data/invalid-item.json") + .assert() + .failure(); + } } diff --git a/crates/core/CHANGELOG.md b/crates/core/CHANGELOG.md index a1dfaad7..b6e8f6b9 100644 --- a/crates/core/CHANGELOG.md +++ b/crates/core/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- `error::Validation::into_json` ([#613](https://github.com/stac-utils/stac-rs/pull/613)) + ### Removed - Async validation ([#611](https://github.com/stac-utils/stac-rs/pull/611)) diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 4c50dc7b..6f8fad23 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -174,15 +174,6 @@ impl Validation { error: jsonschema::ValidationError<'_>, value: Option<&serde_json::Value>, ) -> Validation { - use std::borrow::Cow; - - // Cribbed from https://docs.rs/jsonschema/latest/src/jsonschema/error.rs.html#21-30 - let error = jsonschema::ValidationError { - instance_path: error.instance_path.clone(), - instance: Cow::Owned(error.instance.into_owned()), - kind: error.kind, - schema_path: error.schema_path, - }; let mut id = None; let mut r#type = None; if let Some(value) = value.and_then(|v| v.as_object()) { @@ -192,7 +183,21 @@ impl Validation { .and_then(|v| v.as_str()) .and_then(|s| s.parse::().ok()); } - Validation { id, r#type, error } + Validation { + id, + r#type, + error: error.to_owned(), + } + } + + /// Converts this validation error into a [serde_json::Value]. + pub fn into_json(self) -> serde_json::Value { + let error_description = jsonschema::output::ErrorDescription::from(self.error); + serde_json::json!({ + "id": self.id, + "type": self.r#type, + "error": error_description, + }) } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 77f8f836..8d0e308f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -221,7 +221,7 @@ pub const STAC_VERSION: Version = Version::v1_1_0; pub type Result = std::result::Result; /// Enum for the four "types" of STAC values. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize)] pub enum Type { /// An item. Item,