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