Skip to main content

qualia_core_db/domains/geospatial/adapters/
osm_adapter.rs

1use crate::domains::geospatial::adapters::AdapterHttpRequest;
2use crate::net::disclosure::NetworkDisclosureRegistry;
3
4/// Stub adapter for OpenStreetMap Overpass API and MVT vector tile endpoints.
5pub struct OsmAdapter {
6    pub id: &'static str,
7    pub overpass_endpoint: String,
8    pub tile_endpoint: String,
9}
10
11impl OsmAdapter {
12    pub fn new(id: &'static str, overpass_endpoint: &str, tile_endpoint: &str) -> Self {
13        Self {
14            id,
15            overpass_endpoint: overpass_endpoint.to_string(),
16            tile_endpoint: tile_endpoint.to_string(),
17        }
18    }
19}
20
21impl OsmAdapter {
22    pub fn adapter_id(&self) -> &'static str {
23        self.id
24    }
25
26    pub fn build_fetch_request(
27        &self,
28        bbox: (f64, f64, f64, f64),
29        time_range: (u64, u64),
30        registry: &NetworkDisclosureRegistry,
31    ) -> Result<AdapterHttpRequest, String> {
32        let primary = &self.overpass_endpoint;
33        if !registry.check_egress_consent(self.adapter_id(), primary) {
34            return Err(format!(
35                "Consent denied or unregistered for endpoint {} by adapter {}",
36                primary,
37                self.adapter_id()
38            ));
39        }
40
41        let date_filter = if time_range.1 > 0 {
42            format!("[date:\"{}\"]", time_range.1) // Stub: proper ISO8601 formatting required
43        } else {
44            String::new()
45        };
46
47        // Construct Overpass QL bounding box: (south, west, north, east)
48        let query = format!(
49            "{date_filter}[out:json];(node({s},{w},{n},{e});way({s},{w},{n},{e});relation({s},{w},{n},{e}););out body;",
50            date_filter = date_filter,
51            s = bbox.1,
52            w = bbox.0,
53            n = bbox.3,
54            e = bbox.2
55        );
56
57        Ok(AdapterHttpRequest::post_form(
58            primary.clone(),
59            query,
60            "OSM Overpass",
61        ))
62    }
63
64    pub fn fetch_region(
65        &self,
66        bbox: (f64, f64, f64, f64),
67        time_range: (u64, u64),
68        registry: &NetworkDisclosureRegistry,
69    ) -> Result<(), String> {
70        let request = self.build_fetch_request(bbox, time_range, registry)?;
71        super::execute_http_request_status(&request)?;
72        // Extruding building footprints and roads mapped to .10d Tensor models is deferred.
73        Ok(())
74    }
75
76    /// Parse an OpenStreetMap Overpass API JSON response body into provenance `NQuin`s.
77    ///
78    /// Each element of the top-level `elements` array (a node, way, or relation) is
79    /// mapped to a set of Dublin Core / RDF / WGS84 provenance quins, using
80    /// `generate_60bit_token` for IRI/literal hashing so the emitted data is queryable
81    /// by the SPARQL layer (which hashes IRIs the same way). If `elements` is absent an
82    /// empty `Vec` is returned. Only invalid JSON yields an `Err`.
83    pub fn parse_features(&self, body: &str) -> Result<Vec<crate::NQuin>, String> {
84        use crate::lexicon::generate_60bit_token;
85
86        // Predicate hashes (constant per call).
87        let title = generate_60bit_token(b"http://purl.org/dc/terms/title");
88        let ty = generate_60bit_token(b"http://www.w3.org/1999/02/22-rdf-syntax-ns#type");
89        let source = generate_60bit_token(b"http://purl.org/dc/terms/source");
90        let lat_p = generate_60bit_token(b"http://www.w3.org/2003/01/geo/wgs84_pos#lat");
91        let long_p = generate_60bit_token(b"http://www.w3.org/2003/01/geo/wgs84_pos#long");
92
93        fn quin(s: u64, p: u64, o: u64) -> crate::NQuin {
94            crate::NQuin {
95                subject: s,
96                predicate: p,
97                object: o,
98                context: 0,
99                metadata: 0,
100                parity: s ^ p ^ o,
101            }
102        }
103
104        let json: serde_json::Value = serde_json::from_str(body).map_err(|e| e.to_string())?;
105
106        let mut quins = Vec::new();
107        let elements = match json.get("elements").and_then(|e| e.as_array()) {
108            Some(arr) => arr,
109            None => return Ok(quins),
110        };
111
112        for el in elements {
113            // `type` (node/way/relation) and `id` identify the element; skip if absent.
114            let el_type = match el.get("type").and_then(|t| t.as_str()) {
115                Some(t) => t,
116                None => continue,
117            };
118            let id = match el.get("id").and_then(|i| i.as_u64()) {
119                Some(i) => i,
120                None => continue,
121            };
122
123            let el_iri = format!("https://www.openstreetmap.org/{el_type}/{id}");
124            let subject = generate_60bit_token(el_iri.as_bytes());
125            let kind =
126                generate_60bit_token(format!("https://www.openstreetmap.org/{el_type}").as_bytes());
127
128            // rdf:type -> osm element kind (always).
129            quins.push(quin(subject, ty, kind));
130
131            // dcterms:source -> the element IRI (always).
132            quins.push(quin(
133                subject,
134                source,
135                generate_60bit_token(el_iri.as_bytes()),
136            ));
137
138            // node lat/lon -> WGS84 geo (f64 bits stored as u64).
139            if let Some(lat) = el.get("lat").and_then(|v| v.as_f64()) {
140                quins.push(quin(subject, lat_p, lat.to_bits()));
141            }
142            if let Some(lon) = el.get("lon").and_then(|v| v.as_f64()) {
143                quins.push(quin(subject, long_p, lon.to_bits()));
144            }
145
146            // Tags: `name` -> dcterms:title; every other tag -> OSM Key: predicate.
147            if let Some(tags) = el.get("tags").and_then(|t| t.as_object()) {
148                for (k, v) in tags {
149                    let val = match v.as_str() {
150                        Some(s) => s,
151                        None => continue,
152                    };
153                    if k == "name" {
154                        quins.push(quin(subject, title, generate_60bit_token(val.as_bytes())));
155                    } else {
156                        let pred = generate_60bit_token(
157                            format!("https://wiki.openstreetmap.org/wiki/Key:{k}").as_bytes(),
158                        );
159                        quins.push(quin(subject, pred, generate_60bit_token(val.as_bytes())));
160                    }
161                }
162            }
163        }
164
165        Ok(quins)
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn test_osm_adapter_egress() {
175        let adapter = OsmAdapter::new(
176            "osm_adapter",
177            "https://overpass-api.de/api/interpreter",
178            "https://tiles.example.com/osm",
179        );
180        let registry = NetworkDisclosureRegistry::new();
181
182        let res1 = adapter.fetch_region((0.0, 0.0, 1.0, 1.0), (0, 0), &registry);
183        assert!(res1.is_err());
184
185        let mut registry = registry;
186        registry.register_egress(
187            adapter.adapter_id(),
188            "https://overpass-api.de/api/interpreter",
189            "Fetch OSM features via Overpass",
190            "User views map layer",
191        );
192
193        let res2 = adapter.fetch_region((0.0, 0.0, 1.0, 1.0), (0, 0), &registry);
194        if let Err(e) = res2 {
195            assert!(
196                !e.contains("Consent denied"),
197                "Failed on consent when it should have been granted: {}",
198                e
199            );
200        }
201    }
202
203    #[test]
204    fn test_osm_parse_features() {
205        use crate::lexicon::generate_60bit_token;
206
207        let adapter = OsmAdapter::new(
208            "osm_adapter",
209            "https://overpass-api.de/api/interpreter",
210            "https://tiles.example.com/osm",
211        );
212
213        // Realistic Overpass fixture: one node (name + lat + lon + amenity),
214        // one way (name).
215        let body = r#"{
216            "version": 0.6,
217            "generator": "Overpass API",
218            "elements": [
219                {
220                    "type": "node",
221                    "id": 1,
222                    "lat": -37.8183,
223                    "lon": 144.9671,
224                    "tags": {
225                        "name": "Flinders Street Station",
226                        "amenity": "station"
227                    }
228                },
229                {
230                    "type": "way",
231                    "id": 2,
232                    "nodes": [1, 2, 3],
233                    "tags": {
234                        "name": "Yarra River",
235                        "waterway": "river"
236                    }
237                }
238            ]
239        }"#;
240
241        let quins = adapter
242            .parse_features(body)
243            .expect("valid JSON should parse");
244
245        // Node emits: rdf:type, source, lat, long, title, amenity tag = 6.
246        // Way emits: rdf:type, source, title, waterway tag = 4.
247        assert_eq!(quins.len(), 10, "unexpected quin count: {}", quins.len());
248
249        // Recompute the node's dcterms:title quin and assert it is present.
250        let title = generate_60bit_token(b"http://purl.org/dc/terms/title");
251        let node_subject = generate_60bit_token(b"https://www.openstreetmap.org/node/1");
252        let node_name = generate_60bit_token(b"Flinders Street Station");
253
254        assert!(
255            quins.iter().any(|q| {
256                q.subject == node_subject && q.predicate == title && q.object == node_name
257            }),
258            "node TITLE quin missing from parsed output"
259        );
260
261        // Sanity: an empty/absent elements array yields an empty Vec, not an error.
262        let empty = adapter.parse_features(r#"{"version":0.6}"#).expect("valid");
263        assert!(empty.is_empty());
264
265        // Invalid JSON is the only error path.
266        assert!(adapter.parse_features("not json").is_err());
267    }
268}