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 #[cfg(feature = "auth")]
35 auth_layers: Vec<Box<dyn crate::auth::AuthLayer>>,
36}
37
38impl Client {
39 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 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 #[must_use]
90 pub fn base_url(&self) -> &Url {
91 &self.inner.base_url
92 }
93
94 #[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 #[cfg(not(feature = "auth"))]
105 fn apply_auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
106 req
107 }
108
109 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 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 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 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 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 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 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 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 if policy.should_retry_status(status) && attempt < policy.max_attempts {
264 let delay = if status == 429 {
265 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 return self.handle_response(response).await;
287 }
288 Err(e) => {
289 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 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 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 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 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 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 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 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 if policy.should_retry_status(status) && attempt < policy.max_attempts {
400 let delay = if status == 429 {
401 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 return self.handle_response(response).await;
423 }
424 Err(e) => {
425 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 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 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 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) = ¶ms.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) = ¶ms.datetime {
494 query_params.push(("datetime".to_string(), datetime.clone()));
495 }
496
497 if let Some(collections) = ¶ms.collections {
498 let collections_str = collections.join(",");
499 query_params.push(("collections".to_string(), collections_str));
500 }
501
502 if let Some(ids) = ¶ms.ids {
503 let ids_str = ids.join(",");
504 query_params.push(("ids".to_string(), ids_str));
505 }
506
507 if let Some(intersects) = ¶ms.intersects {
508 let intersects_str = serde_json::to_string(intersects)?;
509 query_params.push(("intersects".to_string(), intersects_str));
510 }
511
512 if let Some(query) = ¶ms.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) = ¶ms.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) = ¶ms.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 #[cfg(feature = "pagination")]
564 pub async fn search_next_page(
565 &self,
566 current: &ItemCollection,
567 ) -> Result<Option<ItemCollection>> {
568 let next_href = match ¤t.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#[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 #[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 #[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 #[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 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
697pub struct SearchBuilder {
702 params: SearchParams,
703}
704
705impl SearchBuilder {
706 #[must_use]
708 pub fn new() -> Self {
709 Self {
710 params: SearchParams::default(),
711 }
712 }
713
714 #[must_use]
716 pub fn limit(mut self, limit: u32) -> Self {
717 self.params.limit = Some(limit);
718 self
719 }
720
721 #[must_use]
727 pub fn bbox(mut self, bbox: Vec<f64>) -> Self {
728 self.params.bbox = Some(bbox);
729 self
730 }
731
732 #[must_use]
738 pub fn datetime(mut self, datetime: &str) -> Self {
739 self.params.datetime = Some(datetime.to_string());
740 self
741 }
742
743 #[must_use]
745 pub fn collections(mut self, collections: Vec<String>) -> Self {
746 self.params.collections = Some(collections);
747 self
748 }
749
750 #[must_use]
752 pub fn ids(mut self, ids: Vec<String>) -> Self {
753 self.params.ids = Some(ids);
754 self
755 }
756
757 #[must_use]
759 pub fn intersects(mut self, geometry: serde_json::Value) -> Self {
760 self.params.intersects = Some(geometry);
761 self
762 }
763
764 #[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 #[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 #[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 #[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 #[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(¶ms).unwrap();
1022
1023 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(¶ms).unwrap();
1049 let param_map: std::collections::HashMap<String, String> =
1050 query_params.into_iter().collect();
1051
1052 assert!(param_map.contains_key("intersects"));
1054 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(¶ms).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 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 let conformance_cached = client.conformance().await.unwrap();
1111 assert_eq!(conformance.conforms_to, conformance_cached.conforms_to);
1112
1113 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}