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/// Parameters for a STAC API search.
235///
236/// This struct is used with `SearchBuilder` to construct a query for the
237/// `POST /search` or `GET /search` endpoints.
238#[derive(Debug, Clone, Default, Serialize)]
239pub struct SearchParams {
240    /// The maximum number of items to return.
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub limit: Option<u32>,
243    /// A bounding box to filter items by.
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub bbox: Option<Vec<f64>>,
246    /// A datetime string or interval to filter items by.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub datetime: Option<String>,
249    /// A `GeoJSON` geometry to filter items by spatial intersection.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub intersects: Option<JsonValue>,
252    /// A list of collection IDs to search within.
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub collections: Option<Vec<String>>,
255    /// A list of item IDs to retrieve.
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub ids: Option<Vec<String>>,
258    /// A map of query expressions for filtering item properties.
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub query: Option<HashMap<String, JsonValue>>,
261    /// A list of sorting rules.
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub sortby: Option<Vec<SortBy>>,
264    /// A filter for including or excluding specific fields.
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub fields: Option<FieldsFilter>,
267}
268
269/// A sorting rule for a STAC API search.
270///
271/// See the [STAC API Sort Extension](https://github.com/radiantearth/stac-api-spec/tree/v1.0.0/fragments/sort)
272/// for details.
273#[derive(Debug, Clone, Serialize)]
274pub struct SortBy {
275    /// The property name to sort by.
276    pub field: String,
277    /// The sort direction.
278    pub direction: SortDirection,
279}
280
281/// The direction for a sort rule.
282#[derive(Debug, Clone, Serialize)]
283#[serde(rename_all = "lowercase")]
284pub enum SortDirection {
285    /// Sort in ascending order.
286    Asc,
287    /// Sort in descending order.
288    Desc,
289}
290
291/// A filter for including or excluding fields in a STAC API search response.
292///
293/// See the [STAC API Fields Extension](https://github.com/radiantearth/stac-api-spec/tree/v1.0.0/fragments/fields)
294/// for details.
295#[derive(Debug, Clone, Default, Serialize)]
296pub struct FieldsFilter {
297    /// A list of fields to include in the response.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub include: Option<Vec<String>>,
300    /// A list of fields to exclude from the response.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub exclude: Option<Vec<String>>,
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use serde_json::json;
309
310    #[test]
311    fn test_catalog_serialization() {
312        let catalog = Catalog {
313            catalog_type: "Catalog".to_string(),
314            stac_version: "1.0.0".to_string(),
315            id: "test-catalog".to_string(),
316            title: Some("Test Catalog".to_string()),
317            description: "A test catalog".to_string(),
318            links: vec![],
319            extra: HashMap::new(),
320        };
321
322        let json_str = serde_json::to_string(&catalog).unwrap();
323        let deserialized: Catalog = serde_json::from_str(&json_str).unwrap();
324
325        assert_eq!(catalog.id, deserialized.id);
326        assert_eq!(catalog.stac_version, deserialized.stac_version);
327    }
328
329    #[test]
330    fn test_collection_serialization() {
331        let collection = Collection {
332            collection_type: "Collection".to_string(),
333            stac_version: "1.0.0".to_string(),
334            id: "test-collection".to_string(),
335            title: Some("Test Collection".to_string()),
336            description: "A test collection".to_string(),
337            keywords: Some(vec!["test".to_string()]),
338            license: "MIT".to_string(),
339            providers: None,
340            extent: Extent {
341                spatial: SpatialExtent {
342                    bbox: vec![vec![-180.0, -90.0, 180.0, 90.0]],
343                },
344                temporal: TemporalExtent {
345                    interval: vec![vec![None, None]],
346                },
347            },
348            summaries: None,
349            links: vec![],
350            assets: None,
351            extra: HashMap::new(),
352        };
353
354        let json_str = serde_json::to_string(&collection).unwrap();
355        let deserialized: Collection = serde_json::from_str(&json_str).unwrap();
356
357        assert_eq!(collection.id, deserialized.id);
358        assert_eq!(collection.license, deserialized.license);
359    }
360
361    #[test]
362    fn test_item_serialization() {
363        let mut properties = HashMap::new();
364        properties.insert("datetime".to_string(), json!("2023-01-01T12:00:00Z"));
365
366        let item = Item {
367            item_type: "Feature".to_string(),
368            stac_version: "1.0.0".to_string(),
369            id: "test-item".to_string(),
370            geometry: None,
371            bbox: Some(vec![-180.0, -90.0, 180.0, 90.0]),
372            properties,
373            links: vec![],
374            assets: HashMap::new(),
375            collection: Some("test-collection".to_string()),
376            extra: HashMap::new(),
377        };
378
379        let json_str = serde_json::to_string(&item).unwrap();
380        let deserialized: Item = serde_json::from_str(&json_str).unwrap();
381
382        assert_eq!(item.id, deserialized.id);
383        assert_eq!(item.collection, deserialized.collection);
384    }
385
386    #[test]
387    fn test_search_params_default() {
388        let params = SearchParams::default();
389        assert!(params.limit.is_none());
390        assert!(params.bbox.is_none());
391        assert!(params.collections.is_none());
392    }
393
394    #[test]
395    fn test_sort_direction_serialization() {
396        let asc = SortDirection::Asc;
397        let desc = SortDirection::Desc;
398
399        assert_eq!(serde_json::to_string(&asc).unwrap(), "\"asc\"");
400        assert_eq!(serde_json::to_string(&desc).unwrap(), "\"desc\"");
401    }
402}