Skip to main content

oxigdal_services/ogc_features/
server.rs

1//! OGC Features API server request handler.
2
3use chrono::Utc;
4
5use super::error::FeaturesError;
6use super::query::QueryParams;
7use super::types::{
8    Collection, Collections, ConformanceClasses, Feature, FeatureCollection, LandingPage, Link,
9};
10
11/// Maximum features per request the server will honour
12pub const MAX_LIMIT: u32 = 10_000;
13
14/// Stateless request handler for OGC API - Features endpoints
15pub struct FeaturesServer {
16    /// Service title
17    pub title: String,
18    /// Service description
19    pub description: String,
20    /// Base URL (e.g. `"https://example.com/ogcapi"`)
21    pub base_url: String,
22    /// Registered collections
23    pub collections: Vec<Collection>,
24}
25
26impl FeaturesServer {
27    /// Create a new server with the given title and base URL.
28    pub fn new(title: impl Into<String>, base_url: impl Into<String>) -> Self {
29        Self {
30            title: title.into(),
31            description: String::new(),
32            base_url: base_url.into(),
33            collections: vec![],
34        }
35    }
36
37    /// Register a collection.
38    pub fn add_collection(&mut self, collection: Collection) {
39        self.collections.push(collection);
40    }
41
42    /// Build the landing page response.
43    pub fn landing_page(&self) -> LandingPage {
44        let base = &self.base_url;
45        LandingPage {
46            title: self.title.clone(),
47            description: if self.description.is_empty() {
48                None
49            } else {
50                Some(self.description.clone())
51            },
52            links: vec![
53                Link::new(base.clone(), "self")
54                    .with_type("application/json")
55                    .with_title("This document"),
56                Link::new(format!("{base}/api"), "service-desc")
57                    .with_type("application/vnd.oai.openapi+json;version=3.0")
58                    .with_title("The API definition"),
59                Link::new(format!("{base}/conformance"), "conformance")
60                    .with_type("application/json")
61                    .with_title("Conformance classes"),
62                Link::new(format!("{base}/collections"), "data")
63                    .with_type("application/json")
64                    .with_title("Access the data"),
65            ],
66        }
67    }
68
69    /// Build the conformance declaration response (Part 1 + Part 2).
70    pub fn conformance(&self) -> ConformanceClasses {
71        ConformanceClasses::with_crs()
72    }
73
74    /// Build the collections listing response.
75    pub fn list_collections(&self) -> Collections {
76        let base = &self.base_url;
77        Collections {
78            links: vec![
79                Link::new(format!("{base}/collections"), "self")
80                    .with_type("application/json")
81                    .with_title("Collections"),
82            ],
83            collections: self.collections.clone(),
84        }
85    }
86
87    /// Look up a collection by id.
88    pub fn get_collection(&self, id: &str) -> Option<&Collection> {
89        self.collections.iter().find(|c| c.id == id)
90    }
91
92    /// Build a paginated `FeatureCollection` response.
93    ///
94    /// The caller supplies the full `features` vector (already filtered but not
95    /// yet paginated) together with `total_matched` (which may differ when
96    /// server-side filtering is applied outside this function).
97    ///
98    /// This function:
99    /// 1. Validates the limit against `MAX_LIMIT`.
100    /// 2. Returns `FeaturesError::CollectionNotFound` if the collection is
101    ///    not registered.
102    /// 3. Applies offset / limit slicing.
103    /// 4. Attaches `numberMatched`, `numberReturned`, and `timeStamp`.
104    /// 5. Attaches `next` and `prev` pagination links.
105    pub fn build_items_response(
106        &self,
107        collection_id: &str,
108        features: Vec<Feature>,
109        params: &QueryParams,
110        total_matched: Option<u64>,
111    ) -> Result<FeatureCollection, FeaturesError> {
112        // Validate collection exists
113        if self.get_collection(collection_id).is_none() {
114            return Err(FeaturesError::CollectionNotFound(collection_id.to_string()));
115        }
116
117        let limit = params.effective_limit();
118        if limit > MAX_LIMIT {
119            return Err(FeaturesError::LimitExceeded {
120                requested: limit,
121                max: MAX_LIMIT,
122            });
123        }
124
125        let offset = params.effective_offset() as usize;
126        let limit_usize = limit as usize;
127        let total = features.len();
128
129        // Apply pagination slice
130        let page: Vec<Feature> = features
131            .into_iter()
132            .skip(offset)
133            .take(limit_usize)
134            .collect();
135
136        let number_returned = page.len() as u64;
137        let number_matched = total_matched.unwrap_or(total as u64);
138
139        // Build pagination links
140        let base = &self.base_url;
141        let items_base = format!("{base}/collections/{collection_id}/items");
142        let mut links: Vec<Link> = vec![
143            Link::new(
144                format!("{items_base}?limit={limit}&offset={offset}"),
145                "self",
146            )
147            .with_type("application/geo+json")
148            .with_title("This page"),
149        ];
150
151        // next link
152        let next_offset = offset + limit_usize;
153        if next_offset < total {
154            links.push(
155                Link::new(
156                    format!("{items_base}?limit={limit}&offset={next_offset}"),
157                    "next",
158                )
159                .with_type("application/geo+json")
160                .with_title("Next page"),
161            );
162        }
163
164        // prev link
165        if offset > 0 {
166            let prev_offset = offset.saturating_sub(limit_usize);
167            links.push(
168                Link::new(
169                    format!("{items_base}?limit={limit}&offset={prev_offset}"),
170                    "prev",
171                )
172                .with_type("application/geo+json")
173                .with_title("Previous page"),
174            );
175        }
176
177        Ok(FeatureCollection {
178            type_: "FeatureCollection".to_string(),
179            features: page,
180            links: Some(links),
181            time_stamp: Some(Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()),
182            number_matched: Some(number_matched),
183            number_returned: Some(number_returned),
184        })
185    }
186}