Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Filter conformance class #519

Merged
merged 5 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions crates/api/src/conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ pub const GEOJSON_URI: &str = "http://www.opengis.net/spec/ogcapi-features-1/1.0
/// The item search conformance uri.
pub const ITEM_SEARCH_URI: &str = "https://api.stacspec.org/v1.0.0/item-search";

/// The filter conformance uris.
pub const FILTER_URIS: [&str; 5] = [
"http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter",
"http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2",
"https://api.stacspec.org/v1.0.0-rc.3/item-search#filter",
"http://www.opengis.net/spec/cql2/1.0/conf/cql2-text",
"http://www.opengis.net/spec/cql2/1.0/conf/cql2-json",
];

/// To support "generic" clients that want to access multiple OGC API Features
/// implementations - and not "just" a specific API / server, the server has to
/// declare the conformance classes it implements and conforms to.
Expand Down Expand Up @@ -76,6 +85,21 @@ impl Conformance {
self.conforms_to.push(ITEM_SEARCH_URI.to_string());
self
}

/// Adds [filter](https://github.com/stac-api-extensions/filter) conformance
/// class.
///
/// # Examples
///
/// ```
/// use stac_api::Conformance;
/// let conformance = Conformance::new().item_search();
/// ```
pub fn filter(mut self) -> Conformance {
self.conforms_to
.extend(FILTER_URIS.iter().map(|s| s.to_string()));
self
}
}

