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}