stac_client/
models.rs

1//! STAC data models, such as `Item`, `Collection`, and `Catalog`.
2//!
3//! The structs in this module represent the core objects in the
4//! [STAC specification](https://stacspec.org/). They are designed to be
5//! deserialized from STAC API responses and provide access to STAC metadata.
6//!
7//! Fields that are not part of the core specification but are used by common
8//! extensions (like `datetime`) are included as optional fields. Any other
9//! non-standard fields are collected in an `extra` field to ensure forward
10//! compatibility.
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16/// A type alias for an arbitrary JSON value.
17pub type JsonValue = serde_json::Value;
18/// A type alias for the `properties` field of a STAC `Item`.
19pub type Properties = HashMap<String, JsonValue>;
20
21/// Represents a STAC Link object.
22///
23/// See the [STAC Link Object specification](https://github.com/radiantearth/stac-spec/blob/v1.0.0/spec/item-spec/item-spec.md#link-object)
24/// for details.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Link {
27    /// The absolute or relative URL of the linked resource.
28    pub href: String,
29    /// The relation type of the link (e.g., `self`, `root`, `item`).
30    pub rel: String,
31    /// The media type of the linked resource.
32    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
33    pub media_type: Option<String>,
34    /// A human-readable title for the link.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub title: Option<String>,
37    /// Additional fields not part of the core specification.
38    #[serde(flatten)]
39    pub extra: HashMap<String, JsonValue>,
40}
41
42/// Represents a STAC Asset object, which is a link to a resource.
43///
44/// See the [STAC Asset Object specification](https://github.com/radiantearth/stac-spec/blob/v1.0.0/spec/item-spec/item-spec.md#asset-object)
45/// for details.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Asset {
48    /// The URL of the asset.
49    pub href: String,
50    /// A human-readable title for the asset.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub title: Option<String>,
53    /// A description of the asset.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub description: Option<String>,
56    /// The media type of the asset.
57    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
58    pub media_type: Option<String>,
59    /// The semantic roles of the asset (e.g., `thumbnail`, `overview`).
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub roles: Option<Vec<String>>,
62    /// Additional fields not part of the core specification.
63    #[serde(flatten)]
64    pub extra: HashMap<String, JsonValue>,
65}
66
67/// Represents a STAC Provider object, which describes an organization providing data.
68///
69/// See the [STAC Provider Object specification](https://github.com/radiantearth/stac-spec/blob/v1.0.0/collection-spec/collection-spec.md#provider-object)
70/// for details.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct Provider {
73    /// The name of the provider.
74    pub name: String,
75    /// A description of the provider.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub description: Option<String>,
78    /// The roles of the provider (e.g., `producer`, `licensor`).
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub roles: Option<Vec<String>>,
81    /// A URL to the provider's website.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub url: Option<String>,
84}
85
86/// Represents a STAC Extent, which describes the spatial and temporal range of a `Collection`.
87///
88/// See the [STAC Extent Object specification](https://github.com/radiantearth/stac-spec/blob/v1.0.0/collection-spec/collection-spec.md#extent-object)
89/// for details.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Extent {
92    /// The spatial extent (bounding box) of the collection.
93    pub spatial: SpatialExtent,
94    /// The temporal extent (time interval) of the collection.
95    pub temporal: TemporalExtent,
96}
97
98/// The spatial component of a STAC `Extent`.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SpatialExtent {
101    /// A list of bounding boxes covering the spatial extent.
102    pub bbox: Vec<Vec<f64>>,
103}
104
105/// The temporal component of a STAC `Extent`.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct TemporalExtent {
108    /// A list of time intervals, where `None` indicates an open-ended interval.
109    pub interval: Vec<Vec<Option<DateTime<Utc>>>>,
110}
111
112/// Represents a STAC Catalog object.
113///
114/// See the [STAC Catalog Specification](https://github.com/radiantearth/stac-spec/blob/v1.0.0/catalog-spec/catalog-spec.md)
115/// for details.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Catalog {
118    /// The type of the object, which is always "Catalog".
119    #[serde(rename = "type")]
120    pub catalog_type: String,
121    /// The STAC version the catalog implements.
122    pub stac_version: String,
123    /// The ID of the catalog.
124    pub id: String,
125    /// A human-readable title for the catalog.
126    pub title: Option<String>,
127    /// A detailed description of the catalog.
128    pub description: String,
129    /// A list of links to other STAC objects.
130    pub links: Vec<Link>,
131    /// Additional fields not part of the core specification.
132    #[serde(flatten)]
133    pub extra: HashMap<String, JsonValue>,
134}
135
136/// Represents a STAC Collection object.
137///
138/// See the [STAC Collection Specification](https://github.com/radiantearth/stac-spec/blob/v1.0.0/collection-spec/collection-spec.md)
139/// for details.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct Collection {
142    /// The type of the object, which is always "Collection".
143    #[serde(rename = "type")]
144    pub collection_type: String,
145    /// The STAC version the collection implements.
146    pub stac_version: String,
147    /// The ID of the collection.
148    pub id: String,
149    /// A human-readable title for the collection.
150    pub title: Option<String>,
151    /// A detailed description of the collection.
152    pub description: String,
153    /// A list of keywords describing the collection.
154    pub keywords: Option<Vec<String>>,
155    /// The license of the data in the collection.
156    pub license: String,
157    /// A list of providers for the collection.
158    pub providers: Option<Vec<Provider>>,
159    /// The spatial and temporal extent of the collection.
160    pub extent: Extent,
161    /// A map of summaries of item properties.
162    pub summaries: Option<HashMap<String, JsonValue>>,
163    /// A list of links to other STAC objects.
164    pub links: Vec<Link>,
165    /// A map of assets available at the collection level.
166    pub assets: Option<HashMap<String, Asset>>,
167    /// Additional fields not part of the core specification.
168    #[serde(flatten)]
169    pub extra: HashMap<String, JsonValue>,
170}
171
172/// Represents a STAC Item object, which is a `GeoJSON` Feature.
173///
174/// See the [STAC Item Specification](https://github.com/radiantearth/stac-spec/blob/v1.0.0/item-spec/item-spec.md)
175/// for details.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct Item {
178    /// The type of the object, which is always "Feature".
179    #[serde(rename = "type")]
180    pub item_type: String,
181    /// The STAC version the item implements.
182    pub stac_version: String,
183    /// The ID of the item.
184    pub id: String,
185    /// The `GeoJSON` geometry of the item.
186    pub geometry: Option<JsonValue>,
187    /// The bounding box of the item's geometry.
188    pub bbox: Option<Vec<f64>>,
189    /// A dictionary of metadata properties.
190    pub properties: Properties,
191    /// A list of links to other STAC objects.
192    pub links: Vec<Link>,
193    /// A map of assets, such as data files.
194    pub assets: HashMap<String, Asset>,
195    /// The ID of the collection this item belongs to.
196    pub collection: Option<String>,
197    /// Additional fields not part of the core specification.
198    #[serde(flatten)]
199    pub extra: HashMap<String, JsonValue>,
200}
201
202/// Represents a `GeoJSON` `FeatureCollection` containing STAC `Item` objects.
203///
204/// This is the standard response format for a STAC API search. See the
205/// [STAC API ItemCollection docs](https://github.com/radiantearth/stac-api-spec/blob/v1.0.0/fragments/itemcollection/README.md)
206/// for details.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct ItemCollection {
209    /// The type of the object, which is always "`FeatureCollection`".
210    #[serde(rename = "type")]
211    pub collection_type: String,
212    /// The list of `Item` objects returned by the search.
213    pub features: Vec<Item>,
214    /// A list of links related to the search results, such as for pagination.
215    pub links: Option<Vec<Link>>,
216    /// Context metadata about the search results.
217    pub context: Option<SearchContext>,
218}
219
220/// Context metadata for an `ItemCollection` response from a STAC API search.
221///
222/// See the [STAC API Context Object docs](https://github.com/radiantearth/stac-api-spec/blob/v1.0.0/fragments/context/README.md)
223/// for details.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct SearchContext {
226    /// The number of items returned in the current response.
227    pub returned: u64,
228    /// The maximum number of items requested.
229    pub limit: Option<u64>,
230    /// The total number of items that matched the search criteria.
231    pub matched: Option<u64>,
232}
233
234/// Represents the response from a STAC API's `/conformance` endpoint.
235///
236/// See the [STAC API Conformance documentation](https://github.com/radiantearth/stac-api-spec/blob/master/conformance/README.md)
237/// for details.
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct Conformance {
240    /// A list of conformance class URIs that the API conforms to.
241    #[serde(rename = "conformsTo")]
242    pub conforms_to: Vec<String>,
243}
244
245impl Conformance {
246    /// Checks if the API conforms to a given conformance class URI.
247    ///
248    /// # Arguments
249    ///
250    /// * `class` - The conformance class URI to check for.
251    ///
252    /// # Returns
253    ///
254    /// `true` if the API conforms to the specified class, `false` otherwise.
255    #[must_use]
256    pub fn conforms_to(&self, class: &str) -> bool {
257        self.conforms_to.iter().any(|c| c == class)
258    }
259}
260
261/// Parameters for a STAC API search.
262///
263/// This struct is used with `SearchBuilder` to construct a query for the
264/// `POST /search` or `GET /search` endpoints.
265#[derive(Debug, Clone, Default, Serialize)]
266pub struct SearchParams {
267    /// The maximum number of items to return.
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub limit: Option<u32>,
270    /// A bounding box to filter items by.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub bbox: Option<Vec<f64>>,
273    /// A datetime string or interval to filter items by.
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub datetime: Option<String>,
276    /// A `GeoJSON` geometry to filter items by spatial intersection.
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub intersects: Option<JsonValue>,
279    /// A list of collection IDs to search within.
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub collections: Option<Vec<String>>,
282    /// A list of item IDs to retrieve.
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub ids: Option<Vec<String>>,
285    /// A map of query expressions for filtering item properties.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub query: Option<HashMap<String, JsonValue>>,
288    /// A list of sorting rules.
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub sortby: Option<Vec<SortBy>>,
291    /// A filter for including or excluding specific fields.
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub fields: Option<FieldsFilter>,
294}
295
296/// A sorting rule for a STAC API search.
297///
298/// See the [STAC API Sort Extension](https://github.com/radiantearth/stac-api-spec/tree/v1.0.0/fragments/sort)
299/// for details.
300#[derive(Debug, Clone, Serialize)]
301pub struct SortBy {
302    /// The property name to sort by.
303    pub field: String,
304    /// The sort direction.
305    pub direction: SortDirection,
306}
307
308/// The direction for a sort rule.
309#[derive(Debug, Clone, Serialize)]
310#[serde(rename_all = "lowercase")]
311pub enum SortDirection {
312    /// Sort in ascending order.
313    Asc,
314    /// Sort in descending order.
315    Desc,
316}
317
318/// A filter for including or excluding fields in a STAC API search response.
319///
320/// See the [STAC API Fields Extension](https://github.com/radiantearth/stac-api-spec/tree/v1.0.0/fragments/fields)
321/// for details.
322#[derive(Debug, Clone, Default, Serialize)]
323pub struct FieldsFilter {
324    /// A list of fields to include in the response.
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub include: Option<Vec<String>>,
327    /// A list of fields to exclude from the response.
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub exclude: Option<Vec<String>>,
330}
331
332/// Represents an asset that has been downloaded into memory.
333#[derive(Debug, Clone)]
334pub struct DownloadedAsset {
335    /// The raw byte content of the asset.
336    pub content: Vec<u8>,
337}
338
339impl DownloadedAsset {
340    /// Saves the downloaded asset's content to a file.
341    ///
342    /// # Arguments
343    ///
344    /// * `path` - The file path where the content should be saved.
345    ///
346    /// # Errors
347    ///
348    /// Returns an `Error::Io` if the file cannot be created or written to.
349    pub fn save(&self, path: &std::path::Path) -> crate::Result<()> {
350        use std::io::Write;
351        let mut file = std::fs::File::create(path)?;
352        file.write_all(&self.content)?;
353        Ok(())
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use serde_json::json;
361
362    #[test]
363    fn test_catalog_serialization() {
364        let catalog = Catalog {
365            catalog_type: "Catalog".to_string(),
366            stac_version: "1.0.0".to_string(),
367            id: "test-catalog".to_string(),
368            title: Some("Test Catalog".to_string()),
369            description: "A test catalog".to_string(),
370            links: vec![],
371            extra: HashMap::new(),
372        };
373
374        let json_str = serde_json::to_string(&catalog).unwrap();
375        let deserialized: Catalog = serde_json::from_str(&json_str).unwrap();
376
377        assert_eq!(catalog.id, deserialized.id);
378        assert_eq!(catalog.stac_version, deserialized.stac_version);
379    }
380
381    #[test]
382    fn test_collection_serialization() {
383        let collection = Collection {
384            collection_type: "Collection".to_string(),
385            stac_version: "1.0.0".to_string(),
386            id: "test-collection".to_string(),
387            title: Some("Test Collection".to_string()),
388            description: "A test collection".to_string(),
389            keywords: Some(vec!["test".to_string()]),
390            license: "MIT".to_string(),
391            providers: None,
392            extent: Extent {
393                spatial: SpatialExtent {
394                    bbox: vec![vec![-180.0, -90.0, 180.0, 90.0]],
395                },
396                temporal: TemporalExtent {
397                    interval: vec![vec![None, None]],
398                },
399            },
400            summaries: None,
401            links: vec![],
402            assets: None,
403            extra: HashMap::new(),
404        };
405
406        let json_str = serde_json::to_string(&collection).unwrap();
407        let deserialized: Collection = serde_json::from_str(&json_str).unwrap();
408
409        assert_eq!(collection.id, deserialized.id);
410        assert_eq!(collection.license, deserialized.license);
411    }
412
413    #[test]
414    fn test_item_serialization() {
415        let mut properties = HashMap::new();
416        properties.insert("datetime".to_string(), json!("2023-01-01T12:00:00Z"));
417
418        let item = Item {
419            item_type: "Feature".to_string(),
420            stac_version: "1.0.0".to_string(),
421            id: "test-item".to_string(),
422            geometry: None,
423            bbox: Some(vec![-180.0, -90.0, 180.0, 90.0]),
424            properties,
425            links: vec![],
426            assets: HashMap::new(),
427            collection: Some("test-collection".to_string()),
428            extra: HashMap::new(),
429        };
430
431        let json_str = serde_json::to_string(&item).unwrap();
432        let deserialized: Item = serde_json::from_str(&json_str).unwrap();
433
434        assert_eq!(item.id, deserialized.id);
435        assert_eq!(item.collection, deserialized.collection);
436    }
437
438    #[test]
439    fn test_search_params_default() {
440        let params = SearchParams::default();
441        assert!(params.limit.is_none());
442        assert!(params.bbox.is_none());
443        assert!(params.collections.is_none());
444    }
445
446    #[test]
447    fn test_sort_direction_serialization() {
448        let asc = SortDirection::Asc;
449        let desc = SortDirection::Desc;
450
451        assert_eq!(serde_json::to_string(&asc).unwrap(), "\"asc\"");
452        assert_eq!(serde_json::to_string(&desc).unwrap(), "\"desc\"");
453    }
454
455    #[test]
456    fn test_downloaded_asset_save() {
457        let asset = DownloadedAsset {
458            content: b"test data".to_vec(),
459        };
460        let dir = tempfile::tempdir().unwrap();
461        let file_path = dir.path().join("test.txt");
462        asset.save(&file_path).unwrap();
463        let content = std::fs::read_to_string(file_path).unwrap();
464        assert_eq!(content, "test data");
465    }
466}