Skip to main content

oxigdal_services/ogc_features/
types.rs

1//! Core data structures for the OGC Features API.
2
3use serde::{Deserialize, Serialize};
4
5use super::error::FeaturesError;
6
7/// OGC link object used throughout the API responses
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub struct Link {
10    /// Target URI
11    pub href: String,
12
13    /// Link relation type (e.g. `self`, `next`, `alternate`)
14    pub rel: String,
15
16    /// MIME type of the target resource
17    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
18    pub type_: Option<String>,
19
20    /// Human-readable label
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub title: Option<String>,
23
24    /// Language tag of the target resource
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub hreflang: Option<String>,
27}
28
29impl Link {
30    /// Construct a minimal link with href and rel.
31    pub fn new(href: impl Into<String>, rel: impl Into<String>) -> Self {
32        Self {
33            href: href.into(),
34            rel: rel.into(),
35            type_: None,
36            title: None,
37            hreflang: None,
38        }
39    }
40
41    /// Set the MIME type.
42    pub fn with_type(mut self, t: impl Into<String>) -> Self {
43        self.type_ = Some(t.into());
44        self
45    }
46
47    /// Set the human-readable title.
48    pub fn with_title(mut self, title: impl Into<String>) -> Self {
49        self.title = Some(title.into());
50        self
51    }
52}
53
54/// Root landing page for the OGC Features API
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct LandingPage {
57    /// Service title
58    pub title: String,
59
60    /// Optional description
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub description: Option<String>,
63
64    /// Links to the API resources
65    pub links: Vec<Link>,
66}
67
68/// OGC conformance declaration
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct ConformanceClasses {
72    /// List of conformance class URIs
73    pub conforms_to: Vec<String>,
74}
75
76impl ConformanceClasses {
77    /// Return conformance classes for OGC API - Features Part 1 (Core).
78    pub fn ogc_features_core() -> Self {
79        Self {
80            conforms_to: vec![
81                "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core".to_string(),
82                "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30".to_string(),
83                "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/html".to_string(),
84                "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson".to_string(),
85            ],
86        }
87    }
88
89    /// Return conformance classes for Part 1 + Part 2 (CRS).
90    pub fn with_crs() -> Self {
91        let mut base = Self::ogc_features_core();
92        base.conforms_to
93            .push("http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs".to_string());
94        base
95    }
96}
97
98/// Spatial extent of a collection
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SpatialExtent {
101    /// One or more bounding boxes `[xmin, ymin, xmax, ymax]`
102    pub bbox: Vec<[f64; 4]>,
103
104    /// CRS URI for the bbox coordinates (defaults to CRS84)
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub crs: Option<String>,
107}
108
109/// Temporal extent of a collection
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct TemporalExtent {
112    /// One or more intervals `[start, end]`; `null` means open-ended
113    pub interval: Vec<[Option<String>; 2]>,
114
115    /// Temporal reference system URI
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub trs: Option<String>,
118}
119
120/// Combined spatial and temporal extent
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct Extent {
123    /// Spatial component
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub spatial: Option<SpatialExtent>,
126
127    /// Temporal component
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub temporal: Option<TemporalExtent>,
130}
131
132/// Metadata about a single feature collection
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct Collection {
136    /// Unique identifier for the collection
137    pub id: String,
138
139    /// Human-readable title
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub title: Option<String>,
142
143    /// Human-readable description
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub description: Option<String>,
146
147    /// Links to related resources
148    pub links: Vec<Link>,
149
150    /// Spatial/temporal extent
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub extent: Option<Extent>,
153
154    /// Type of items in the collection (usually `"feature"`)
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub item_type: Option<String>,
157
158    /// List of supported CRS URIs (Part 2)
159    #[serde(default)]
160    pub crs: Vec<String>,
161
162    /// Native/storage CRS URI (Part 2)
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub storage_crs: Option<String>,
165}
166
167impl Collection {
168    /// Create a minimal collection with an id.
169    pub fn new(id: impl Into<String>) -> Self {
170        Self {
171            id: id.into(),
172            title: None,
173            description: None,
174            links: vec![],
175            extent: None,
176            item_type: Some("feature".to_string()),
177            crs: vec![],
178            storage_crs: None,
179        }
180    }
181}
182
183/// List of collections with navigational links
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct Collections {
186    /// Top-level links (e.g. self)
187    pub links: Vec<Link>,
188
189    /// The actual collection metadata records
190    pub collections: Vec<Collection>,
191}
192
193/// Feature identifier — may be a string or an integer
194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
195#[serde(untagged)]
196pub enum FeatureId {
197    /// String identifier
198    String(String),
199    /// Integer identifier
200    Integer(i64),
201}
202
203/// A GeoJSON Feature with optional OGC links
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct Feature {
206    /// Always `"Feature"`
207    #[serde(rename = "type")]
208    pub type_: String,
209
210    /// Optional feature identifier
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub id: Option<FeatureId>,
213
214    /// Geometry object (null if geometry-less)
215    pub geometry: Option<serde_json::Value>,
216
217    /// Properties object
218    pub properties: Option<serde_json::Value>,
219
220    /// OGC addition: links for this feature
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub links: Option<Vec<Link>>,
223}
224
225impl Feature {
226    /// Create a feature with default `type_` = `"Feature"`.
227    pub fn new() -> Self {
228        Self {
229            type_: "Feature".to_string(),
230            id: None,
231            geometry: None,
232            properties: None,
233            links: None,
234        }
235    }
236}
237
238impl Default for Feature {
239    fn default() -> Self {
240        Self::new()
241    }
242}
243
244/// A GeoJSON FeatureCollection with OGC pagination metadata
245#[derive(Debug, Clone, Serialize, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct FeatureCollection {
248    /// Always `"FeatureCollection"`
249    #[serde(rename = "type")]
250    pub type_: String,
251
252    /// Features in this page
253    pub features: Vec<Feature>,
254
255    /// OGC navigation links (next, prev, self, …)
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub links: Option<Vec<Link>>,
258
259    /// ISO 8601 timestamp when the response was generated
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub time_stamp: Option<String>,
262
263    /// Total number of features matching the query (before pagination)
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub number_matched: Option<u64>,
266
267    /// Number of features in this response page
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub number_returned: Option<u64>,
270}
271
272impl FeatureCollection {
273    /// Create an empty FeatureCollection.
274    pub fn new() -> Self {
275        Self {
276            type_: "FeatureCollection".to_string(),
277            features: vec![],
278            links: None,
279            time_stamp: None,
280            number_matched: None,
281            number_returned: None,
282        }
283    }
284}
285
286impl Default for FeatureCollection {
287    fn default() -> Self {
288        Self::new()
289    }
290}
291
292/// Parsed datetime filter from the `datetime` query parameter
293#[derive(Debug, Clone, PartialEq)]
294pub enum DateTimeFilter {
295    /// A single point in time: `"2021-04-22T00:00:00Z"`
296    Instant(String),
297    /// A time interval: `[start, end]` where `None` means open-ended
298    Interval(Option<String>, Option<String>),
299}
300
301impl DateTimeFilter {
302    /// Parse a datetime query parameter value into a `DateTimeFilter`.
303    ///
304    /// Accepted formats:
305    /// - `"2021-04-22T00:00:00Z"` → `Instant`
306    /// - `"../2021-01-01T00:00:00Z"` → `Interval(None, Some(...))`
307    /// - `"2021-01-01T00:00:00Z/.."` → `Interval(Some(...), None)`
308    /// - `"2021-01-01T00:00:00Z/2021-12-31T23:59:59Z"` → `Interval(Some, Some)`
309    pub fn parse(s: &str) -> Result<Self, FeaturesError> {
310        if s.is_empty() {
311            return Err(FeaturesError::InvalidDatetime(
312                "datetime value is empty".to_string(),
313            ));
314        }
315
316        if let Some(slash_pos) = s.find('/') {
317            let start_str = &s[..slash_pos];
318            let end_str = &s[slash_pos + 1..];
319
320            let start = if start_str == ".." || start_str.is_empty() {
321                None
322            } else {
323                Some(start_str.to_string())
324            };
325            let end = if end_str == ".." || end_str.is_empty() {
326                None
327            } else {
328                Some(end_str.to_string())
329            };
330
331            Ok(DateTimeFilter::Interval(start, end))
332        } else {
333            Ok(DateTimeFilter::Instant(s.to_string()))
334        }
335    }
336}