use crate::{Backend, Error, Result, APPLICATION_OPENAPI_3_0, DEFAULT_DESCRIPTION, DEFAULT_ID};
use http::Method;
use serde::Serialize;
use serde_json::{Map, Value};
use stac::{Catalog, Collection, Fields, Item, Link, Links};
use stac_api::{Collections, Conformance, ItemCollection, Items, Root, Search};
use url::Url;
#[derive(Clone, Debug)]
pub struct Api<B: Backend> {
pub backend: B,
pub description: String,
pub id: String,
pub root: Url,
}
impl<B: Backend> Api<B> {
pub fn new(backend: B, root: &str) -> Result<Api<B>> {
Ok(Api {
backend,
id: DEFAULT_ID.to_string(),
description: DEFAULT_DESCRIPTION.to_string(),
root: root.parse()?,
})
}
pub fn id(mut self, id: impl ToString) -> Api<B> {
self.id = id.to_string();
self
}
pub fn description(mut self, description: impl ToString) -> Api<B> {
self.description = description.to_string();
self
}
fn url(&self, path: &str) -> Result<Url> {
self.root.join(path).map_err(Error::from)
}
pub async fn root(&self) -> Result<Root> {
let mut catalog = Catalog::new(&self.id, &self.description);
catalog.set_link(Link::root(&self.root).json());
catalog.set_link(Link::self_(&self.root).json());
catalog.set_link(
Link::new(self.url("/api")?, "service-desc")
.r#type(APPLICATION_OPENAPI_3_0.to_string()),
);
catalog.set_link(
Link::new(self.url("/api.html")?, "service-doc").r#type("text/html".to_string()),
);
catalog.set_link(Link::new(self.url("/conformance")?, "conformance").json());
catalog.set_link(Link::new(self.url("/collections")?, "data").json());
for collection in self.backend.collections().await? {
catalog
.links
.push(Link::child(self.url(&format!("/collections/{}", collection.id))?).json());
}
let search_url = self.url("/search")?;
catalog
.links
.push(Link::new(&search_url, "search").geojson().method("GET"));
catalog
.links
.push(Link::new(search_url, "search").geojson().method("POST"));
Ok(Root {
catalog,
conformance: self.conformance(),
})
}
pub fn conformance(&self) -> Conformance {
let mut conformance = Conformance::new().ogcapi_features();
if self.backend.has_item_search() {
conformance = conformance.item_search();
}
conformance
}
pub async fn collections(&self) -> Result<Collections> {
let mut collections: Collections = self.backend.collections().await?.into();
collections.set_link(Link::root(&self.root).json());
collections.set_link(Link::self_(self.url("/collections")?).json());
for collection in collections.collections.iter_mut() {
self.set_collection_links(collection)?;
}
Ok(collections)
}
pub async fn collection(&self, id: &str) -> Result<Option<Collection>> {
if let Some(mut collection) = self.backend.collection(id).await? {
self.set_collection_links(&mut collection)?;
Ok(Some(collection))
} else {
Ok(None)
}
}
pub async fn items(&self, collection_id: &str, items: Items) -> Result<Option<ItemCollection>> {
if let Some(mut item_collection) = self.backend.items(collection_id, items.clone()).await? {
let collection_url = self.url(&format!("/collections/{}", collection_id))?;
let items_url = self.url(&format!("/collections/{}/items", collection_id))?;
item_collection.set_link(Link::root(&self.root).json());
item_collection.set_link(Link::self_(items_url.clone()).geojson());
item_collection.set_link(Link::collection(collection_url).json());
if let Some(next) = item_collection.next.take() {
item_collection.set_link(self.pagination_link(
items_url.clone(),
items.clone(),
next,
"next",
&Method::GET,
)?);
}
if let Some(prev) = item_collection.prev.take() {
item_collection.set_link(self.pagination_link(
items_url,
items,
prev,
"prev",
&Method::GET,
)?);
}
for item in item_collection.items.iter_mut() {
self.set_item_links(item)?;
}
Ok(Some(item_collection))
} else {
Ok(None)
}
}
pub async fn item(&self, collection_id: &str, item_id: &str) -> Result<Option<Item>> {
if let Some(mut item) = self.backend.item(collection_id, item_id).await? {
item.set_link(Link::root(&self.root).json());
item.set_link(
Link::self_(
self.url(&format!("/collections/{}/items/{}", collection_id, item_id))?,
)
.geojson(),
);
let collection_url = self.url(&format!("/collections/{}", collection_id))?;
item.set_link(Link::collection(collection_url.clone()).json());
item.set_link(Link::parent(collection_url).json());
Ok(Some(item))
} else {
Ok(None)
}
}
pub async fn search(&self, search: Search, method: Method) -> Result<ItemCollection> {
let mut item_collection = self.backend.search(search.clone()).await?;
item_collection.set_link(Link::root(&self.root).json());
let search_url = self.url("/search")?;
if let Some(next) = item_collection.next.take() {
item_collection.set_link(self.pagination_link(
search_url.clone(),
search.clone(),
next,
"next",
&method,
)?);
}
if let Some(prev) = item_collection.prev.take() {
item_collection
.set_link(self.pagination_link(search_url, search, prev, "prev", &method)?);
}
for item in item_collection.items.iter_mut() {
self.set_item_links(item)?;
}
Ok(item_collection)
}
fn set_collection_links(&self, collection: &mut Collection) -> Result<()> {
collection.set_link(Link::root(&self.root).json());
collection
.set_link(Link::self_(self.url(&format!("/collections/{}", collection.id))?).json());
collection.set_link(Link::parent(&self.root).json());
collection.set_link(
Link::new(
self.url(&format!("/collections/{}/items", collection.id))?,
"items",
)
.geojson(),
);
Ok(())
}
fn pagination_link<D>(
&self,
mut url: Url,
mut data: D,
pagination: Map<String, Value>,
rel: &str,
method: &Method,
) -> Result<Link>
where
D: Fields + Serialize,
{
for (key, value) in pagination {
let _ = data.set_field(key, value)?;
}
match *method {
Method::GET => {
url.set_query(Some(&serde_urlencoded::to_string(data)?));
Ok(Link::new(url, rel).geojson().method("GET"))
}
Method::POST => Ok(Link::new(url, rel).geojson().method("POST").body(data)?),
_ => unimplemented!(),
}
}
fn set_item_links(&self, item: &mut stac_api::Item) -> Result<()> {
let mut collection_url = None;
let mut item_link = None;
if let Some(item_id) = item.get("id").and_then(|id| id.as_str()) {
if let Some(collection_id) = item.get("collection").and_then(|id| id.as_str()) {
collection_url = Some(self.url(&format!("/collections/{}", collection_id))?);
item_link = Some(serde_json::to_value(
Link::self_(
self.url(&format!("/collections/{}/items/{}", collection_id, item_id))?,
)
.geojson(),
)?);
}
}
if item
.get("links")
.map(|links| !links.is_array())
.unwrap_or(true)
{
let _ = item.insert("links".to_string(), Value::Array(Vec::new()));
}
let links = item.get_mut("links").unwrap().as_array_mut().unwrap();
links.push(serde_json::to_value(Link::root(&self.root).json())?);
if let Some(item_link) = item_link {
links.push(item_link);
}
if let Some(collection_url) = collection_url {
links.push(serde_json::to_value(
Link::collection(&collection_url).json(),
)?);
links.push(serde_json::to_value(Link::parent(&collection_url).json())?);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::Api;
use crate::{Backend, MemoryBackend};
use http::Method;
use stac::{Catalog, Collection, Item, Links};
use stac_api::{Items, Search, ITEM_SEARCH_URI};
use stac_validate::Validate;
use std::collections::HashSet;
macro_rules! assert_link {
($link:expr, $href:expr, $media_type:expr) => {
let link = $link.unwrap();
assert_eq!(link.href, $href);
assert_eq!(link.r#type.as_ref().unwrap(), $media_type);
};
}
fn test_api(backend: MemoryBackend) -> Api<MemoryBackend> {
Api::new(backend, "http://stac.test/")
.unwrap()
.id("an-id")
.description("a description")
}
#[tokio::test]
async fn root() {
let mut backend = MemoryBackend::new();
backend
.add_collection(Collection::new("a-collection", "A description"))
.await
.unwrap();
let api = test_api(backend);
let root = api.root().await.unwrap();
assert!(!root.conformance.conforms_to.is_empty());
let catalog: Catalog = serde_json::from_value(serde_json::to_value(root).unwrap()).unwrap();
catalog.validate().unwrap();
assert_eq!(catalog.id, "an-id");
assert_eq!(catalog.description, "a description");
assert_link!(
catalog.link("root"),
"http://stac.test/",
"application/json"
);
assert_link!(
catalog.link("self"),
"http://stac.test/",
"application/json"
);
assert_link!(
catalog.link("service-desc"),
"http://stac.test/api",
"application/vnd.oai.openapi+json;version=3.0"
);
assert_link!(
catalog.link("service-doc"),
"http://stac.test/api.html",
"text/html"
);
assert_link!(
catalog.link("conformance"),
"http://stac.test/conformance",
"application/json"
);
assert_link!(
catalog.link("data"),
"http://stac.test/collections",
"application/json"
);
let mut methods = HashSet::new();
let search_links = catalog.links.iter().filter(|link| link.rel == "search");
for link in search_links {
assert_eq!(link.href, "http://stac.test/search");
assert_eq!(link.r#type.as_deref().unwrap(), "application/geo+json");
let _ = methods.insert(link.method.as_deref().unwrap());
}
assert_eq!(methods.len(), 2);
assert!(methods.contains("GET"));
assert!(methods.contains("POST"));
let children: Vec<_> = catalog.iter_child_links().collect();
assert_eq!(children.len(), 1);
let child = children[0];
assert_eq!(child.href, "http://stac.test/collections/a-collection");
assert_eq!(child.r#type.as_ref().unwrap(), "application/json");
}
#[tokio::test]
async fn conformance() {
let api = test_api(MemoryBackend::new());
let conformance = api.conformance();
for conformance_class in [
"https://api.stacspec.org/v1.0.0/core",
"https://api.stacspec.org/v1.0.0/ogcapi-features",
"https://api.stacspec.org/v1.0.0/collections",
"http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson",
"http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core",
] {
assert!(
conformance
.conforms_to
.contains(&conformance_class.to_string()),
"{} not in the conforms_to list",
conformance_class
);
}
}
#[tokio::test]
async fn collections() {
let mut backend = MemoryBackend::new();
backend
.add_collection(Collection::new("a-collection", "A description"))
.await
.unwrap();
let api = test_api(backend);
let collections = api.collections().await.unwrap();
assert_link!(
collections.link("root"),
"http://stac.test/",
"application/json"
);
assert_link!(
collections.link("self"),
"http://stac.test/collections",
"application/json"
);
assert_eq!(collections.collections.len(), 1);
let collection = &collections.collections[0];
collection.validate().unwrap();
assert_link!(
collection.link("root"),
"http://stac.test/",
"application/json"
);
assert_link!(
collection.link("self"),
"http://stac.test/collections/a-collection",
"application/json"
);
assert_link!(
collection.link("parent"),
"http://stac.test/",
"application/json"
);
assert_link!(
collection.link("items"),
"http://stac.test/collections/a-collection/items",
"application/geo+json"
);
}
#[tokio::test]
async fn collection() {
let mut backend = MemoryBackend::new();
backend
.add_collection(Collection::new("a-collection", "A description"))
.await
.unwrap();
let api = test_api(backend);
let collection = api.collection("a-collection").await.unwrap().unwrap();
collection.validate().unwrap();
assert_link!(
collection.link("root"),
"http://stac.test/",
"application/json"
);
assert_link!(
collection.link("self"),
"http://stac.test/collections/a-collection",
"application/json"
);
assert_link!(
collection.link("parent"),
"http://stac.test/",
"application/json"
);
assert_link!(
collection.link("items"),
"http://stac.test/collections/a-collection/items",
"application/geo+json"
);
}
#[tokio::test]
async fn items() {
let mut backend = MemoryBackend::new();
let api = test_api(backend.clone());
assert!(api
.items("collection-id", Items::default())
.await
.unwrap()
.is_none());
backend
.add_collection(Collection::new("collection-id", "a description"))
.await
.unwrap();
backend
.add_item(Item::new("item-a").collection("collection-id"))
.await
.unwrap();
let items = api
.items("collection-id", Items::default())
.await
.unwrap()
.unwrap();
assert_link!(items.link("root"), "http://stac.test/", "application/json");
assert_link!(
items.link("self"),
"http://stac.test/collections/collection-id/items",
"application/geo+json"
);
assert_link!(
items.link("collection"),
"http://stac.test/collections/collection-id",
"application/json"
);
assert_eq!(items.items.len(), 1);
let item: Item = items.items[0].clone().try_into().unwrap();
assert_link!(item.link("root"), "http://stac.test/", "application/json");
assert_link!(
item.link("self"),
"http://stac.test/collections/collection-id/items/item-a",
"application/geo+json"
);
assert_link!(
item.link("collection"),
"http://stac.test/collections/collection-id",
"application/json"
);
assert_link!(
item.link("parent"),
"http://stac.test/collections/collection-id",
"application/json"
);
}
#[tokio::test]
async fn items_pagination() {
let mut backend = MemoryBackend::new();
backend
.add_collection(Collection::new("collection-id", "a description"))
.await
.unwrap();
backend
.add_item(Item::new("item-a").collection("collection-id"))
.await
.unwrap();
backend
.add_item(Item::new("item-b").collection("collection-id"))
.await
.unwrap();
let api = test_api(backend);
let items = Items {
limit: Some(1),
..Default::default()
};
let items = api.items("collection-id", items).await.unwrap().unwrap();
assert_eq!(items.items.len(), 1);
assert_link!(
items.link("next"),
"http://stac.test/collections/collection-id/items?limit=1&skip=1",
"application/geo+json"
);
let mut items = Items {
limit: Some(1),
..Default::default()
};
let _ = items
.additional_fields
.insert("skip".to_string(), "1".into());
let items = api.items("collection-id", items).await.unwrap().unwrap();
assert_eq!(items.items.len(), 1);
assert_link!(
items.link("prev"),
"http://stac.test/collections/collection-id/items?limit=1&skip=0",
"application/geo+json"
);
}
#[tokio::test]
async fn item() {
let mut backend = MemoryBackend::new();
let api = test_api(backend.clone());
assert!(api
.item("collection-id", "item-id")
.await
.unwrap()
.is_none());
backend
.add_collection(Collection::new("collection-id", "a description"))
.await
.unwrap();
backend
.add_item(Item::new("item-id").collection("collection-id"))
.await
.unwrap();
let item = api.item("collection-id", "item-id").await.unwrap().unwrap();
assert_link!(item.link("root"), "http://stac.test/", "application/json");
assert_link!(
item.link("self"),
"http://stac.test/collections/collection-id/items/item-id",
"application/geo+json"
);
assert_link!(
item.link("collection"),
"http://stac.test/collections/collection-id",
"application/json"
);
assert_link!(
item.link("parent"),
"http://stac.test/collections/collection-id",
"application/json"
);
}
#[tokio::test]
async fn search() {
let api = test_api(MemoryBackend::new());
let item_collection = api.search(Search::default(), Method::GET).await.unwrap();
assert!(item_collection.items.is_empty());
assert_link!(
item_collection.link("root"),
"http://stac.test/",
"application/json"
);
}
#[test]
fn memory_item_search_conformance() {
let api = test_api(MemoryBackend::new());
let conformance = api.conformance();
if cfg!(feature = "memory-item-search") {
assert!(conformance
.conforms_to
.contains(&ITEM_SEARCH_URI.to_string()));
} else {
assert!(!conformance
.conforms_to
.contains(&ITEM_SEARCH_URI.to_string()));
}
}
}