Skip to main content

qualia_core_db/domains/geospatial/adapters/
mod.rs

1//! Geospatial data adapters for integrating external layers into the canvas.
2
3pub mod astrometry_adapter;
4pub mod ckan_adapter;
5pub mod dem_adapter;
6pub mod gbif_adapter;
7pub mod ivoa_tap_adapter;
8pub mod ogc_3d_tiles;
9pub mod opendap_adapter;
10pub mod osm_adapter;
11pub mod sparql_adapter;
12pub mod stac_adapter;
13pub mod wms_adapter;
14
15pub mod canvas_defaults;
16
17use std::collections::HashMap;
18
19use crate::net::disclosure::NetworkDisclosureRegistry;
20
21pub use astrometry_adapter::AstrometryAdapter;
22pub use ckan_adapter::CkanAdapter;
23pub use dem_adapter::DemAdapter;
24pub use gbif_adapter::GbifAdapter;
25pub use ivoa_tap_adapter::IvoaTapAdapter;
26pub use ogc_3d_tiles::Ogc3dTilesAdapter;
27pub use opendap_adapter::OpendapAdapter;
28pub use osm_adapter::OsmAdapter;
29pub use sparql_adapter::SparqlAdapter;
30pub use stac_adapter::StacAdapter;
31pub use wms_adapter::WmsAdapter;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum AdapterHttpMethod {
35    Get,
36    Post,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct AdapterHttpRequest {
41    pub method: AdapterHttpMethod,
42    pub url: String,
43    pub body: Option<String>,
44    pub content_type: Option<&'static str>,
45    pub service_label: &'static str,
46}
47
48impl AdapterHttpRequest {
49    pub fn get(url: String, service_label: &'static str) -> Self {
50        Self {
51            method: AdapterHttpMethod::Get,
52            url,
53            body: None,
54            content_type: None,
55            service_label,
56        }
57    }
58
59    pub fn post_form(url: String, body: String, service_label: &'static str) -> Self {
60        Self {
61            method: AdapterHttpMethod::Post,
62            url,
63            body: Some(body),
64            content_type: Some("application/x-www-form-urlencoded"),
65            service_label,
66        }
67    }
68}
69
70#[cfg(not(target_arch = "wasm32"))]
71fn execute_http_request_text(request: &AdapterHttpRequest) -> Result<String, String> {
72    let client = reqwest::blocking::Client::new();
73    let mut builder = match request.method {
74        AdapterHttpMethod::Get => client.get(&request.url),
75        AdapterHttpMethod::Post => client.post(&request.url),
76    };
77    if let Some(content_type) = request.content_type {
78        builder = builder.header("Content-Type", content_type);
79    }
80    if let Some(body) = &request.body {
81        builder = builder.body(body.clone());
82    }
83
84    let resp = builder.send().map_err(|e| e.to_string())?;
85    if !resp.status().is_success() {
86        return Err(format!(
87            "{} API returned error: {}",
88            request.service_label,
89            resp.status()
90        ));
91    }
92    resp.text().map_err(|e| e.to_string())
93}
94
95#[cfg(not(target_arch = "wasm32"))]
96fn execute_http_request_status(request: &AdapterHttpRequest) -> Result<(), String> {
97    execute_http_request_text(request).map(|_| ())
98}
99
100#[cfg(target_arch = "wasm32")]
101async fn execute_http_request_text_async(request: &AdapterHttpRequest) -> Result<String, String> {
102    let client = reqwest::Client::new();
103    let mut builder = match request.method {
104        AdapterHttpMethod::Get => client.get(&request.url),
105        AdapterHttpMethod::Post => client.post(&request.url),
106    };
107    if let Some(content_type) = request.content_type {
108        builder = builder.header("Content-Type", content_type);
109    }
110    if let Some(body) = &request.body {
111        builder = builder.body(body.clone());
112    }
113
114    let resp = builder.send().await.map_err(|e| e.to_string())?;
115    let status = resp.status();
116    if !status.is_success() {
117        return Err(format!(
118            "{} API returned error: {status}",
119            request.service_label
120        ));
121    }
122    resp.text().await.map_err(|e| e.to_string())
123}
124
125#[cfg(target_arch = "wasm32")]
126fn execute_http_request_status(_request: &AdapterHttpRequest) -> Result<(), String> {
127    Err(
128        "Synchronous geospatial HTTP is unavailable on wasm32; call fetch_region_async instead"
129            .to_string(),
130    )
131}
132
133#[cfg(target_arch = "wasm32")]
134fn execute_http_request_text(_request: &AdapterHttpRequest) -> Result<String, String> {
135    Err(
136        "Synchronous geospatial HTTP is unavailable on wasm32; call fetch_region_async instead"
137            .to_string(),
138    )
139}
140
141#[cfg(target_arch = "wasm32")]
142pub async fn fetch_region_async(
143    adapter: &dyn DataAdapter,
144    bbox: (f64, f64, f64, f64),
145    time_range: (u64, u64),
146    registry: &NetworkDisclosureRegistry,
147) -> Result<(), String> {
148    let request = adapter.build_fetch_request(bbox, time_range, registry)?;
149    if adapter.needs_fetch_body() {
150        let body = execute_http_request_text_async(&request).await?;
151        adapter.handle_fetch_body(&body)
152    } else {
153        execute_http_request_text_async(&request).await.map(|_| ())
154    }
155}
156
157pub trait DataAdapter {
158    /// Returns the unique identifier for this adapter (e.g., "dem_adapter")
159    fn adapter_id(&self) -> &'static str;
160
161    /// Build the outbound request for a spatial bounding box [x1, y1, x2, y2]
162    /// and temporal range [t0, t1]. Implementations must check `NetworkDisclosureRegistry`
163    /// before returning an actual network request.
164    fn build_fetch_request(
165        &self,
166        bbox: (f64, f64, f64, f64),
167        time_range: (u64, u64),
168        registry: &NetworkDisclosureRegistry,
169    ) -> Result<AdapterHttpRequest, String>;
170
171    /// Initiate fetching through the native blocking transport. Browser WASM
172    /// cannot legally block on network I/O, so wasm callers use `fetch_region_async`.
173    fn fetch_region(
174        &self,
175        bbox: (f64, f64, f64, f64),
176        time_range: (u64, u64),
177        registry: &NetworkDisclosureRegistry,
178    ) -> Result<(), String> {
179        let request = self.build_fetch_request(bbox, time_range, registry)?;
180        if self.needs_fetch_body() {
181            let body = execute_http_request_text(&request)?;
182            self.handle_fetch_body(&body)
183        } else {
184            execute_http_request_status(&request)
185        }
186    }
187
188    /// Whether the adapter consumes the response body instead of only checking
189    /// that the endpoint accepted the request.
190    fn needs_fetch_body(&self) -> bool {
191        false
192    }
193
194    /// Parse or enqueue the fetched body. Most adapters currently only verify
195    /// access; CKAN consumes JSON discovery results here.
196    fn handle_fetch_body(&self, _body: &str) -> Result<(), String> {
197        Ok(())
198    }
199
200    /// Parse a fetched response body into provenance `NQuin`s (title / license /
201    /// created / source / lat / long, hashed with the same `generate_60bit_token`
202    /// the SPARQL layer uses, so the results are queryable). Adapters that can
203    /// interpret their response format override this — GBIF, OSM/Overpass, STAC,
204    /// OGC 3D Tiles and SPARQL-results do — and the default yields none. This is
205    /// how a fetched response becomes graph data rather than being discarded.
206    fn parse_response(&self, _body: &str) -> Result<Vec<crate::NQuin>, String> {
207        Ok(Vec::new())
208    }
209
210    /// Native fetch that returns the parsed provenance `NQuin`s: build the
211    /// consent-gated request, execute it, and run `parse_response` on the body.
212    /// A caller can then route the quins into the graph. (WASM callers fetch via
213    /// `fetch_region_async` and call `parse_response` on the returned body.)
214    fn fetch_region_features(
215        &self,
216        bbox: (f64, f64, f64, f64),
217        time_range: (u64, u64),
218        registry: &NetworkDisclosureRegistry,
219    ) -> Result<Vec<crate::NQuin>, String> {
220        let request = self.build_fetch_request(bbox, time_range, registry)?;
221        let body = execute_http_request_text(&request)?;
222        self.parse_response(&body)
223    }
224
225    /// Primary egress endpoint for disclosure checks and fetch reports.
226    fn primary_endpoint(&self) -> &str;
227
228    /// Honest estimate of how many tile/API units would be requested (no fake payloads).
229    fn estimate_tile_count(&self, bbox: (f64, f64, f64, f64)) -> u32;
230}
231
232impl DataAdapter for DemAdapter {
233    fn adapter_id(&self) -> &'static str {
234        "dem_adapter"
235    }
236
237    fn build_fetch_request(
238        &self,
239        bbox: (f64, f64, f64, f64),
240        time_range: (u64, u64),
241        registry: &NetworkDisclosureRegistry,
242    ) -> Result<AdapterHttpRequest, String> {
243        DemAdapter::build_fetch_request(self, bbox, time_range, registry)
244    }
245
246    fn primary_endpoint(&self) -> &str {
247        &self.endpoint
248    }
249
250    fn estimate_tile_count(&self, bbox: (f64, f64, f64, f64)) -> u32 {
251        estimate_raster_tiles(bbox, 14)
252    }
253}
254
255impl DataAdapter for OsmAdapter {
256    fn adapter_id(&self) -> &'static str {
257        "osm_adapter"
258    }
259
260    fn build_fetch_request(
261        &self,
262        bbox: (f64, f64, f64, f64),
263        time_range: (u64, u64),
264        registry: &NetworkDisclosureRegistry,
265    ) -> Result<AdapterHttpRequest, String> {
266        OsmAdapter::build_fetch_request(self, bbox, time_range, registry)
267    }
268
269    fn primary_endpoint(&self) -> &str {
270        &self.overpass_endpoint
271    }
272
273    fn estimate_tile_count(&self, bbox: (f64, f64, f64, f64)) -> u32 {
274        // Overpass = 1 query + MVT tiles at z15
275        1 + estimate_raster_tiles(bbox, 15)
276    }
277}
278
279impl DataAdapter for WmsAdapter {
280    fn adapter_id(&self) -> &'static str {
281        "wms_adapter"
282    }
283
284    fn build_fetch_request(
285        &self,
286        bbox: (f64, f64, f64, f64),
287        time_range: (u64, u64),
288        registry: &NetworkDisclosureRegistry,
289    ) -> Result<AdapterHttpRequest, String> {
290        WmsAdapter::build_fetch_request(self, bbox, time_range, registry)
291    }
292
293    fn primary_endpoint(&self) -> &str {
294        &self.endpoint
295    }
296
297    fn estimate_tile_count(&self, bbox: (f64, f64, f64, f64)) -> u32 {
298        // WMS GetMap: one request per 256px tile at typical viewport scale
299        estimate_raster_tiles(bbox, 12)
300    }
301}
302
303impl DataAdapter for GbifAdapter {
304    fn adapter_id(&self) -> &'static str {
305        "gbif_adapter"
306    }
307
308    fn build_fetch_request(
309        &self,
310        bbox: (f64, f64, f64, f64),
311        time_range: (u64, u64),
312        registry: &NetworkDisclosureRegistry,
313    ) -> Result<AdapterHttpRequest, String> {
314        GbifAdapter::build_fetch_request(self, bbox, time_range, registry)
315    }
316
317    fn primary_endpoint(&self) -> &str {
318        &self.occurrence_endpoint
319    }
320
321    fn estimate_tile_count(&self, bbox: (f64, f64, f64, f64)) -> u32 {
322        // GBIF paginates at 300 records/page; estimate pages from bbox area (deg²).
323        let area = (bbox.2 - bbox.0).abs() * (bbox.3 - bbox.1).abs();
324        let pages = (area * 10.0).ceil() as u32;
325        pages.max(1)
326    }
327}
328
329/// Status of a layer fetch plan — honest, no fabricated payloads.
330#[derive(Debug, Clone, PartialEq, Eq)]
331pub enum LayerFetchStatus {
332    /// Egress consent not registered; fail-closed.
333    ConsentDenied,
334    /// Consent granted; fetch would proceed (stub — no network I/O performed).
335    ReadyToFetch,
336    /// Adapter id not found in registry.
337    AdapterNotFound,
338}
339
340/// Structured report describing what a layer fetch *would* request.
341#[derive(Debug, Clone, PartialEq)]
342pub struct LayerFetchReport {
343    pub adapter_id: String,
344    pub endpoint: String,
345    pub bbox: (f64, f64, f64, f64),
346    pub time_range: (u64, u64),
347    pub estimated_tile_count: u32,
348    pub status: LayerFetchStatus,
349}
350
351/// Holds registered geospatial adapters and the network disclosure registry.
352pub struct AdapterRegistry {
353    adapters: HashMap<String, Box<dyn DataAdapter>>,
354    disclosure: NetworkDisclosureRegistry,
355}
356
357impl AdapterRegistry {
358    pub fn new() -> Self {
359        Self {
360            adapters: HashMap::new(),
361            disclosure: NetworkDisclosureRegistry::new(),
362        }
363    }
364
365    pub fn disclosure_registry(&self) -> &NetworkDisclosureRegistry {
366        &self.disclosure
367    }
368
369    pub fn disclosure_registry_mut(&mut self) -> &mut NetworkDisclosureRegistry {
370        &mut self.disclosure
371    }
372
373    pub fn register_adapter(&mut self, adapter: Box<dyn DataAdapter>) {
374        let id = adapter.adapter_id().to_string();
375        self.adapters.insert(id, adapter);
376    }
377
378    /// Describe what would be fetched for a layer. Performs consent check; does not
379    /// initiate network I/O or return fabricated data.
380    pub fn fetch_layer(
381        &self,
382        adapter_id: &str,
383        bbox: (f64, f64, f64, f64),
384        time_range: (u64, u64),
385    ) -> Result<LayerFetchReport, String> {
386        let adapter = self
387            .adapters
388            .get(adapter_id)
389            .ok_or_else(|| format!("Adapter '{}' not registered", adapter_id))?;
390
391        let endpoint = adapter.primary_endpoint().to_string();
392        let tile_count = adapter.estimate_tile_count(bbox);
393
394        let status = if self.disclosure.check_egress_consent(adapter_id, &endpoint) {
395            LayerFetchStatus::ReadyToFetch
396        } else {
397            LayerFetchStatus::ConsentDenied
398        };
399
400        Ok(LayerFetchReport {
401            adapter_id: adapter_id.to_string(),
402            endpoint,
403            bbox,
404            time_range,
405            estimated_tile_count: tile_count,
406            status,
407        })
408    }
409}
410
411impl Default for AdapterRegistry {
412    fn default() -> Self {
413        let mut registry = Self::new();
414        canvas_defaults::register_canvas_defaults(&mut registry);
415        registry
416    }
417}
418
419/// Rough Web-Mercator tile count estimate from a lon/lat bbox at a given zoom.
420fn estimate_raster_tiles(bbox: (f64, f64, f64, f64), zoom: u8) -> u32 {
421    let (x1, y1, x2, y2) = bbox;
422    let n = 1u32 << zoom;
423    let tx1 = ((x1 + 180.0) / 360.0 * n as f64).floor() as u32;
424    let tx2 = ((x2 + 180.0) / 360.0 * n as f64).ceil() as u32;
425    let ty1 = ((1.0
426        - (y2.to_radians().tan() + 1.0 / y2.to_radians().cos()).ln() / std::f64::consts::PI)
427        / 2.0
428        * n as f64)
429        .floor() as u32;
430    let ty2 = ((1.0
431        - (y1.to_radians().tan() + 1.0 / y1.to_radians().cos()).ln() / std::f64::consts::PI)
432        / 2.0
433        * n as f64)
434        .ceil() as u32;
435    let tiles_x = tx2.saturating_sub(tx1).max(1);
436    let tiles_y = ty2.saturating_sub(ty1).max(1);
437    tiles_x.saturating_mul(tiles_y)
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_adapter_registry_consent_denied() {
446        let mut registry = AdapterRegistry::new();
447        registry.register_adapter(Box::new(DemAdapter::new(
448            "dem_adapter",
449            "https://elevation.example.com",
450        )));
451
452        let report = registry
453            .fetch_layer("dem_adapter", (0.0, 0.0, 1.0, 1.0), (0, 0))
454            .expect("report");
455
456        assert_eq!(report.status, LayerFetchStatus::ConsentDenied);
457        assert_eq!(report.endpoint, "https://elevation.example.com");
458        assert!(report.estimated_tile_count >= 1);
459    }
460
461    #[test]
462    fn test_adapter_registry_consent_granted() {
463        let mut registry = AdapterRegistry::new();
464        registry.register_adapter(Box::new(OsmAdapter::new(
465            "osm_adapter",
466            "https://overpass-api.de/api/interpreter",
467            "https://tiles.example.com/osm",
468        )));
469        registry.disclosure_registry_mut().register_egress(
470            "osm_adapter",
471            "https://overpass-api.de/api/interpreter",
472            "Fetch OSM features",
473            "User pans map",
474        );
475
476        let report = registry
477            .fetch_layer("osm_adapter", (-0.1, 51.4, 0.1, 51.6), (0, 0))
478            .expect("report");
479
480        assert_eq!(report.status, LayerFetchStatus::ReadyToFetch);
481        assert!(report.estimated_tile_count >= 1);
482    }
483
484    #[test]
485    fn test_adapter_registry_unknown_adapter() {
486        let registry = AdapterRegistry::new();
487        let err = registry
488            .fetch_layer("unknown_adapter", (0.0, 0.0, 1.0, 1.0), (0, 0))
489            .unwrap_err();
490        assert!(err.contains("not registered"));
491    }
492}