Skip to main content

oxigdal_stac/api/
search.rs

1//! STAC API Item Search request and response models.
2//!
3//! These types model the STAC API Item Search endpoint as defined at
4//! <https://api.stacspec.org/v1.0.0/item-search>.
5
6use serde::{Deserialize, Serialize};
7
8/// A link with relation, href, optional media-type, and optional title.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct Link {
11    /// Relation type (e.g., `"self"`, `"next"`, `"root"`).
12    pub rel: String,
13    /// Target URL.
14    pub href: String,
15    /// Media type of the linked resource.
16    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
17    pub link_type: Option<String>,
18    /// Human-readable title.
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub title: Option<String>,
21}
22
23impl Link {
24    /// Creates a link with only `rel` and `href`.
25    pub fn new(rel: impl Into<String>, href: impl Into<String>) -> Self {
26        Self {
27            rel: rel.into(),
28            href: href.into(),
29            link_type: None,
30            title: None,
31        }
32    }
33
34    /// Adds a media type.
35    pub fn with_type(mut self, media_type: impl Into<String>) -> Self {
36        self.link_type = Some(media_type.into());
37        self
38    }
39
40    /// Adds a human-readable title.
41    pub fn with_title(mut self, title: impl Into<String>) -> Self {
42        self.title = Some(title.into());
43        self
44    }
45}
46
47/// Sort direction for search results.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "lowercase")]
50pub enum SortDirection {
51    /// Ascending order (smallest first).
52    Asc,
53    /// Descending order (largest first).
54    Desc,
55}
56
57/// A sort field specifying a property and direction.
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
59pub struct SortField {
60    /// Name of the property to sort by.
61    pub field: String,
62    /// Sort direction.
63    pub direction: SortDirection,
64}
65
66impl SortField {
67    /// Creates a sort field.
68    pub fn new(field: impl Into<String>, direction: SortDirection) -> Self {
69        Self {
70            field: field.into(),
71            direction,
72        }
73    }
74}
75
76/// Field inclusion / exclusion specification for the `fields` extension.
77#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
78pub struct FieldsSpec {
79    /// Properties to include in the response (dot-notation supported).
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub include: Vec<String>,
82    /// Properties to exclude from the response.
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub exclude: Vec<String>,
85}
86
87impl FieldsSpec {
88    /// Creates a new, empty [`FieldsSpec`].
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// Adds a property to include.
94    pub fn include(mut self, field: impl Into<String>) -> Self {
95        self.include.push(field.into());
96        self
97    }
98
99    /// Adds a property to exclude.
100    pub fn exclude(mut self, field: impl Into<String>) -> Self {
101        self.exclude.push(field.into());
102        self
103    }
104}
105
106/// STAC API search request (POST body or GET query parameters).
107///
108/// All fields are optional; an empty request returns all items up to the
109/// default page size.
110#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
111pub struct SearchRequest {
112    /// Bounding box filter `[west, south, east, north]` in WGS 84.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub bbox: Option<[f64; 4]>,
115
116    /// RFC 3339 datetime or interval `"2020-01-01T00:00:00Z/2021-01-01T00:00:00Z"`.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub datetime: Option<String>,
119
120    /// CQL2-JSON or CQL2-Text filter expression.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub filter: Option<serde_json::Value>,
123
124    /// Filter language identifier (e.g., `"cql2-json"`).
125    #[serde(rename = "filter-lang", skip_serializing_if = "Option::is_none")]
126    pub filter_lang: Option<String>,
127
128    /// Restrict results to items belonging to these collections.
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub collections: Vec<String>,
131
132    /// Restrict results to items with these IDs.
133    #[serde(default, skip_serializing_if = "Vec::is_empty")]
134    pub ids: Vec<String>,
135
136    /// Maximum number of items to return (server may apply a lower cap).
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub limit: Option<u32>,
139
140    /// Opaque pagination token returned by the previous page response.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub token: Option<String>,
143
144    /// Sort specifications (evaluated in order).
145    #[serde(default, skip_serializing_if = "Vec::is_empty")]
146    pub sortby: Vec<SortField>,
147
148    /// Field inclusion/exclusion specification.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub fields: Option<FieldsSpec>,
151}
152
153impl SearchRequest {
154    /// Creates a new, empty [`SearchRequest`].
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    /// Sets the bounding box filter.
160    pub fn with_bbox(mut self, bbox: [f64; 4]) -> Self {
161        self.bbox = Some(bbox);
162        self
163    }
164
165    /// Sets the datetime / interval filter.
166    pub fn with_datetime(mut self, dt: impl Into<String>) -> Self {
167        self.datetime = Some(dt.into());
168        self
169    }
170
171    /// Restricts the search to the given collections.
172    pub fn with_collections(mut self, cols: impl IntoIterator<Item = impl Into<String>>) -> Self {
173        self.collections = cols.into_iter().map(Into::into).collect();
174        self
175    }
176
177    /// Restricts the search to the given item IDs.
178    pub fn with_ids(mut self, ids: impl IntoIterator<Item = impl Into<String>>) -> Self {
179        self.ids = ids.into_iter().map(Into::into).collect();
180        self
181    }
182
183    /// Sets the page size limit.
184    pub fn with_limit(mut self, n: u32) -> Self {
185        self.limit = Some(n);
186        self
187    }
188
189    /// Appends a sort specification.
190    pub fn with_sort(mut self, field: impl Into<String>, dir: SortDirection) -> Self {
191        self.sortby.push(SortField::new(field, dir));
192        self
193    }
194
195    /// Sets a CQL2-JSON filter.
196    pub fn with_filter(mut self, filter: serde_json::Value) -> Self {
197        self.filter = Some(filter);
198        self.filter_lang = Some("cql2-json".to_string());
199        self
200    }
201
202    /// Sets the fields specification.
203    pub fn with_fields(mut self, fields: FieldsSpec) -> Self {
204        self.fields = Some(fields);
205        self
206    }
207
208    /// Sets the pagination token.
209    pub fn with_token(mut self, token: impl Into<String>) -> Self {
210        self.token = Some(token.into());
211        self
212    }
213}
214
215/// Context metadata included in a paginated item collection response.
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
217pub struct SearchContext {
218    /// Number of items actually returned in this page.
219    pub returned: u64,
220    /// The limit that was requested (may differ from returned).
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub limit: Option<u32>,
223    /// Total number of items that match the query (if known).
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub matched: Option<u64>,
226}
227
228/// A paginated GeoJSON `FeatureCollection` of STAC items.
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
230pub struct ItemCollection {
231    /// Always `"FeatureCollection"`.
232    #[serde(rename = "type")]
233    pub collection_type: String,
234
235    /// STAC items serialised as raw JSON values for flexibility.
236    pub features: Vec<serde_json::Value>,
237
238    /// Navigation links (e.g., `"next"`, `"prev"`, `"self"`).
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub links: Option<Vec<Link>>,
241
242    /// Context (pagination metadata).
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub context: Option<SearchContext>,
245
246    /// Total items matching the query (OGC API – Features field).
247    #[serde(rename = "numberMatched", skip_serializing_if = "Option::is_none")]
248    pub number_matched: Option<u64>,
249
250    /// Items returned in this page (OGC API – Features field).
251    #[serde(rename = "numberReturned", skip_serializing_if = "Option::is_none")]
252    pub number_returned: Option<u64>,
253}
254
255impl ItemCollection {
256    /// Creates a new item collection with the given features.
257    pub fn new(features: Vec<serde_json::Value>) -> Self {
258        let n = features.len() as u64;
259        Self {
260            collection_type: "FeatureCollection".to_string(),
261            features,
262            links: None,
263            context: None,
264            number_matched: None,
265            number_returned: Some(n),
266        }
267    }
268
269    /// Returns the next-page link, if present.
270    pub fn next_link(&self) -> Option<&Link> {
271        self.links
272            .as_ref()
273            .and_then(|ls| ls.iter().find(|l| l.rel == "next"))
274    }
275
276    /// Returns `true` if a `"next"` link is present.
277    pub fn has_next_page(&self) -> bool {
278        self.next_link().is_some()
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_search_request_default() {
288        let req = SearchRequest::new();
289        assert!(req.bbox.is_none());
290        assert!(req.collections.is_empty());
291    }
292
293    #[test]
294    fn test_with_bbox() {
295        let req = SearchRequest::new().with_bbox([-180.0, -90.0, 180.0, 90.0]);
296        assert_eq!(req.bbox, Some([-180.0, -90.0, 180.0, 90.0]));
297    }
298
299    #[test]
300    fn test_with_collections() {
301        let req = SearchRequest::new().with_collections(["sentinel-2-l2a"]);
302        assert_eq!(req.collections, vec!["sentinel-2-l2a"]);
303    }
304
305    #[test]
306    fn test_with_sort() {
307        let req = SearchRequest::new().with_sort("datetime", SortDirection::Desc);
308        assert_eq!(req.sortby.len(), 1);
309        assert_eq!(req.sortby[0].direction, SortDirection::Desc);
310    }
311
312    #[test]
313    fn test_search_request_roundtrip() {
314        let req = SearchRequest::new()
315            .with_bbox([-10.0, -10.0, 10.0, 10.0])
316            .with_datetime("2023-01-01/2024-01-01")
317            .with_collections(["my-collection"])
318            .with_limit(50);
319        let json = serde_json::to_string(&req).expect("serialize");
320        let back: SearchRequest = serde_json::from_str(&json).expect("deserialize");
321        assert_eq!(req, back);
322    }
323
324    #[test]
325    fn test_item_collection_new() {
326        let fc = ItemCollection::new(vec![]);
327        assert_eq!(fc.collection_type, "FeatureCollection");
328        assert_eq!(fc.number_returned, Some(0));
329    }
330
331    #[test]
332    fn test_has_next_page() {
333        let mut fc = ItemCollection::new(vec![]);
334        assert!(!fc.has_next_page());
335        fc.links = Some(vec![Link::new("next", "https://example.com?token=abc")]);
336        assert!(fc.has_next_page());
337    }
338
339    #[test]
340    fn test_fields_spec() {
341        let fs = FieldsSpec::new()
342            .include("properties.datetime")
343            .exclude("assets");
344        assert_eq!(fs.include, vec!["properties.datetime"]);
345        assert_eq!(fs.exclude, vec!["assets"]);
346    }
347}