stac_client/
client.rs

1//! The core STAC API client and search builder.
2
3use crate::error::{Error, Result};
4use crate::models::{
5    Catalog, Collection, Conformance, FieldsFilter, Item, ItemCollection, SearchParams, SortBy,
6    SortDirection,
7};
8use reqwest;
9use serde_json;
10use std::collections::HashMap;
11use std::sync::Arc;
12use tokio::sync::OnceCell;
13use url::Url;
14
15/// An async client for a STAC API.
16///
17/// This client provides methods for interacting with a STAC-compliant API,
18/// allowing you to fetch `Catalog`, `Collection`, and `Item` objects, and to
19/// perform searches.
20///
21/// The client is inexpensive to clone, as it wraps its internal state in an `Arc`.
22#[derive(Debug, Clone)]
23pub struct Client {
24    inner: Arc<ClientInner>,
25}
26
27#[derive(Debug)]
28struct ClientInner {
29    base_url: Url,
30    client: reqwest::Client,
31    conformance: OnceCell<Conformance>,
32    #[cfg(feature = "resilience")]
33    resilience_policy: Option<crate::resilience::ResiliencePolicy>,
34    #[cfg(feature = "auth")]
35    auth_layers: Vec<Box<dyn crate::auth::AuthLayer>>,
36}
37
38impl Client {
39    /// Creates a new `Client` for a given STAC API base URL.
40    ///
41    /// # Arguments
42    ///
43    /// * `base_url` - The base URL of the STAC API (e.g.,
44    ///   `"https://planetarycomputer.microsoft.com/api/stac/v1"`).
45    ///
46    /// # Errors
47    ///
48    /// Returns an [`Error::Url`] if the provided `base_url` is not a valid URL.
49    pub fn new(base_url: &str) -> Result<Self> {
50        let base_url = Url::parse(base_url)?;
51        let client = reqwest::Client::new();
52        Ok(Self {
53            inner: Arc::new(ClientInner {
54                base_url,
55                client,
56                conformance: OnceCell::new(),
57                #[cfg(feature = "resilience")]
58                resilience_policy: None,
59                #[cfg(feature = "auth")]
60                auth_layers: Vec::new(),
61            }),
62        })
63    }
64
65    /// Creates a new `Client` from an existing `reqwest::Client`.
66    ///
67    /// This allows for customization of the underlying HTTP client, such as
68    /// setting default headers, proxies, or timeouts.
69    ///
70    /// # Errors
71    ///
72    /// Returns an [`Error::Url`] if the provided `base_url` is not a valid URL.
73    pub fn with_client(base_url: &str, client: reqwest::Client) -> Result<Self> {
74        let base_url = Url::parse(base_url)?;
75        Ok(Self {
76            inner: Arc::new(ClientInner {
77                base_url,
78                client,
79                conformance: OnceCell::new(),
80                #[cfg(feature = "resilience")]
81                resilience_policy: None,
82                #[cfg(feature = "auth")]
83                auth_layers: Vec::new(),
84            }),
85        })
86    }
87
88    /// Returns the base URL of the STAC API.
89    #[must_use]
90    pub fn base_url(&self) -> &Url {
91        &self.inner.base_url
92    }
93
94    /// Applies all configured authentication layers to a request builder.
95    #[cfg(feature = "auth")]
96    fn apply_auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
97        self.inner
98            .auth_layers
99            .iter()
100            .fold(req, |req, layer| layer.apply(req))
101    }
102
103    /// No-op when auth feature is disabled.
104    #[cfg(not(feature = "auth"))]
105    fn apply_auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
106        req
107    }
108
109    /// Fetches the root `Catalog` or `Collection` from the API.
110    ///
111    /// # Errors
112    ///
113    /// Returns an `Error` if the request fails or the response cannot be parsed.
114    pub async fn get_catalog(&self) -> Result<Catalog> {
115        let url = self.inner.base_url.clone();
116        self.fetch_json(&url).await
117    }
118
119    /// Fetches all `Collection` objects from the `/collections` endpoint.
120    ///
121    /// # Errors
122    ///
123    /// Returns an `Error` if the request fails or the response cannot be parsed.
124    pub async fn get_collections(&self) -> Result<Vec<Collection>> {
125        #[derive(serde::Deserialize)]
126        struct CollectionsResponse {
127            collections: Vec<Collection>,
128        }
129
130        let mut url = self.inner.base_url.clone();
131        url.path_segments_mut()
132            .map_err(|()| Error::InvalidEndpoint("Cannot modify URL path".to_string()))?
133            .push("collections");
134
135        let response: CollectionsResponse = self.fetch_json(&url).await?;
136        Ok(response.collections)
137    }
138
139    /// Fetches a single `Collection` by its ID from the `/collections/{collection_id}` endpoint.
140    ///
141    /// # Errors
142    ///
143    /// Returns an `Error` if the request fails or the response cannot be parsed.
144    pub async fn get_collection(&self, collection_id: &str) -> Result<Collection> {
145        let mut url = self.inner.base_url.clone();
146        url.path_segments_mut()
147            .map_err(|()| Error::InvalidEndpoint("Cannot modify URL path".to_string()))?
148            .push("collections")
149            .push(collection_id);
150
151        self.fetch_json(&url).await
152    }
153
154    /// Fetches an `ItemCollection` of `Item` objects from a specific collection.
155    ///
156    /// This method retrieves items from the `/collections/{collection_id}/items` endpoint.
157    /// Note that this retrieves only a single page of items; the `limit` parameter
158    /// can be used to control the page size.
159    ///
160    /// # Errors
161    ///
162    /// Returns an `Error` if the request fails or the response cannot be parsed.
163    pub async fn get_collection_items(
164        &self,
165        collection_id: &str,
166        limit: Option<u32>,
167    ) -> Result<ItemCollection> {
168        let mut url = self.inner.base_url.clone();
169        url.path_segments_mut()
170            .map_err(|()| Error::InvalidEndpoint("Cannot modify URL path".to_string()))?
171            .push("collections")
172            .push(collection_id)
173            .push("items");
174
175        if let Some(limit) = limit {
176            url.query_pairs_mut()
177                .append_pair("limit", &limit.to_string());
178        }
179
180        self.fetch_json(&url).await
181    }
182
183    /// Fetches a single `Item` by its collection ID and item ID.
184    ///
185    /// # Errors
186    ///
187    /// Returns an `Error` if the request fails or the response cannot be parsed.
188    pub async fn get_item(&self, collection_id: &str, item_id: &str) -> Result<Item> {
189        let mut url = self.inner.base_url.clone();
190        url.path_segments_mut()
191            .map_err(|()| Error::InvalidEndpoint("Cannot modify URL path".to_string()))?
192            .push("collections")
193            .push(collection_id)
194            .push("items")
195            .push(item_id);
196
197        self.fetch_json(&url).await
198    }
199
200    /// Searches for `Item` objects using the `POST /search` endpoint.
201    ///
202    /// This is the preferred method for searching, as it supports complex queries
203    /// that may be too long for a GET request's URL.
204    ///
205    /// # Errors
206    ///
207    /// Returns an `Error` if the request fails or the response cannot be parsed.
208    pub async fn search(&self, params: &SearchParams) -> Result<ItemCollection> {
209        let mut url = self.inner.base_url.clone();
210        url.path_segments_mut()
211            .map_err(|()| Error::InvalidEndpoint("Cannot modify URL path".to_string()))?
212            .push("search");
213
214        #[cfg(feature = "resilience")]
215        if let Some(ref policy) = self.inner.resilience_policy {
216            return self.post_with_retry(&url, params, policy).await;
217        }
218
219        let req = self.inner.client.post(url).json(params);
220        let req = self.apply_auth(req);
221        let response = req.send().await?;
222
223        self.handle_response(response).await
224    }
225
226    #[cfg(feature = "resilience")]
227    /// Posts JSON with retry logic according to the resilience policy.
228    async fn post_with_retry<T, B>(
229        &self,
230        url: &Url,
231        body: &B,
232        policy: &crate::resilience::ResiliencePolicy,
233    ) -> Result<T>
234    where
235        T: for<'de> serde::Deserialize<'de>,
236        B: serde::Serialize,
237    {
238        use std::time::Instant;
239
240        let start_time = Instant::now();
241        let mut attempt = 0;
242
243        loop {
244            // Check total timeout
245            if let Some(total_timeout) = policy.total_timeout {
246                if start_time.elapsed() >= total_timeout {
247                    return Err(Error::Api {
248                        status: 0,
249                        message: "Total operation timeout exceeded".to_string(),
250                    });
251                }
252            }
253
254            let req = self.inner.client.post(url.clone()).json(body);
255            let req = self.apply_auth(req);
256            let result = req.send().await;
257
258            match result {
259                Ok(response) => {
260                    let status = response.status().as_u16();
261
262                    // Check if we should retry based on status
263                    if policy.should_retry_status(status) && attempt < policy.max_attempts {
264                        let delay = if status == 429 {
265                            // Handle 429 with Retry-After header
266                            let retry_after = response
267                                .headers()
268                                .get(reqwest::header::RETRY_AFTER)
269                                .and_then(|v| v.to_str().ok())
270                                .and_then(|s| s.parse::<u64>().ok())
271                                .map(std::time::Duration::from_secs);
272
273                            retry_after
274                                .unwrap_or_else(|| policy.calculate_delay(attempt))
275                                .min(policy.max_delay)
276                        } else {
277                            policy.calculate_delay(attempt)
278                        };
279
280                        attempt += 1;
281                        tokio::time::sleep(delay).await;
282                        continue;
283                    }
284
285                    // Not retryable or max attempts reached, handle response
286                    return self.handle_response(response).await;
287                }
288                Err(e) => {
289                    // Check if network error is retryable
290                    if (e.is_timeout() || e.is_connect()) && attempt < policy.max_attempts {
291                        let delay = policy.calculate_delay(attempt);
292                        attempt += 1;
293                        tokio::time::sleep(delay).await;
294                        continue;
295                    }
296                    return Err(Error::Http(e));
297                }
298            }
299        }
300    }
301
302    /// Searches for `Item` objects using the `GET /search` endpoint.
303    ///
304    /// The `SearchParams` are converted into URL query parameters.
305    ///
306    /// # Errors
307    ///
308    /// Returns an `Error` if the request fails or the response cannot be parsed.
309    pub async fn search_get(&self, params: &SearchParams) -> Result<ItemCollection> {
310        let mut url = self.inner.base_url.clone();
311        url.path_segments_mut()
312            .map_err(|()| Error::InvalidEndpoint("Cannot modify URL path".to_string()))?
313            .push("search");
314
315        // Convert search params to query parameters
316        let query_params = Client::search_params_to_query(params)?;
317        for (key, value) in query_params {
318            url.query_pairs_mut().append_pair(&key, &value);
319        }
320
321        self.fetch_json(&url).await
322    }
323
324    /// Fetches the API's conformance classes from the `/conformance` endpoint.
325    ///
326    /// The result is cached, so subsequent calls will not make a new network request.
327    ///
328    /// # Errors
329    ///
330    /// Returns an `Error` if the request fails or the response cannot be parsed.
331    pub async fn conformance(&self) -> Result<&Conformance> {
332        self.inner
333            .conformance
334            .get_or_try_init(|| self.fetch_conformance())
335            .await
336    }
337
338    /// Fetches the API's conformance classes from the `/conformance` endpoint.
339    async fn fetch_conformance(&self) -> Result<Conformance> {
340        let mut url = self.inner.base_url.clone();
341        url.path_segments_mut()
342            .map_err(|()| Error::InvalidEndpoint("Cannot modify URL path".to_string()))?
343            .push("conformance");
344
345        self.fetch_json(&url).await
346    }
347
348    /// Fetches JSON from a URL and deserializes it into a target type.
349    async fn fetch_json<T>(&self, url: &Url) -> Result<T>
350    where
351        T: for<'de> serde::Deserialize<'de>,
352    {
353        #[cfg(feature = "resilience")]
354        if let Some(ref policy) = self.inner.resilience_policy {
355            return self.fetch_json_with_retry(url, policy).await;
356        }
357
358        let req = self.inner.client.get(url.clone());
359        let req = self.apply_auth(req);
360        let response = req.send().await?;
361        self.handle_response(response).await
362    }
363
364    #[cfg(feature = "resilience")]
365    /// Fetches JSON with retry logic according to the resilience policy.
366    async fn fetch_json_with_retry<T>(
367        &self,
368        url: &Url,
369        policy: &crate::resilience::ResiliencePolicy,
370    ) -> Result<T>
371    where
372        T: for<'de> serde::Deserialize<'de>,
373    {
374        use std::time::Instant;
375
376        let start_time = Instant::now();
377        let mut attempt = 0;
378
379        loop {
380            // Check total timeout
381            if let Some(total_timeout) = policy.total_timeout {
382                if start_time.elapsed() >= total_timeout {
383                    return Err(Error::Api {
384                        status: 0,
385                        message: "Total operation timeout exceeded".to_string(),
386                    });
387                }
388            }
389
390            let req = self.inner.client.get(url.clone());
391            let req = self.apply_auth(req);
392            let result = req.send().await;
393
394            match result {
395                Ok(response) => {
396                    let status = response.status().as_u16();
397
398                    // Check if we should retry based on status
399                    if policy.should_retry_status(status) && attempt < policy.max_attempts {
400                        let delay = if status == 429 {
401                            // Handle 429 with Retry-After header
402                            let retry_after = response
403                                .headers()
404                                .get(reqwest::header::RETRY_AFTER)
405                                .and_then(|v| v.to_str().ok())
406                                .and_then(|s| s.parse::<u64>().ok())
407                                .map(std::time::Duration::from_secs);
408
409                            retry_after
410                                .unwrap_or_else(|| policy.calculate_delay(attempt))
411                                .min(policy.max_delay)
412                        } else {
413                            policy.calculate_delay(attempt)
414                        };
415
416                        attempt += 1;
417                        tokio::time::sleep(delay).await;
418                        continue;
419                    }
420
421                    // Not retryable or max attempts reached, handle response
422                    return self.handle_response(response).await;
423                }
424                Err(e) => {
425                    // Check if network error is retryable
426                    if (e.is_timeout() || e.is_connect()) && attempt < policy.max_attempts {
427                        let delay = policy.calculate_delay(attempt);
428                        attempt += 1;
429                        tokio::time::sleep(delay).await;
430                        continue;
431                    }
432                    return Err(Error::Http(e));
433                }
434            }
435        }
436    }
437
438    /// Handles a `reqwest::Response`, deserializing a successful response body
439    /// or converting an error status into an `Error`.
440    async fn handle_response<T>(&self, response: reqwest::Response) -> Result<T>
441    where
442        T: for<'de> serde::Deserialize<'de>,
443    {
444        let status = response.status();
445        if status.is_success() {
446            let text = response.text().await?;
447            let result = serde_json::from_str(&text)?;
448            return Ok(result);
449        }
450
451        if status.as_u16() == 429 {
452            // Retry-After may be delta-seconds or an HTTP-date; we only parse integer seconds.
453            let retry_after = response
454                .headers()
455                .get(reqwest::header::RETRY_AFTER)
456                .and_then(|v| v.to_str().ok())
457                .and_then(|s| s.parse::<u64>().ok());
458            return Err(Error::RateLimited { retry_after });
459        }
460
461        let error_text = response
462            .text()
463            .await
464            .unwrap_or_else(|_| "Unknown error".to_string());
465        Err(Error::Api {
466            status: status.as_u16(),
467            message: error_text,
468        })
469    }
470
471    /// Converts `SearchParams` into a vector of key-value pairs for a GET request.
472    ///
473    /// # Errors
474    ///
475    /// Returns an [`Error::Json`] if any part of the search parameters
476    /// cannot be serialized into a string.
477    fn search_params_to_query(params: &SearchParams) -> Result<Vec<(String, String)>> {
478        let mut query_params = Vec::new();
479
480        if let Some(limit) = params.limit {
481            query_params.push(("limit".to_string(), limit.to_string()));
482        }
483
484        if let Some(bbox) = &params.bbox {
485            let bbox_str = bbox
486                .iter()
487                .map(std::string::ToString::to_string)
488                .collect::<Vec<_>>()
489                .join(",");
490            query_params.push(("bbox".to_string(), bbox_str));
491        }
492
493        if let Some(datetime) = &params.datetime {
494            query_params.push(("datetime".to_string(), datetime.clone()));
495        }
496
497        if let Some(collections) = &params.collections {
498            let collections_str = collections.join(",");
499            query_params.push(("collections".to_string(), collections_str));
500        }
501
502        if let Some(ids) = &params.ids {
503            let ids_str = ids.join(",");
504            query_params.push(("ids".to_string(), ids_str));
505        }
506
507        if let Some(intersects) = &params.intersects {
508            let intersects_str = serde_json::to_string(intersects)?;
509            query_params.push(("intersects".to_string(), intersects_str));
510        }
511
512        // Handle query parameters (simplified - full implementation would need more complex handling)
513        if let Some(query) = &params.query {
514            for (key, value) in query {
515                let value_str = serde_json::to_string(value)?;
516                query_params.push((format!("query[{key}]"), value_str));
517            }
518        }
519
520        if let Some(sort_by) = &params.sortby {
521            let sort_str = sort_by
522                .iter()
523                .map(|s| {
524                    let prefix = match s.direction {
525                        SortDirection::Asc => "+",
526                        SortDirection::Desc => "-",
527                    };
528                    format!("{}{}", prefix, s.field)
529                })
530                .collect::<Vec<_>>()
531                .join(",");
532            query_params.push(("sortby".to_string(), sort_str));
533        }
534
535        if let Some(fields) = &params.fields {
536            let mut field_specs = Vec::new();
537            if let Some(include) = &fields.include {
538                field_specs.extend(include.iter().cloned());
539            }
540            if let Some(exclude) = &fields.exclude {
541                field_specs.extend(exclude.iter().map(|f| format!("-{f}")));
542            }
543
544            if !field_specs.is_empty() {
545                query_params.push(("fields".to_string(), field_specs.join(",")));
546            }
547        }
548
549        Ok(query_params)
550    }
551
552    /// Fetches the next page of results from an `ItemCollection`.
553    ///
554    /// This is a convenience helper available when the `pagination` feature is enabled.
555    /// It searches the `ItemCollection` links for one with `rel="next"` and, if
556    /// found, fetches the corresponding URL.
557    ///
558    /// Returns `Ok(None)` if no "next" link is present.
559    ///
560    /// # Errors
561    ///
562    /// Returns an `Error` if the request for the next page fails.
563    #[cfg(feature = "pagination")]
564    pub async fn search_next_page(
565        &self,
566        current: &ItemCollection,
567    ) -> Result<Option<ItemCollection>> {
568        let next_href = match &current.links {
569            Some(links) => links
570                .iter()
571                .find(|l| l.rel == "next")
572                .map(|l| l.href.clone()),
573            None => None,
574        };
575        let Some(href) = next_href else {
576            return Ok(None);
577        };
578        let url = Url::parse(&href).map_err(|e| Error::InvalidEndpoint(e.to_string()))?;
579        let page: ItemCollection = self.fetch_json(&url).await?;
580        Ok(Some(page))
581    }
582}
583
584#[cfg(any(feature = "resilience", feature = "auth"))]
585/// A builder for constructing a `Client` with resilience and/or authentication features.
586///
587/// This builder allows for fluent configuration of the STAC client,
588/// including resilience policies for retries/timeouts and pluggable
589/// authentication layers.
590///
591/// # Example
592///
593/// ```rust,ignore
594/// use stac_client::{ClientBuilder, ResiliencePolicy};
595/// use std::time::Duration;
596///
597/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
598/// let policy = ResiliencePolicy::new()
599///     .max_attempts(5)
600///     .base_delay(Duration::from_millis(200));
601///
602/// let client = ClientBuilder::new("https://api.example.com/stac")
603///     .resilience_policy(policy) // resilience feature
604///     .auth_layer(stac_client::auth::ApiKey::new("X-API-Key", "secret")) // auth feature
605///     .build()?;
606/// # Ok(())
607/// # }
608/// ```
609#[derive(Debug, Default)]
610pub struct ClientBuilder {
611    base_url: String,
612    #[cfg(feature = "resilience")]
613    resilience_policy: Option<crate::resilience::ResiliencePolicy>,
614    #[cfg(feature = "auth")]
615    auth_layers: Vec<Box<dyn crate::auth::AuthLayer>>,
616}
617
618#[cfg(any(feature = "resilience", feature = "auth"))]
619impl ClientBuilder {
620    /// Creates a new `ClientBuilder` for the given base URL.
621    ///
622    /// # Arguments
623    ///
624    /// * `base_url` - The base URL of the STAC API.
625    #[must_use]
626    pub fn new(base_url: &str) -> Self {
627        Self {
628            base_url: base_url.to_string(),
629            ..Default::default()
630        }
631    }
632
633    /// Sets the resilience policy for the client.
634    ///
635    /// Requires the `resilience` feature.
636    ///
637    /// # Arguments
638    ///
639    /// * `policy` - The `ResiliencePolicy` to use for retries and timeouts.
640    #[cfg(feature = "resilience")]
641    #[must_use]
642    pub fn resilience_policy(mut self, policy: crate::resilience::ResiliencePolicy) -> Self {
643        self.resilience_policy = Some(policy);
644        self
645    }
646
647    /// Adds an authentication layer to the client.
648    ///
649    /// Requires the `auth` feature.
650    ///
651    /// # Arguments
652    ///
653    /// * `layer` - An implementation of `AuthLayer` to apply to all requests.
654    #[cfg(feature = "auth")]
655    #[must_use]
656    pub fn auth_layer(mut self, layer: impl crate::auth::AuthLayer + 'static) -> Self {
657        self.auth_layers.push(Box::new(layer));
658        self
659    }
660
661    /// Builds and returns a configured `Client`.
662    ///
663    /// # Errors
664    ///
665    /// Returns an [`Error::Url`] if the provided `base_url` is not a valid URL.
666    pub fn build(self) -> Result<Client> {
667        let base_url = Url::parse(&self.base_url)?;
668        let mut client_builder = reqwest::Client::builder();
669
670        #[cfg(feature = "resilience")]
671        if let Some(ref policy) = self.resilience_policy {
672            if let Some(timeout) = policy.request_timeout {
673                client_builder = client_builder.timeout(timeout);
674            }
675            if let Some(connect_timeout) = policy.connect_timeout {
676                client_builder = client_builder.connect_timeout(connect_timeout);
677            }
678        }
679
680        let client = client_builder.build()?;
681        let inner = ClientInner {
682            base_url,
683            client,
684            conformance: OnceCell::new(),
685            #[cfg(feature = "resilience")]
686            resilience_policy: self.resilience_policy,
687            #[cfg(feature = "auth")]
688            auth_layers: self.auth_layers,
689        };
690
691        Ok(Client {
692            inner: Arc::new(inner),
693        })
694    }
695}
696
697/// A fluent builder for constructing `SearchParams`.
698///
699/// This builder helps create a `SearchParams` struct, which can be passed to
700/// the `Client::search` or `Client::search_get` methods.
701pub struct SearchBuilder {
702    params: SearchParams,
703}
704
705impl SearchBuilder {
706    /// Creates a new, empty `SearchBuilder`.
707    #[must_use]
708    pub fn new() -> Self {
709        Self {
710            params: SearchParams::default(),
711        }
712    }
713
714    /// Sets the maximum number of items to return (the `limit` parameter).
715    #[must_use]
716    pub fn limit(mut self, limit: u32) -> Self {
717        self.params.limit = Some(limit);
718        self
719    }
720
721    /// Sets the spatial bounding box for the search.
722    ///
723    /// The coordinates must be in the order: `[west, south, east, north]`.
724    /// An optional fifth and sixth element can be used to specify a vertical
725    /// range (`[min_elevation, max_elevation]`).
726    #[must_use]
727    pub fn bbox(mut self, bbox: Vec<f64>) -> Self {
728        self.params.bbox = Some(bbox);
729        self
730    }
731
732    /// Sets the temporal window for the search using a `datetime` string.
733    ///
734    /// This can be a single datetime or a closed/open interval.
735    /// See the [STAC API spec](https://github.com/radiantearth/stac-api-spec/blob/master/fragments/datetime/README.md)
736    /// for valid formats.
737    #[must_use]
738    pub fn datetime(mut self, datetime: &str) -> Self {
739        self.params.datetime = Some(datetime.to_string());
740        self
741    }
742
743    /// Restricts the search to a set of collection IDs.
744    #[must_use]
745    pub fn collections(mut self, collections: Vec<String>) -> Self {
746        self.params.collections = Some(collections);
747        self
748    }
749
750    /// Restricts the search to a set of item IDs.
751    #[must_use]
752    pub fn ids(mut self, ids: Vec<String>) -> Self {
753        self.params.ids = Some(ids);
754        self
755    }
756
757    /// Filters items that intersect a `GeoJSON` geometry.
758    #[must_use]
759    pub fn intersects(mut self, geometry: serde_json::Value) -> Self {
760        self.params.intersects = Some(geometry);
761        self
762    }
763
764    /// Adds a filter expression using the STAC Query Extension.
765    ///
766    /// If a query already exists for the given key, it will be overwritten.
767    #[must_use]
768    pub fn query(mut self, key: &str, value: serde_json::Value) -> Self {
769        self.params
770            .query
771            .get_or_insert_with(HashMap::new)
772            .insert(key.to_string(), value);
773        self
774    }
775
776    /// Adds a sorting rule. Multiple calls will append additional sort rules.
777    #[must_use]
778    pub fn sort_by(mut self, field: &str, direction: SortDirection) -> Self {
779        self.params
780            .sortby
781            .get_or_insert_with(Vec::new)
782            .push(SortBy {
783                field: field.to_string(),
784                direction,
785            });
786        self
787    }
788
789    /// Includes only the specified fields in the response.
790    ///
791    /// This will overwrite any previously set `include` fields.
792    #[must_use]
793    pub fn include_fields(mut self, fields: Vec<String>) -> Self {
794        self.params
795            .fields
796            .get_or_insert_with(FieldsFilter::default)
797            .include = Some(fields);
798        self
799    }
800
801    /// Excludes the specified fields from the response.
802    ///
803    /// This will overwrite any previously set `exclude` fields.
804    #[must_use]
805    pub fn exclude_fields(mut self, fields: Vec<String>) -> Self {
806        self.params
807            .fields
808            .get_or_insert_with(FieldsFilter::default)
809            .exclude = Some(fields);
810        self
811    }
812
813    /// Finalizes the builder and returns the constructed `SearchParams`.
814    #[must_use]
815    pub fn build(self) -> SearchParams {
816        self.params
817    }
818}
819
820impl Default for SearchBuilder {
821    fn default() -> Self {
822        Self::new()
823    }
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829    use mockito;
830    use serde_json::json;
831
832    #[test]
833    fn test_client_creation() {
834        let client = Client::new("https://example.com/stac").unwrap();
835        assert_eq!(client.base_url().as_str(), "https://example.com/stac");
836    }
837
838    #[test]
839    fn test_invalid_url() {
840        let result = Client::new("not-a-valid-url");
841        assert!(result.is_err());
842    }
843
844    #[test]
845    fn test_search_builder() {
846        let params = SearchBuilder::new()
847            .limit(10)
848            .bbox(vec![-180.0, -90.0, 180.0, 90.0])
849            .datetime("2023-01-01T00:00:00Z/2023-12-31T23:59:59Z")
850            .collections(vec!["collection1".to_string(), "collection2".to_string()])
851            .ids(vec!["item1".to_string(), "item2".to_string()])
852            .query("eo:cloud_cover", json!({"lt": 10}))
853            .sort_by("datetime", SortDirection::Desc)
854            .include_fields(vec!["id".to_string(), "geometry".to_string()])
855            .build();
856
857        assert_eq!(params.limit, Some(10));
858        assert_eq!(params.bbox, Some(vec![-180.0, -90.0, 180.0, 90.0]));
859        assert_eq!(
860            params.datetime,
861            Some("2023-01-01T00:00:00Z/2023-12-31T23:59:59Z".to_string())
862        );
863        assert_eq!(
864            params.collections,
865            Some(vec!["collection1".to_string(), "collection2".to_string()])
866        );
867        assert_eq!(
868            params.ids,
869            Some(vec!["item1".to_string(), "item2".to_string()])
870        );
871        assert!(params.query.is_some());
872        assert!(params.sortby.is_some());
873        assert!(params.fields.is_some());
874    }
875
876    #[tokio::test]
877    async fn test_get_catalog_mock() {
878        let mut server = mockito::Server::new_async().await;
879        let mock_catalog = json!({
880            "type": "Catalog",
881            "stac_version": "1.0.0",
882            "id": "test-catalog",
883            "description": "Test catalog",
884            "links": []
885        });
886
887        let mock = server
888            .mock("GET", "/")
889            .with_status(200)
890            .with_header("content-type", "application/json")
891            .with_body(mock_catalog.to_string())
892            .create_async()
893            .await;
894
895        let client = Client::new(&server.url()).unwrap();
896        let catalog = client.get_catalog().await.unwrap();
897
898        mock.assert_async().await;
899        assert_eq!(catalog.id, "test-catalog");
900        assert_eq!(catalog.stac_version, "1.0.0");
901    }
902
903    #[tokio::test]
904    async fn test_get_collections_mock() {
905        let mut server = mockito::Server::new_async().await;
906        let mock_response = json!({
907            "collections": [
908                {
909                    "type": "Collection",
910                    "stac_version": "1.0.0",
911                    "id": "test-collection",
912                    "description": "Test collection",
913                    "license": "MIT",
914                    "extent": {
915                        "spatial": {
916                            "bbox": [[-180.0, -90.0, 180.0, 90.0]]
917                        },
918                        "temporal": {
919                            "interval": [["2023-01-01T00:00:00Z", "2023-12-31T23:59:59Z"]]
920                        }
921                    },
922                    "links": []
923                }
924            ]
925        });
926
927        let mock = server
928            .mock("GET", "/collections")
929            .with_status(200)
930            .with_header("content-type", "application/json")
931            .with_body(mock_response.to_string())
932            .create_async()
933            .await;
934
935        let client = Client::new(&server.url()).unwrap();
936        let collections = client.get_collections().await.unwrap();
937
938        mock.assert_async().await;
939        assert_eq!(collections.len(), 1);
940        assert_eq!(collections[0].id, "test-collection");
941    }
942
943    #[tokio::test]
944    async fn test_search_mock() {
945        let mut server = mockito::Server::new_async().await;
946        let mock_response = json!({
947            "type": "FeatureCollection",
948            "features": [
949                {
950                    "type": "Feature",
951                    "stac_version": "1.0.0",
952                    "id": "test-item",
953                    "geometry": null,
954                    "properties": {
955                        "datetime": "2023-01-01T12:00:00Z"
956                    },
957                    "links": [],
958                    "assets": {},
959                    "collection": "test-collection"
960                }
961            ]
962        });
963
964        let mock = server
965            .mock("POST", "/search")
966            .with_status(200)
967            .with_header("content-type", "application/json")
968            .with_body(mock_response.to_string())
969            .create_async()
970            .await;
971
972        let client = Client::new(&server.url()).unwrap();
973        let search_params = SearchBuilder::new()
974            .limit(10)
975            .collections(vec!["test-collection".to_string()])
976            .build();
977
978        let results = client.search(&search_params).await.unwrap();
979
980        mock.assert_async().await;
981        assert_eq!(results.features.len(), 1);
982        assert_eq!(results.features[0].id, "test-item");
983        assert_eq!(
984            results.features[0].collection.as_ref().unwrap(),
985            "test-collection"
986        );
987    }
988
989    #[tokio::test]
990    async fn test_error_handling() {
991        let mut server = mockito::Server::new_async().await;
992        let mock = server
993            .mock("GET", "/")
994            .with_status(404)
995            .with_body("Not found")
996            .create_async()
997            .await;
998
999        let client = Client::new(&server.url()).unwrap();
1000        let result = client.get_catalog().await;
1001
1002        mock.assert_async().await;
1003        assert!(result.is_err());
1004        match result.unwrap_err() {
1005            Error::Api { status, .. } => assert_eq!(status, 404),
1006            _ => panic!("Expected API error"),
1007        }
1008    }
1009
1010    #[test]
1011    fn test_search_params_to_query() {
1012        let params = SearchParams {
1013            limit: Some(10),
1014            bbox: Some(vec![-180.0, -90.0, 180.0, 90.0]),
1015            datetime: Some("2023-01-01T00:00:00Z".to_string()),
1016            collections: Some(vec!["col1".to_string(), "col2".to_string()]),
1017            ids: Some(vec!["id1".to_string(), "id2".to_string()]),
1018            ..Default::default()
1019        };
1020
1021        let query_params = Client::search_params_to_query(&params).unwrap();
1022
1023        // Check that all expected parameters are present
1024        let param_map: std::collections::HashMap<String, String> =
1025            query_params.into_iter().collect();
1026
1027        assert_eq!(param_map.get("limit").unwrap(), "10");
1028        assert_eq!(param_map.get("bbox").unwrap(), "-180,-90,180,90");
1029        assert_eq!(param_map.get("datetime").unwrap(), "2023-01-01T00:00:00Z");
1030        assert_eq!(param_map.get("collections").unwrap(), "col1,col2");
1031        assert_eq!(param_map.get("ids").unwrap(), "id1,id2");
1032    }
1033
1034    #[test]
1035    fn test_search_params_to_query_with_intersects_and_query() {
1036        let mut query_map = HashMap::new();
1037        query_map.insert("eo:cloud_cover".to_string(), json!({"lt": 5}));
1038        let geom = json!({
1039            "type": "Point",
1040            "coordinates": [0.0, 0.0]
1041        });
1042        let params = SearchParams {
1043            intersects: Some(geom.clone()),
1044            query: Some(query_map.clone()),
1045            ..Default::default()
1046        };
1047
1048        let query_params = Client::search_params_to_query(&params).unwrap();
1049        let param_map: std::collections::HashMap<String, String> =
1050            query_params.into_iter().collect();
1051
1052        // Ensure intersects serialized and query expression present
1053        assert!(param_map.contains_key("intersects"));
1054        // URL encoding not applied yet (raw value) so we can check JSON substring
1055        assert!(param_map.get("intersects").unwrap().contains("\"Point\""));
1056        assert!(param_map.contains_key("query[eo:cloud_cover]"));
1057        assert_eq!(
1058            param_map.get("query[eo:cloud_cover]").unwrap(),
1059            &serde_json::to_string(&json!({"lt": 5})).unwrap()
1060        );
1061    }
1062
1063    #[test]
1064    fn test_search_params_to_query_with_sortby_and_fields() {
1065        let params = SearchBuilder::new()
1066            .sort_by("datetime", SortDirection::Asc)
1067            .sort_by("eo:cloud_cover", SortDirection::Desc)
1068            .include_fields(vec!["id".to_string(), "properties".to_string()])
1069            .exclude_fields(vec!["geometry".to_string()])
1070            .build();
1071
1072        let query_params = Client::search_params_to_query(&params).unwrap();
1073        let param_map: std::collections::HashMap<String, String> =
1074            query_params.into_iter().collect();
1075
1076        assert_eq!(
1077            param_map.get("sortby").unwrap(),
1078            "+datetime,-eo:cloud_cover"
1079        );
1080        assert_eq!(param_map.get("fields").unwrap(), "id,properties,-geometry");
1081    }
1082
1083    #[tokio::test]
1084    async fn test_conformance_handling_mock() {
1085        let mut server = mockito::Server::new_async().await;
1086        let mock_conformance = json!({
1087            "conformsTo": [
1088                "https://api.stacspec.org/v1.0.0/core",
1089                "https://api.stacspec.org/v1.0.0/collections",
1090                "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core"
1091            ]
1092        });
1093
1094        let mock = server
1095            .mock("GET", "/conformance")
1096            .with_status(200)
1097            .with_header("content-type", "application/json")
1098            .with_body(mock_conformance.to_string())
1099            .create_async()
1100            .await;
1101
1102        let client = Client::new(&server.url()).unwrap();
1103
1104        // First call should fetch and cache
1105        let conformance = client.conformance().await.unwrap();
1106        assert!(conformance.conforms_to("https://api.stacspec.org/v1.0.0/core"));
1107        assert!(!conformance.conforms_to("https://api.stacspec.org/v1.0.0/item-search"));
1108
1109        // Second call should use the cache
1110        let conformance_cached = client.conformance().await.unwrap();
1111        assert_eq!(conformance.conforms_to, conformance_cached.conforms_to);
1112
1113        // The mock should have been called exactly once
1114        mock.assert_async().await;
1115    }
1116
1117    #[test]
1118    fn test_search_builder_exclude_fields() {
1119        let params = SearchBuilder::new()
1120            .exclude_fields(vec!["geometry".to_string(), "assets".to_string()])
1121            .build();
1122        assert!(params.fields.is_some());
1123        let fields = params.fields.unwrap();
1124        assert!(fields.include.is_none());
1125        assert_eq!(
1126            fields.exclude.unwrap(),
1127            vec!["geometry".to_string(), "assets".to_string()]
1128        );
1129    }
1130}