1use 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#[cfg(feature = "reqwest")]
18#[derive(Debug, Clone)]
19pub struct StacClient {
20 #[allow(dead_code)]
22 base_url: String,
23 #[allow(dead_code)]
25 client: HttpClient,
26}
27
28#[cfg(feature = "reqwest")]
29impl StacClient {
30 pub fn new(base_url: impl Into<String>) -> Result<Self> {
40 let base_url = base_url.into();
41
42 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 pub fn search(&self) -> SearchBuilder {
59 SearchBuilder::new(self.clone())
60 }
61
62 #[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 #[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#[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 pub fn new(client: StacClient) -> Self {
143 Self {
144 client,
145 params: SearchParams::default(),
146 }
147 }
148
149 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 pub fn bbox(mut self, bbox: [f64; 4]) -> Self {
173 self.params.bbox = Some(bbox.to_vec());
174 self
175 }
176
177 pub fn datetime(mut self, datetime: impl Into<String>) -> Self {
187 self.params.datetime = Some(datetime.into());
188 self
189 }
190
191 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 pub fn limit(mut self, limit: u32) -> Self {
217 self.params.limit = Some(limit);
218 self
219 }
220
221 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 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 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 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 #[cfg(feature = "async")]
303 pub async fn execute(self) -> Result<SearchResults> {
304 self.client.execute_search(&self.params).await
305 }
306
307 #[cfg(feature = "reqwest")]
313 pub fn paginate(self) -> crate::pagination::Paginator {
314 crate::pagination::Paginator::new(self.client, self.params)
315 }
316}
317
318#[derive(Debug, Clone, Default, Serialize, Deserialize)]
320pub struct SearchParams {
321 #[serde(skip_serializing_if = "Option::is_none")]
323 pub collections: Option<Vec<String>>,
324
325 #[serde(skip_serializing_if = "Option::is_none")]
327 pub bbox: Option<Vec<f64>>,
328
329 #[serde(skip_serializing_if = "Option::is_none")]
331 pub datetime: Option<String>,
332
333 #[serde(skip_serializing_if = "Option::is_none")]
335 pub limit: Option<u32>,
336
337 #[serde(skip_serializing_if = "Option::is_none")]
339 pub query: Option<HashMap<String, serde_json::Value>>,
340
341 #[serde(skip_serializing_if = "Option::is_none")]
343 pub filter: Option<serde_json::Value>,
344
345 #[serde(rename = "filter-lang", skip_serializing_if = "Option::is_none")]
347 pub filter_lang: Option<String>,
348
349 #[serde(skip_serializing_if = "Option::is_none")]
351 pub page_token: Option<String>,
352
353 #[serde(skip_serializing_if = "Option::is_none")]
355 pub fields: Option<Vec<String>>,
356
357 #[serde(skip_serializing_if = "Option::is_none")]
359 pub sortby: Option<Vec<SortBy>>,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct SortBy {
365 pub field: String,
367
368 pub direction: SortDirection,
370}
371
372#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
374#[serde(rename_all = "lowercase")]
375pub enum SortDirection {
376 Asc,
378 Desc,
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct SearchResults {
385 #[serde(rename = "type")]
387 pub type_: String,
388
389 pub features: Vec<Item>,
391
392 #[serde(skip_serializing_if = "Option::is_none")]
394 pub links: Option<Vec<crate::item::Link>>,
395
396 #[serde(skip_serializing_if = "Option::is_none")]
398 pub number_returned: Option<u32>,
399
400 #[serde(skip_serializing_if = "Option::is_none")]
402 pub number_matched: Option<u32>,
403
404 #[serde(skip_serializing_if = "Option::is_none")]
406 pub context: Option<SearchContext>,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct SearchContext {
412 pub returned: u32,
414
415 #[serde(skip_serializing_if = "Option::is_none")]
417 pub limit: Option<u32>,
418
419 #[serde(skip_serializing_if = "Option::is_none")]
421 pub matched: Option<u32>,
422}
423
424impl SearchResults {
425 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 pub fn has_more(&self) -> bool {
442 self.get_next_link().is_some()
443 }
444
445 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 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(¶ms);
518 assert!(json.is_ok());
519 }
520}