1use 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#[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 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 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 #[must_use]
84 pub fn base_url(&self) -> &Url {
85 &self.inner.base_url
86 }
87
88 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 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 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 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 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 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 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 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 if policy.should_retry_status(status) && attempt < policy.max_attempts {
239 let delay = if status == 429 {
240 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 return self.handle_response(response).await;
262 }
263 Err(e) => {
264 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 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 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 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 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 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 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 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 if policy.should_retry_status(status) && attempt < policy.max_attempts {
371 let delay = if status == 429 {
372 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 return self.handle_response(response).await;
394 }
395 Err(e) => {
396 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 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 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 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) = ¶ms.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) = ¶ms.datetime {
465 query_params.push(("datetime".to_string(), datetime.clone()));
466 }
467
468 if let Some(collections) = ¶ms.collections {
469 let collections_str = collections.join(",");
470 query_params.push(("collections".to_string(), collections_str));
471 }
472
473 if let Some(ids) = ¶ms.ids {
474 let ids_str = ids.join(",");
475 query_params.push(("ids".to_string(), ids_str));
476 }
477
478 if let Some(intersects) = ¶ms.intersects {
479 let intersects_str = serde_json::to_string(intersects)?;
480 query_params.push(("intersects".to_string(), intersects_str));
481 }
482
483 if let Some(query) = ¶ms.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) = ¶ms.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) = ¶ms.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 #[cfg(feature = "pagination")]
535 pub async fn search_next_page(
536 &self,
537 current: &ItemCollection,
538 ) -> Result<Option<ItemCollection>> {
539 let next_href = match ¤t.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#[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 #[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 #[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 pub fn build(self) -> Result<Client> {
617 let base_url = Url::parse(&self.base_url)?;
618
619 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
644pub struct SearchBuilder {
649 params: SearchParams,
650}
651
652impl SearchBuilder {
653 #[must_use]
655 pub fn new() -> Self {
656 Self {
657 params: SearchParams::default(),
658 }
659 }
660
661 #[must_use]
663 pub fn limit(mut self, limit: u32) -> Self {
664 self.params.limit = Some(limit);
665 self
666 }
667
668 #[must_use]
674 pub fn bbox(mut self, bbox: Vec<f64>) -> Self {
675 self.params.bbox = Some(bbox);
676 self
677 }
678
679 #[must_use]
685 pub fn datetime(mut self, datetime: &str) -> Self {
686 self.params.datetime = Some(datetime.to_string());
687 self
688 }
689
690 #[must_use]
692 pub fn collections(mut self, collections: Vec<String>) -> Self {
693 self.params.collections = Some(collections);
694 self
695 }
696
697 #[must_use]
699 pub fn ids(mut self, ids: Vec<String>) -> Self {
700 self.params.ids = Some(ids);
701 self
702 }
703
704 #[must_use]
706 pub fn intersects(mut self, geometry: serde_json::Value) -> Self {
707 self.params.intersects = Some(geometry);
708 self
709 }
710
711 #[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 #[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 #[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 #[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 #[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(¶ms).unwrap();
969
970 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(¶ms).unwrap();
996 let param_map: std::collections::HashMap<String, String> =
997 query_params.into_iter().collect();
998
999 assert!(param_map.contains_key("intersects"));
1001 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(¶ms).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 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 let conformance_cached = client.conformance().await.unwrap();
1058 assert_eq!(conformance.conforms_to, conformance_cached.conforms_to);
1059
1060 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}