impl Default for Conformance {
Expand Down
22 changes: 18 additions & 4 deletions crates/api/src/items.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ pub struct GetItems {
#[serde(skip_serializing_if = "Option::is_none", rename = "filter-crs")]
pub filter_crs: Option<String>,

/// CQL2 filter expression.
#[serde(skip_serializing_if = "Option::is_none")]
/// This should always be cql2-text if present.
#[serde(skip_serializing_if = "Option::is_none", rename = "filter-lang")]
pub filter_lang: Option<String>,

/// CQL2 filter expression.
Expand Down Expand Up @@ -335,7 +335,11 @@ impl TryFrom<Items> for GetItems {
.join(",")
}),
filter_crs: items.filter_crs,
filter_lang: filter.as_ref().map(|_| "cql2-text".to_string()),
filter_lang: if filter.is_some() {
Some("cql2-text".to_string())
} else {
None
},
filter,
additional_fields: items
.additional_fields
Expand Down Expand Up @@ -413,7 +417,7 @@ fn maybe_parse_from_rfc3339(s: &str) -> Result<Option<DateTime<FixedOffset>>> {
mod tests {
use super::{GetItems, Items};
use crate::{sort::Direction, Fields, Filter, Sortby};
use serde_json::{Map, Value};
use serde_json::{json, Map, Value};
use std::collections::HashMap;

#[test]
Expand Down Expand Up @@ -493,4 +497,14 @@ mod tests {
assert_eq!(get_items.filter.unwrap(), "dummy text");
assert_eq!(get_items.additional_fields["token"], "\"foobar\"");
}

#[test]
fn filter() {
let value = json!({
"filter": "eo:cloud_cover >= 5 AND eo:cloud_cover < 10",
"filter-lang": "cql2-text",
});
let items: Items = serde_json::from_value(value).unwrap();
assert!(items.filter.is_some());
}
}
4 changes: 2 additions & 2 deletions crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ pub use client::{BlockingClient, Client};
pub use {
collections::Collections,
conformance::{
Conformance, COLLECTIONS_URI, CORE_URI, FEATURES_URI, GEOJSON_URI, ITEM_SEARCH_URI,
OGC_API_FEATURES_URI,
Conformance, COLLECTIONS_URI, CORE_URI, FEATURES_URI, FILTER_URIS, GEOJSON_URI,
ITEM_SEARCH_URI, OGC_API_FEATURES_URI,
},
error::Error,
fields::Fields,
Expand Down
3 changes: 2 additions & 1 deletion crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ rust-version.workspace = true
[features]
default = ["pgstac"]
duckdb = ["dep:stac-duckdb", "dep:duckdb"]
pgstac = ["stac-server/pgstac"]
pgstac = ["stac-server/pgstac", "dep:tokio-postgres"]
python = ["dep:pyo3", "pgstac"]

[dependencies]
Expand Down Expand Up @@ -47,6 +47,7 @@ tokio = { workspace = true, features = [
"rt-multi-thread",
"fs",
] }
tokio-postgres = { workspace = true, optional = true }
tokio-stream.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
Expand Down
5 changes: 5 additions & 0 deletions crates/cli/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ pub enum Error {
#[error(transparent)]
TokioJoinError(#[from] tokio::task::JoinError),

/// [tokio_postgres::Error]
#[cfg(feature = "pgstac")]
#[error(transparent)]
TokioPostgres(#[from] tokio_postgres::Error),

/// [std::num::TryFromIntError]
#[error(transparent)]
TryFromInt(#[from] std::num::TryFromIntError),
Expand Down
5 changes: 0 additions & 5 deletions crates/cli/src/subcommand/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,6 @@ pub struct Args {
#[arg(long)]
filter_crs: Option<String>,

/// `cql2-text` or `cql2-json`. If undefined, defaults to cql2-text for a GET request and cql2-json for a POST request.
#[arg(long)]
filter_lang: Option<String>,

/// CQL2 filter expression.
#[arg(short, long)]
filter: Option<String>,
Expand Down Expand Up @@ -90,7 +86,6 @@ impl crate::Args {
fields: args.fields.clone(),
sortby: args.sortby.clone(),
filter_crs: args.filter_crs.clone(),
filter_lang: args.filter_lang.clone(),
filter: args.filter.clone(),
..Default::default()
};
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/subcommand/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ impl crate::Args {
#[cfg(feature = "pgstac")]
{
if let Some(pgstac) = args.pgstac.as_deref() {
let _ = tokio_postgres::connect(pgstac, tokio_postgres::NoTls).await?;
let backend = stac_server::PgstacBackend::new_from_stringlike(pgstac).await?;
self.load_and_serve(args, backend).await
} else {
Expand Down
4 changes: 4 additions & 0 deletions crates/server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Added

- Filter extension for **pgstac** backend ([#519](https://github.com/stac-utils/stac-rs/pull/519))

## [0.3.1] - 2024.09-19

### Changed
Expand Down
2 changes: 1 addition & 1 deletion crates/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ This table lists the provided backends and their supported conformance classes a
| [Collection search extension](https://github.com/stac-api-extensions/collection-search) | ✖️ | ✖️ |
| [Collection transaction extension](https://github.com/stac-api-extensions/collection-transaction) | ✖️ | ✖️ |
| [Fields extension](https://github.com/stac-api-extensions/fields) | ✖️ | ✖️ |
| [Filter extension](https://github.com/stac-api-extensions/filter) | ✖️ | ️ |
| [Filter extension](https://github.com/stac-api-extensions/filter) | ✖️ | ️ |
| [Free-text search extension](https://github.com/stac-api-extensions/freetext-search) | ✖️ | ✖️ |
| [Language (I18N) extension](https://github.com/stac-api-extensions/language) | ✖️ | ✖️ |
| [Query extension](https://github.com/stac-api-extensions/query) | ✖️ | ✖️ |
Expand Down
29 changes: 28 additions & 1 deletion crates/server/src/api.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::{Backend, Error, Result, DEFAULT_DESCRIPTION, DEFAULT_ID};
use http::Method;
use serde::Serialize;
use serde_json::{Map, Value};
use serde_json::{json, Map, Value};
use stac::{mime::APPLICATION_OPENAPI_3_0, Catalog, Collection, Fields, Item, Link, Links};
use stac_api::{Collections, Conformance, ItemCollection, Items, Root, Search};
use url::Url;
Expand Down Expand Up @@ -115,6 +115,15 @@ impl<B: Backend> Api<B> {
catalog
.links
.push(Link::new(search_url, "search").geojson().method("POST"));
if self.backend.has_filter() {
catalog.links.push(
Link::new(
self.url("/queryables")?,
"http://www.opengis.net/def/rel/ogc/1.0/queryables",
)
.r#type("application/schema+json".to_string()),
);
}
Ok(Root {
catalog,
conformance: self.conformance(),
Expand All @@ -136,9 +145,27 @@ impl<B: Backend> Api<B> {
if self.backend.has_item_search() {
conformance = conformance.item_search();
}
if self.backend.has_filter() {
conformance = conformance.filter();
}
conformance
}

/// Returns queryables.
pub fn queryables(&self) -> Value {
// This is a pure punt from https://github.com/stac-api-extensions/filter?tab=readme-ov-file#queryables
json!({
"$schema" : "https://json-schema.org/draft/2019-09/schema",
"$id" : "https://stac-api.example.com/queryables",
"type" : "object",
"title" : "Queryables for Example STAC API",
"description" : "Queryable names for the example STAC API Item Search filter.",
"properties" : {
},
"additionalProperties": true
})
}

/// Returns the collections from the backend.
///
/// # Examples
Expand Down
4 changes: 4 additions & 0 deletions crates/server/src/backend/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ impl Backend for MemoryBackend {
true
}

fn has_filter(&self) -> bool {
false
}

async fn collections(&self) -> Result<Vec<Collection>> {
let collections = self.collections.read().unwrap();
Ok(collections.values().cloned().collect())
Expand Down
11 changes: 11 additions & 0 deletions crates/server/src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ pub trait Backend: Clone + Sync + Send + 'static {
/// ```
fn has_item_search(&self) -> bool;

/// Returns true if this backend has [filter](https://github.com/stac-api-extensions/filter) capabilities.
///
/// # Examples
///
/// ```
/// use stac_server::{MemoryBackend, Backend};
///
/// assert!(!MemoryBackend::new().has_filter());
/// ```
fn has_filter(&self) -> bool;

/// Returns all collections.
///
/// # Examples
Expand Down
4 changes: 4 additions & 0 deletions crates/server/src/backend/pgstac.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ where
true
}

fn has_filter(&self) -> bool {
true
}

async fn add_collection(&mut self, collection: Collection) -> Result<()> {
let client = self.pool.get().await?;
let client = Client::new(&*client);
Expand Down
10 changes: 10 additions & 0 deletions crates/server/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ pub fn from_api<B: Backend>(api: Api<B>) -> Router {
.route("/api", get(service_desc))
.route("/api.html", get(service_doc))
.route("/conformance", get(conformance))
.route("/queryables", get(queryables))
.route("/collections", get(collections))
.route("/collections/:collection_id", get(collection))
.route("/collections/:collection_id/items", get(items))
Expand Down Expand Up @@ -147,6 +148,15 @@ pub async fn conformance<B: Backend>(State(api): State<Api<B>>) -> Response {
Json(api.conformance()).into_response()
}

/// Returns the `/queryables` endpoint.
pub async fn queryables<B: Backend>(State(api): State<Api<B>>) -> Response {
(
[(CONTENT_TYPE, "application/schema+json")],
Json(api.queryables()),
)
.into_response()
}

/// Returns the `/collections` endpoint from the [ogcapi-features conformance
/// class](https://github.com/radiantearth/stac-api-spec/blob/release/v1.0.0/ogcapi-features/README.md#endpoints).
pub async fn collections<B: Backend>(State(api): State<Api<B>>) -> Result<Json<Collections>> {
Expand Down
6 changes: 3 additions & 3 deletions scripts/validate-stac-server
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ set -e

args="crates/server/data/sentinel-2/*"
build_args="--no-default-features"
conformance="--conformance core --conformance features --conformance item-search"

if [ $# -eq 1 ]; then
if [ "$1" = "--pgstac" ]; then
args="$args --pgstac postgres://username:password@localhost/postgis"
build_args="$build_args -F pgstac"
conformance="$conformance --conformance filter"
else
echo "Unknown argument: $1"
exit 1
Expand All @@ -29,9 +31,7 @@ set +e
scripts/wait-for-it.sh localhost:7822 && \
stac-api-validator \
--root-url http://localhost:7822 \
--conformance core \
--conformance features \
--conformance item-search \
$conformance \
--collection sentinel-2-c1-l2a \
--geometry '{"type":"Point","coordinates":[-105.07,40.08]}'
status=$?
Expand Down