Skip to content

Commit 6568f74

Browse files
authored
feat(server): add filter conformance class (#519)
1 parent 3bd3192 commit 6568f74

File tree

15 files changed

+117
-17
lines changed

15 files changed

+117
-17
lines changed

crates/api/src/conformance.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ pub const GEOJSON_URI: &str = "http://www.opengis.net/spec/ogcapi-features-1/1.0
1919
/// The item search conformance uri.
2020
pub const ITEM_SEARCH_URI: &str = "https://api.stacspec.org/v1.0.0/item-search";
2121

22+
/// The filter conformance uris.
23+
pub const FILTER_URIS: [&str; 5] = [
24+
"http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter",
25+
"http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2",
26+
"https://api.stacspec.org/v1.0.0-rc.3/item-search#filter",
27+
"http://www.opengis.net/spec/cql2/1.0/conf/cql2-text",
28+
"http://www.opengis.net/spec/cql2/1.0/conf/cql2-json",
29+
];
30+
2231
/// To support "generic" clients that want to access multiple OGC API Features
2332
/// implementations - and not "just" a specific API / server, the server has to
2433
/// declare the conformance classes it implements and conforms to.
@@ -76,6 +85,21 @@ impl Conformance {
7685
self.conforms_to.push(ITEM_SEARCH_URI.to_string());
7786
self
7887
}
88+
89+
/// Adds [filter](https://github.com/stac-api-extensions/filter) conformance
90+
/// class.
91+
///
92+
/// # Examples
93+
///
94+
/// ```
95+
/// use stac_api::Conformance;
96+
/// let conformance = Conformance::new().item_search();
97+
/// ```
98+
pub fn filter(mut self) -> Conformance {
99+
self.conforms_to
100+
.extend(FILTER_URIS.iter().map(|s| s.to_string()));
101+
self
102+
}
79103
}
80104

