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#[cfg(test)]
333mod tests {
334    use super::*;
335    use serde_json::json;
336
337    #[test]
338    fn test_catalog_serialization() {
339        let catalog = Catalog {
340            catalog_type: "Catalog".to_string(),
341            stac_version: "1.0.0".to_string(),
342            id: "test-catalog".to_string(),
343            title: Some("Test Catalog".to_string()),
344            description: "A test catalog".to_string(),
345            links: vec![],
346            extra: HashMap::new(),
347        };
348
349        let json_str = serde_json::to_string(&catalog).unwrap();
350        let deserialized: Catalog = serde_json::from_str(&json_str).unwrap();
351
352        assert_eq!(catalog.id, deserialized.id);
353        assert_eq!(catalog.stac_version, deserialized.stac_version);
354    }
355
356    #[test]
357    fn test_collection_serialization() {
358        let collection = Collection {
359            collection_type: "Collection".to_string(),
360            stac_version: "1.0.0".to_string(),
361            id: "test-collection".to_string(),
362            title: Some("Test Collection".to_string()),
363            description: "A test collection".to_string(),
364            keywords: Some(vec!["test".to_string()]),
365            license: "MIT".to_string(),
366            providers: None,
367            extent: Extent {
368                spatial: SpatialExtent {
369                    bbox: vec![vec![-180.0, -90.0, 180.0, 90.0]],
370                },
371                temporal: TemporalExtent {
372                    interval: vec![vec![None, None]],
373                },
374            },
375            summaries: None,
376            links: vec![],
377            assets: None,
378            extra: HashMap::new(),
379        };
380
381        let json_str = serde_json::to_string(&collection).unwrap();
382        let deserialized: Collection = serde_json::from_str(&json_str).unwrap();
383
384        assert_eq!(collection.id, deserialized.id);
385        assert_eq!(collection.license, deserialized.license);
386    }
387
388    #[test]
389    fn test_item_serialization() {
390        let mut properties = HashMap::new();
391        properties.insert("datetime".to_string(), json!("2023-01-01T12:00:00Z"));
392
393        let item = Item {
394            item_type: "Feature".to_string(),
395            stac_version: "1.0.0".to_string(),
396            id: "test-item".to_string(),
397            geometry: None,
398            bbox: Some(vec![-180.0, -90.0, 180.0, 90.0]),
399            properties,
400            links: vec![],
401            assets: HashMap::new(),
402            collection: Some("test-collection".to_string()),
403            extra: HashMap::new(),
404        };
405
406        let json_str = serde_json::to_string(&item).unwrap();
407        let deserialized: Item = serde_json::from_str(&json_str).unwrap();
408
409        assert_eq!(item.id, deserialized.id);
410        assert_eq!(item.collection, deserialized.collection);
411    }
412
413    #[test]
414    fn test_search_params_default() {
415        let params = SearchParams::default();
416        assert!(params.limit.is_none());
417        assert!(params.bbox.is_none());
418        assert!(params.collections.is_none());
419    }
420
421    #[test]
422    fn test_sort_direction_serialization() {
423        let asc = SortDirection::Asc;
424        let desc = SortDirection::Desc;
425
426        assert_eq!(serde_json::to_string(&asc).unwrap(), "\"asc\"");
427        assert_eq!(serde_json::to_string(&desc).unwrap(), "\"desc\"");
428    }
429}