Skip to main content

oxigdal_stac/
search.rs

1//! STAC API search client.
2//!
3//! This module provides an async HTTP client for searching STAC APIs.
4
5use crate::{
6    error::{Result, StacError},
7    item::Item,
8};
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13#[cfg(feature = "reqwest")]
14use reqwest::Client as HttpClient;
15
16/// STAC API client for searching catalogs.
17#[cfg(feature = "reqwest")]
18#[derive(Debug, Clone)]
19pub struct StacClient {
20    /// Base URL of the STAC API.
21    #[allow(dead_code)]
22    base_url: String,
23    /// HTTP client.
24    #[allow(dead_code)]
25    client: HttpClient,
26}
27
28#[cfg(feature = "reqwest")]
29impl StacClient {
30    /// Creates a new STAC API client.
31    ///
32    /// # Arguments
33    ///
34    /// * `base_url` - Base URL of the STAC API
35    ///
36    /// # Returns
37    ///
38    /// A new StacClient instance
39    pub fn new(base_url: impl Into<String>) -> Result<Self> {
40        let base_url = base_url.into();
41
42        // Validate URL
43        url::Url::parse(&base_url)?;
44
45        let client = HttpClient::builder()
46            .user_agent("oxigdal-stac/0.1.0")
47            .build()
48            .map_err(|e| StacError::Http(e.to_string()))?;
49
50        Ok(Self { base_url, client })
51    }
52
53    /// Creates a new search query builder.
54    ///
55    /// # Returns
56    ///
57    /// A new SearchBuilder instance
58    pub fn search(&self) -> SearchBuilder {
59        SearchBuilder::new(self.clone())
60    }
61
62    /// Executes a search request.
63    ///
64    /// # Arguments
65    ///
66    /// * `params` - Search parameters
67    ///
68    /// # Returns
69    ///
70    /// Search results
71    #[cfg(feature = "async")]
72    pub async fn execute_search(&self, params: &SearchParams) -> Result<SearchResults> {
73        let url = format!("{}/search", self.base_url);
74
75        let response = self.client.post(&url).json(params).send().await?;
76
77        if !response.status().is_success() {
78            let status = response.status();
79            let body = response.text().await.unwrap_or_default();
80            return Err(StacError::ApiResponse(format!(
81                "HTTP {} - {}",
82                status, body
83            )));
84        }
85
86        let results: SearchResults = response.json().await?;
87        Ok(results)
88    }
89
90    /// Gets an item by ID.
91    ///
92    /// # Arguments
93    ///
94    /// * `collection_id` - Collection ID
95    /// * `item_id` - Item ID
96    ///
97    /// # Returns
98    ///
99    /// The requested item
100    #[cfg(feature = "async")]
101    pub async fn get_item(&self, collection_id: &str, item_id: &str) -> Result<Item> {
102        let url = format!(
103            "{}/collections/{}/items/{}",
104            self.base_url, collection_id, item_id
105        );
106
107        let response = self.client.get(&url).send().await?;
108
109        if !response.status().is_success() {
110            let status = response.status();
111            return Err(StacError::ApiResponse(format!(
112                "HTTP {} - Item not found",
113                status
114            )));
115        }
116
117        let item: Item = response.json().await?;
118        Ok(item)
119    }
120}
121
122/// Builder for STAC search queries.
123#[cfg(feature = "reqwest")]
124#[derive(Debug, Clone)]
125pub struct SearchBuilder {
126    #[allow(dead_code)]
127    client: StacClient,
128    params: SearchParams,
129}
130
131#[cfg(feature = "reqwest")]
132impl SearchBuilder {
133    /// Creates a new search builder.
134    ///
135    /// # Arguments
136    ///
137    /// * `client` - STAC client
138    ///
139    /// # Returns
140    ///
141    /// A new SearchBuilder instance
142    pub fn new(client: StacClient) -> Self {
143        Self {
144            client,
145            params: SearchParams::default(),
146        }
147    }
148
149    /// Sets the collections to search.
150    ///
151    /// # Arguments
152    ///
153    /// * `collections` - Vector of collection IDs
154    ///
155    /// # Returns
156    ///
157    /// Self for method chaining
158    pub fn collections(mut self, collections: Vec<impl Into<String>>) -> Self {
159        self.params.collections = Some(collections.into_iter().map(|c| c.into()).collect());
160        self
161    }
162
163    /// Sets the bounding box to search within.
164    ///
165    /// # Arguments
166    ///
167    /// * `bbox` - Bounding box [west, south, east, north]
168    ///
169    /// # Returns
170    ///
171    /// Self for method chaining
172    pub fn bbox(mut self, bbox: [f64; 4]) -> Self {
173        self.params.bbox = Some(bbox.to_vec());
174        self
175    }
176
177    /// Sets the datetime filter.
178    ///
179    /// # Arguments
180    ///
181    /// * `datetime` - Datetime string (RFC 3339 or interval)
182    ///
183    /// # Returns
184    ///
185    /// Self for method chaining
186    pub fn datetime(mut self, datetime: impl Into<String>) -> Self {
187        self.params.datetime = Some(datetime.into());
188        self
189    }
190
191    /// Sets the datetime range filter.
192    ///
193    /// # Arguments
194    ///
195    /// * `start` - Start datetime
196    /// * `end` - End datetime
197    ///
198    /// # Returns
199    ///
200    /// Self for method chaining
201    pub fn datetime_range(mut self, start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
202        let datetime_str = format!("{}/{}", start.to_rfc3339(), end.to_rfc3339());
203        self.params.datetime = Some(datetime_str);
204        self
205    }
206
207    /// Sets the maximum number of results.
208    ///
209    /// # Arguments
210    ///
211    /// * `limit` - Maximum number of results
212    ///
213    /// # Returns
214    ///
215    /// Self for method chaining
216    pub fn limit(mut self, limit: u32) -> Self {
217        self.params.limit = Some(limit);
218        self
219    }
220
221    /// Adds a query filter.
222    ///
223    /// # Arguments
224    ///
225    /// * `key` - Property key
226    /// * `value` - Filter value
227    ///
228    /// # Returns
229    ///
230    /// Self for method chaining
231    pub fn query(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
232        match &mut self.params.query {
233            Some(query) => {
234                query.insert(key.into(), value);
235            }
236            None => {
237                let mut query = HashMap::new();
238                query.insert(key.into(), value);
239                self.params.query = Some(query);
240            }
241        }
242        self
243    }
244
245    /// Sets a CQL2 filter.
246    ///
247    /// # Arguments
248    ///
249    /// * `filter` - CQL2 filter expression
250    ///
251    /// # Returns
252    ///
253    /// Self for method chaining
254    pub fn filter(mut self, filter: serde_json::Value) -> Self {
255        self.params.filter = Some(filter);
256        self.params.filter_lang = Some("cql2-json".to_string());
257        self
258    }
259
260    /// Sets fields to include in the response.
261    ///
262    /// # Arguments
263    ///
264    /// * `fields` - Field names to include
265    ///
266    /// # Returns
267    ///
268    /// Self for method chaining
269    pub fn fields(mut self, fields: Vec<impl Into<String>>) -> Self {
270        self.params.fields = Some(fields.into_iter().map(|f| f.into()).collect());
271        self
272    }
273
274    /// Adds a sort specification.
275    ///
276    /// # Arguments
277    ///
278    /// * `field` - Field to sort by
279    /// * `direction` - Sort direction
280    ///
281    /// # Returns
282    ///
283    /// Self for method chaining
284    pub fn sort_by(mut self, field: impl Into<String>, direction: SortDirection) -> Self {
285        let sort = SortBy {
286            field: field.into(),
287            direction,
288        };
289
290        match &mut self.params.sortby {
291            Some(sortby) => sortby.push(sort),
292            None => self.params.sortby = Some(vec![sort]),
293        }
294        self
295    }
296
297    /// Executes the search.
298    ///
299    /// # Returns
300    ///
301    /// Search results
302    #[cfg(feature = "async")]
303    pub async fn execute(self) -> Result<SearchResults> {
304        self.client.execute_search(&self.params).await
305    }
306
307    /// Creates a paginator for iterating through results.
308    ///
309    /// # Returns
310    ///
311    /// A paginator for the search
312    #[cfg(feature = "reqwest")]
313    pub fn paginate(self) -> crate::pagination::Paginator {
314        crate::pagination::Paginator::new(self.client, self.params)
315    }
316}
317
318/// STAC search parameters.
319#[derive(Debug, Clone, Default, Serialize, Deserialize)]
320pub struct SearchParams {
321    /// Collections to search in.
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub collections: Option<Vec<String>>,
324
325    /// Bounding box [west, south, east, north].
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub bbox: Option<Vec<f64>>,
328
329    /// Datetime string (RFC 3339 or interval).
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub datetime: Option<String>,
332
333    /// Maximum number of results.
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub limit: Option<u32>,
336
337    /// Query filters.
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub query: Option<HashMap<String, serde_json::Value>>,
340
341    /// CQL2 filter (Common Query Language 2).
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub filter: Option<serde_json::Value>,
344
345    /// Filter language (e.g., "cql2-json", "cql2-text").
346    #[serde(rename = "filter-lang", skip_serializing_if = "Option::is_none")]
347    pub filter_lang: Option<String>,
348
349    /// Page token for pagination.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub page_token: Option<String>,
352
353    /// Fields to include in the response.
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub fields: Option<Vec<String>>,
356
357    /// Sortby specifications.
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub sortby: Option<Vec<SortBy>>,
360}
361
362/// Sort specification for search results.
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct SortBy {
365    /// Field to sort by.
366    pub field: String,
367
368    /// Sort direction.
369    pub direction: SortDirection,
370}
371
372/// Sort direction.
373#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
374#[serde(rename_all = "lowercase")]
375pub enum SortDirection {
376    /// Ascending order.
377    Asc,
378    /// Descending order.
379    Desc,
380}
381
382/// STAC search results.
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct SearchResults {
385    /// Type must be "FeatureCollection".
386    #[serde(rename = "type")]
387    pub type_: String,
388
389    /// Features (STAC Items) in the results.
390    pub features: Vec<Item>,
391
392    /// Links to related resources.
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub links: Option<Vec<crate::item::Link>>,
395
396    /// Number of items returned.
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub number_returned: Option<u32>,
399
400    /// Number of items matched.
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub number_matched: Option<u32>,
403
404    /// Context information.
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub context: Option<SearchContext>,
407}
408
409/// Context information for search results.
410#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct SearchContext {
412    /// Number of items returned.
413    pub returned: u32,
414
415    /// Limit specified in the request.
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub limit: Option<u32>,
418
419    /// Number of items matched.
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub matched: Option<u32>,
422}
423
424impl SearchResults {
425    /// Gets the next page link if available.
426    ///
427    /// # Returns
428    ///
429    /// The next page link if it exists
430    pub fn get_next_link(&self) -> Option<&crate::item::Link> {
431        self.links
432            .as_ref()
433            .and_then(|links| links.iter().find(|link| link.rel == "next"))
434    }
435
436    /// Checks if there are more results available.
437    ///
438    /// # Returns
439    ///
440    /// `true` if there are more results
441    pub fn has_more(&self) -> bool {
442        self.get_next_link().is_some()
443    }
444
445    /// Validates the search results.
446    ///
447    /// # Returns
448    ///
449    /// `Ok(())` if valid, otherwise an error
450    pub fn validate(&self) -> Result<()> {
451        if self.type_ != "FeatureCollection" {
452            return Err(StacError::InvalidType {
453                expected: "FeatureCollection".to_string(),
454                found: self.type_.clone(),
455            });
456        }
457
458        // Validate all items
459        for (i, item) in self.features.iter().enumerate() {
460            item.validate().map_err(|e| StacError::InvalidFieldValue {
461                field: format!("features[{}]", i),
462                reason: e.to_string(),
463            })?;
464        }
465
466        Ok(())
467    }
468}
469
470#[cfg(test)]
471#[cfg(feature = "reqwest")]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn test_stac_client_new() {
477        let client = StacClient::new("https://earth-search.aws.element84.com/v1");
478        assert!(client.is_ok());
479
480        let invalid = StacClient::new("not-a-url");
481        assert!(invalid.is_err());
482    }
483
484    #[test]
485    fn test_search_builder() {
486        let client = StacClient::new("https://earth-search.aws.element84.com/v1")
487            .expect("Failed to create client");
488        let builder = client
489            .search()
490            .collections(vec!["sentinel-2-l2a"])
491            .bbox([-122.5, 37.5, -122.0, 38.0])
492            .limit(10);
493
494        assert_eq!(
495            builder.params.collections,
496            Some(vec!["sentinel-2-l2a".to_string()])
497        );
498        assert_eq!(builder.params.bbox, Some(vec![-122.5, 37.5, -122.0, 38.0]));
499        assert_eq!(builder.params.limit, Some(10));
500    }
501
502    #[test]
503    fn test_search_params_serialization() {
504        let params = SearchParams {
505            collections: Some(vec!["test".to_string()]),
506            bbox: Some(vec![-180.0, -90.0, 180.0, 90.0]),
507            datetime: Some("2023-01-01/2023-12-31".to_string()),
508            limit: Some(100),
509            query: None,
510            filter: None,
511            filter_lang: None,
512            page_token: None,
513            fields: None,
514            sortby: None,
515        };
516
517        let json = serde_json::to_string(&params);
518        assert!(json.is_ok());
519    }
520}