81105
impl Default for Conformance {

crates/api/src/items.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ pub struct GetItems {
8787
#[serde(skip_serializing_if = "Option::is_none", rename = "filter-crs")]
8888
pub filter_crs: Option<String>,
8989

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

9494
/// CQL2 filter expression.
@@ -335,7 +335,11 @@ impl TryFrom<Items> for GetItems {
335335
.join(",")
336336
}),
337337
filter_crs: items.filter_crs,
338-
filter_lang: filter.as_ref().map(|_| "cql2-text".to_string()),
338+
filter_lang: if filter.is_some() {
339+
Some("cql2-text".to_string())
340+
} else {
341+
None
342+
},
339343
filter,
340344
additional_fields: items
341345
.additional_fields
@@ -413,7 +417,7 @@ fn maybe_parse_from_rfc3339(s: &str) -> Result<Option<DateTime<FixedOffset>>> {
413417
mod tests {
414418
use super::{GetItems, Items};
415419
use crate::{sort::Direction, Fields, Filter, Sortby};
416-
use serde_json::{Map, Value};
420+
use serde_json::{json, Map, Value};
417421
use std::collections::HashMap;
418422

419423
#[test]
@@ -493,4 +497,14 @@ mod tests {
493497
assert_eq!(get_items.filter.unwrap(), "dummy text");
494498
assert_eq!(get_items.additional_fields["token"], "\"foobar\"");
495499
}
500+
501+
#[test]
502+
fn filter() {
503+
let value = json!({
504+
"filter": "eo:cloud_cover >= 5 AND eo:cloud_cover < 10",
505+
"filter-lang": "cql2-text",
506+
});
507+
let items: Items = serde_json::from_value(value).unwrap();
508+
assert!(items.filter.is_some());
509+
}
496510
}

crates/api/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ pub use client::{BlockingClient, Client};
8282
pub use {
8383
collections::Collections,
8484
conformance::{
85-
Conformance, COLLECTIONS_URI, CORE_URI, FEATURES_URI, GEOJSON_URI, ITEM_SEARCH_URI,
86-
OGC_API_FEATURES_URI,
85+
Conformance, COLLECTIONS_URI, CORE_URI, FEATURES_URI, FILTER_URIS, GEOJSON_URI,
86+
ITEM_SEARCH_URI, OGC_API_FEATURES_URI,
8787
},
8888
error::Error,
8989
fields::Fields,

crates/cli/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ rust-version.workspace = true
1414
[features]
1515
default = ["pgstac"]
1616
duckdb = ["dep:stac-duckdb", "dep:duckdb"]
17-
pgstac = ["stac-server/pgstac"]
17+
pgstac = ["stac-server/pgstac", "dep:tokio-postgres"]
1818
python = ["dep:pyo3", "pgstac"]
1919

2020
[dependencies]
@@ -47,6 +47,7 @@ tokio = { workspace = true, features = [
4747
"rt-multi-thread",
4848
"fs",
4949
] }
50+
tokio-postgres = { workspace = true, optional = true }
5051
tokio-stream.workspace = true
5152
tracing.workspace = true
5253
tracing-subscriber.workspace = true

crates/cli/src/error.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ pub enum Error {
5454
#[error(transparent)]
5555
TokioJoinError(#[from] tokio::task::JoinError),
5656

57+
/// [tokio_postgres::Error]
58+
#[cfg(feature = "pgstac")]
59+
#[error(transparent)]
60+
TokioPostgres(#[from] tokio_postgres::Error),
61+
5762
/// [std::num::TryFromIntError]
5863
#[error(transparent)]
5964
TryFromInt(#[from] std::num::TryFromIntError),

crates/cli/src/subcommand/search.rs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,6 @@ pub struct Args {
5252
#[arg(long)]
5353
filter_crs: Option<String>,
5454

55-
/// `cql2-text` or `cql2-json`. If undefined, defaults to cql2-text for a GET request and cql2-json for a POST request.
56-
#[arg(long)]
57-
filter_lang: Option<String>,
58-
5955
/// CQL2 filter expression.
6056
#[arg(short, long)]
6157
filter: Option<String>,
@@ -90,7 +86,6 @@ impl crate::Args {
9086
fields: args.fields.clone(),
9187
sortby: args.sortby.clone(),
9288
filter_crs: args.filter_crs.clone(),
93-
filter_lang: args.filter_lang.clone(),
9489
filter: args.filter.clone(),
9590
..Default::default()
9691
};

crates/cli/src/subcommand/serve.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ impl crate::Args {
3535
#[cfg(feature = "pgstac")]
3636
{
3737
if let Some(pgstac) = args.pgstac.as_deref() {
38+
let _ = tokio_postgres::connect(pgstac, tokio_postgres::NoTls).await?;
3839
let backend = stac_server::PgstacBackend::new_from_stringlike(pgstac).await?;
3940
self.load_and_serve(args, backend).await
4041
} else {

crates/server/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- Filter extension for **pgstac** backend ([#519](https://github.com/stac-utils/stac-rs/pull/519))
12+
913
## [0.3.1] - 2024.09-19
1014

1115
### Changed

crates/server/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ This table lists the provided backends and their supported conformance classes a
7171
| [Collection search extension](https://github.com/stac-api-extensions/collection-search) | ✖️ | ✖️ |
7272
| [Collection transaction extension](https://github.com/stac-api-extensions/collection-transaction) | ✖️ | ✖️ |
7373
| [Fields extension](https://github.com/stac-api-extensions/fields) | ✖️ | ✖️ |
74-
| [Filter extension](https://github.com/stac-api-extensions/filter) | ✖️ | |
74+
| [Filter extension](https://github.com/stac-api-extensions/filter) | ✖️ | |
7575
| [Free-text search extension](https://github.com/stac-api-extensions/freetext-search) | ✖️ | ✖️ |
7676
| [Language (I18N) extension](https://github.com/stac-api-extensions/language) | ✖️ | ✖️ |
7777
| [Query extension](https://github.com/stac-api-extensions/query) | ✖️ | ✖️ |

crates/server/src/api.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::{Backend, Error, Result, DEFAULT_DESCRIPTION, DEFAULT_ID};
22
use http::Method;
33
use serde::Serialize;
4-
use serde_json::{Map, Value};
4+
use serde_json::{json, Map, Value};
55
use stac::{mime::APPLICATION_OPENAPI_3_0, Catalog, Collection, Fields, Item, Link, Links};
66
use stac_api::{Collections, Conformance, ItemCollection, Items, Root, Search};
77
use url::Url;
@@ -115,6 +115,15 @@ impl<B: Backend> Api<B> {
115115
catalog
116116
.links
117117
.push(Link::new(search_url, "search").geojson().method("POST"));
118+
if self.backend.has_filter() {
119+
catalog.links.push(
120+
Link::new(
121+
self.url("/queryables")?,
122+
"http://www.opengis.net/def/rel/ogc/1.0/queryables",
123+
)
124+
.r#type("application/schema+json".to_string()),
125+
);
126+
}
118127
Ok(Root {
119128
catalog,
120129
conformance: self.conformance(),
@@ -136,9 +145,27 @@ impl<B: Backend> Api<B> {
136145
if self.backend.has_item_search() {
137146
conformance = conformance.item_search();
138147
}
148+
if self.backend.has_filter() {
149+
conformance = conformance.filter();
150+
}
139151
conformance
140152
}
141153

154+
/// Returns queryables.
155+
pub fn queryables(&self) -> Value {
156+
// This is a pure punt from https://github.com/stac-api-extensions/filter?tab=readme-ov-file#queryables
157+
json!({
158+
"$schema" : "https://json-schema.org/draft/2019-09/schema",
159+
"$id" : "https://stac-api.example.com/queryables",
160+
"type" : "object",
161+
"title" : "Queryables for Example STAC API",
162+
"description" : "Queryable names for the example STAC API Item Search filter.",
163+
"properties" : {
164+
},
165+
"additionalProperties": true
166+
})
167+
}
168+
142169
/// Returns the collections from the backend.
143170
///
144171
/// # Examples

0 commit comments

Comments
 (0)