diff --git a/stac-api/CHANGELOG.md b/stac-api/CHANGELOG.md index f178120d..dabda23c 100644 --- a/stac-api/CHANGELOG.md +++ b/stac-api/CHANGELOG.md @@ -10,14 +10,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Conformance URIs ([#170](https://github.com/gadomski/stac-rs/pull/170)) - `schemars` feature ([#177](https://github.com/gadomski/stac-rs/pull/177)) +- `PartialEq` to `Filter` ([#179](https://github.com/gadomski/stac-rs/pull/179)) +- `TryFrom` to go between `Items` and `GetItems` ([#179](https://github.com/gadomski/stac-rs/pull/179)) ### Changed - Don't serialize a missing context in an `ItemCollection` ([#170](https://github.com/gadomski/stac-rs/pull/170)) +### Fixed + +- Strip plus sign from fields ([#179](https://github.com/gadomski/stac-rs/pull/179)) + ### Removed - `LinkBuilder` ([#170](https://github.com/gadomski/stac-rs/pull/170)) +- `Items.into_get_items` ([#179](https://github.com/gadomski/stac-rs/pull/179)) ## [0.2.0] - 2023-04-03 diff --git a/stac-api/README.md b/stac-api/README.md index 0a298532..7670b56e 100644 --- a/stac-api/README.md +++ b/stac-api/README.md @@ -8,7 +8,7 @@ Rust implementation of the data structures that make up the [STAC API specification](https://github.com/radiantearth/stac-api-spec). This is **not** a server implementation. -For an (experimental) STAC API server written in Rust, check out [stac-server-rs](https://github.com/gadomski/stac-server-rs). +For a STAC API server written in Rust, check out [stac-server-rs](https://github.com/gadomski/stac-server-rs). ## Usage diff --git a/stac-api/src/error.rs b/stac-api/src/error.rs index 43672316..71261f68 100644 --- a/stac-api/src/error.rs +++ b/stac-api/src/error.rs @@ -14,6 +14,14 @@ pub enum Error { #[error("cannot convert cql2-json to strings")] CannotConvertCql2JsonToString(Map), + /// [std::num::ParseIntError] + #[error(transparent)] + ParseIntError(#[from] std::num::ParseIntError), + + /// [std::num::ParseFloatError] + #[error(transparent)] + ParseFloatError(#[from] std::num::ParseFloatError), + /// [serde_json::Error] #[error(transparent)] SerdeJson(#[from] serde_json::Error), diff --git a/stac-api/src/fields.rs b/stac-api/src/fields.rs index 4f739296..e91cc2c4 100644 --- a/stac-api/src/fields.rs +++ b/stac-api/src/fields.rs @@ -34,6 +34,8 @@ impl FromStr for Fields { for field in s.split(",").filter(|s| !s.is_empty()) { if field.starts_with('-') { exclude.push(field[1..].to_string()); + } else if field.starts_with("+") { + include.push(field[1..].to_string()); } else { include.push(field.to_string()); } @@ -64,6 +66,17 @@ mod tests { assert_eq!(Fields::default(), "".parse().unwrap()); } + #[test] + fn plus() { + assert_eq!( + Fields { + include: vec!["foo".to_string()], + exclude: Vec::new(), + }, + "+foo".parse().unwrap() + ); + } + #[test] fn includes() { assert_eq!( diff --git a/stac-api/src/filter.rs b/stac-api/src/filter.rs index 668dc14c..aabb71c6 100644 --- a/stac-api/src/filter.rs +++ b/stac-api/src/filter.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; /// The language of the filter expression. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "filter-lang", content = "filter")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub enum Filter { diff --git a/stac-api/src/items.rs b/stac-api/src/items.rs index d409a585..83170303 100644 --- a/stac-api/src/items.rs +++ b/stac-api/src/items.rs @@ -1,4 +1,4 @@ -use crate::{Error, Fields, Filter, Result, Sortby}; +use crate::{Error, Fields, Filter, Result, Search, Sortby}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::collections::HashMap; @@ -104,28 +104,45 @@ pub struct GetItems { } impl Items { - /// Converts this items structure into a [GetItems]. - /// - /// Used as query parameters in a GET request. + /// Converts this items object to a search in the given collection. /// /// # Examples /// /// ``` - /// # use stac_api::{Items, GetItems, Fields}; + /// use stac_api::Items; /// let items = Items { - /// fields: Some(Fields { - /// include: vec!["foo".to_string()], - /// exclude: vec!["bar".to_string()], - /// }), + /// datetime: Some("2023".to_string()), /// ..Default::default() /// }; - /// let get_items = items.into_get_items().unwrap(); - /// assert_eq!(get_items.fields.unwrap(), "foo,-bar"); - pub fn into_get_items(self) -> Result { - if let Some(query) = self.query { + /// let search = items.into_search("collection-id"); + /// assert_eq!(search.collections.unwrap(), vec!["collection-id"]); + /// ``` + pub fn into_search(self, collection_id: impl ToString) -> Search { + Search { + limit: self.limit, + bbox: self.bbox, + datetime: self.datetime, + intersects: None, + ids: None, + collections: Some(vec![collection_id.to_string()]), + fields: self.fields, + sortby: self.sortby, + filter_crs: self.filter_crs, + filter: self.filter, + query: self.query, + additional_fields: self.additional_fields, + } + } +} + +impl TryFrom for GetItems { + type Error = Error; + + fn try_from(items: Items) -> Result { + if let Some(query) = items.query { return Err(Error::CannotConvertQueryToString(query)); } - let filter = if let Some(filter) = self.filter { + let filter = if let Some(filter) = items.filter { match filter { Filter::Cql2Json(json) => return Err(Error::CannotConvertCql2JsonToString(json)), Filter::Cql2Text(text) => Some(text), @@ -134,26 +151,26 @@ impl Items { None }; Ok(GetItems { - limit: self.limit.map(|n| n.to_string()), - bbox: self.bbox.map(|bbox| { + limit: items.limit.map(|n| n.to_string()), + bbox: items.bbox.map(|bbox| { bbox.into_iter() .map(|n| n.to_string()) .collect::>() .join(",") }), - datetime: self.datetime, - fields: self.fields.map(|fields| fields.to_string()), - sortby: self.sortby.map(|sortby| { + datetime: items.datetime, + fields: items.fields.map(|fields| fields.to_string()), + sortby: items.sortby.map(|sortby| { sortby .into_iter() .map(|s| s.to_string()) .collect::>() .join(",") }), - filter_crs: self.filter_crs, + filter_crs: items.filter_crs, filter_lang: filter.as_ref().map(|_| "cql2-text".to_string()), filter: filter, - additional_fields: self + additional_fields: items .additional_fields .into_iter() .map(|(key, value)| (key, value.to_string())) @@ -161,3 +178,130 @@ impl Items { }) } } + +impl TryFrom for Items { + type Error = Error; + + fn try_from(get_items: GetItems) -> Result { + let bbox = if let Some(value) = get_items.bbox { + let mut bbox = Vec::new(); + for s in value.split(",") { + bbox.push(s.parse()?) + } + Some(bbox) + } else { + None + }; + + let sortby = if let Some(value) = get_items.sortby { + let mut sortby = Vec::new(); + for s in value.split(",") { + sortby.push(s.parse().expect("infallible")); + } + Some(sortby) + } else { + None + }; + + Ok(Items { + limit: get_items.limit.map(|limit| limit.parse()).transpose()?, + bbox, + datetime: get_items.datetime, + fields: get_items + .fields + .map(|fields| fields.parse().expect("infallible")), + sortby, + filter_crs: get_items.filter_crs, + filter: get_items.filter.map(Filter::Cql2Text), + query: None, + additional_fields: get_items + .additional_fields + .into_iter() + .map(|(key, value)| (key, Value::String(value))) + .collect(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::{GetItems, Items}; + use crate::{sort::Direction, Fields, Filter, Sortby}; + use serde_json::{Map, Value}; + use std::collections::HashMap; + + #[test] + fn get_items_try_from_items() { + let mut additional_fields = HashMap::new(); + let _ = additional_fields.insert("token".to_string(), "foobar".to_string()); + + let get_items = GetItems { + limit: Some("42".to_string()), + bbox: Some("-1,-2,1,2".to_string()), + datetime: Some("2023".to_string()), + fields: Some("+foo,-bar".to_string()), + sortby: Some("-foo".to_string()), + filter_crs: None, + filter_lang: Some("cql2-text".to_string()), + filter: Some("dummy text".to_string()), + additional_fields, + }; + + let items: Items = get_items.try_into().unwrap(); + assert_eq!(items.limit.unwrap(), 42); + assert_eq!(items.bbox.unwrap(), vec![-1.0, -2.0, 1.0, 2.0]); + assert_eq!(items.datetime.unwrap(), "2023"); + assert_eq!( + items.fields.unwrap(), + Fields { + include: vec!["foo".to_string()], + exclude: vec!["bar".to_string()], + } + ); + assert_eq!( + items.sortby.unwrap(), + vec![Sortby { + field: "foo".to_string(), + direction: Direction::Descending, + }] + ); + assert_eq!( + items.filter.unwrap(), + Filter::Cql2Text("dummy text".to_string()) + ); + assert_eq!(items.additional_fields["token"], "foobar"); + } + + #[test] + fn items_try_from_get_items() { + let mut additional_fields = Map::new(); + let _ = additional_fields.insert("token".to_string(), Value::String("foobar".to_string())); + + let items = Items { + limit: Some(42), + bbox: Some(vec![-1.0, -2.0, 1.0, 2.0]), + datetime: Some("2023".to_string()), + fields: Some(Fields { + include: vec!["foo".to_string()], + exclude: vec!["bar".to_string()], + }), + sortby: Some(vec![Sortby { + field: "foo".to_string(), + direction: Direction::Descending, + }]), + filter_crs: None, + filter: Some(Filter::Cql2Text("dummy text".to_string())), + query: None, + additional_fields, + }; + + let get_items: GetItems = items.try_into().unwrap(); + assert_eq!(get_items.limit.unwrap(), "42"); + assert_eq!(get_items.bbox.unwrap(), "-1,-2,1,2"); + assert_eq!(get_items.datetime.unwrap(), "2023"); + assert_eq!(get_items.fields.unwrap(), "foo,-bar"); + assert_eq!(get_items.sortby.unwrap(), "-foo"); + assert_eq!(get_items.filter.unwrap(), "dummy text"); + assert_eq!(get_items.additional_fields["token"], "\"foobar\""); + } +} diff --git a/stac-async/src/api_client.rs b/stac-async/src/api_client.rs index 5b393419..f951a468 100644 --- a/stac-async/src/api_client.rs +++ b/stac-async/src/api_client.rs @@ -4,7 +4,7 @@ use futures_core::stream::Stream; use futures_util::{pin_mut, StreamExt}; use reqwest::Method; use stac::{Collection, Links}; -use stac_api::{Item, ItemCollection, Items, Search, UrlBuilder}; +use stac_api::{GetItems, Item, ItemCollection, Items, Search, UrlBuilder}; use tokio::sync::mpsc; const DEFAULT_CHANNEL_BUFFER: usize = 4; @@ -87,7 +87,7 @@ impl ApiClient { ) -> Result>> { let url = self.url_builder.items(id)?; // TODO HATEOS let items = if let Some(items) = items.into() { - Some(items.into_get_items()?) + Some(GetItems::try_from(items)?) } else { None };