Skip to main content

qualia_core_db/domains/geospatial/adapters/
stac_adapter.rs

1use crate::domains::geospatial::adapters::{AdapterHttpRequest, DataAdapter};
2use crate::net::disclosure::NetworkDisclosureRegistry;
3
4/// Adapter for SpatioTemporal Asset Catalog (STAC) API endpoints.
5/// Supports querying JSON-based metadata via 4D bounding boxes (x, y, z, t).
6pub struct StacAdapter {
7    pub id: &'static str,
8    pub endpoint: String,
9    pub collection: Option<String>,
10}
11
12/// Build a provenance NQuin from three pre-hashed 60-bit tokens.
13/// Parity is the XOR fold of the three semantic vectors (matches the
14/// convention used elsewhere for lightweight integrity checks).
15fn quin(s: u64, p: u64, o: u64) -> crate::NQuin {
16    crate::NQuin {
17        subject: s,
18        predicate: p,
19        object: o,
20        context: 0,
21        metadata: 0,
22        parity: s ^ p ^ o,
23    }
24}
25
26impl StacAdapter {
27    pub fn new(id: &'static str, endpoint: &str, collection: Option<&str>) -> Self {
28        Self {
29            id,
30            endpoint: endpoint.to_string(),
31            collection: collection.map(|s| s.to_string()),
32        }
33    }
34
35    /// Parse a STAC item-search response (a GeoJSON `FeatureCollection`) into
36    /// provenance `NQuin`s. IRIs are hashed with `generate_60bit_token` so the
37    /// resulting quins are queryable by the SPARQL layer, which hashes the same
38    /// way. Returns `Err` only when the body is not valid JSON; a response with
39    /// no `features` array yields an empty `Vec`.
40    pub fn parse_features(&self, body: &str) -> Result<Vec<crate::NQuin>, String> {
41        use crate::lexicon::generate_60bit_token;
42
43        let json: serde_json::Value = serde_json::from_str(body).map_err(|e| e.to_string())?;
44
45        // Predicate / kind hashes (stable, IRI-derived).
46        let title_p = generate_60bit_token(b"http://purl.org/dc/terms/title");
47        let type_p = generate_60bit_token(b"http://www.w3.org/1999/02/22-rdf-syntax-ns#type");
48        let license_p = generate_60bit_token(b"http://purl.org/dc/terms/license");
49        let created_p = generate_60bit_token(b"http://purl.org/dc/terms/created");
50        let source_p = generate_60bit_token(b"http://purl.org/dc/terms/source");
51        let lat_p = generate_60bit_token(b"http://www.w3.org/2003/01/geo/wgs84_pos#lat");
52        let long_p = generate_60bit_token(b"http://www.w3.org/2003/01/geo/wgs84_pos#long");
53        let kind_o = generate_60bit_token(b"https://stacspec.org/Item");
54
55        let features = match json.get("features").and_then(|f| f.as_array()) {
56            Some(f) => f,
57            None => return Ok(Vec::new()),
58        };
59
60        let mut quins = Vec::new();
61        for (i, feature) in features.iter().enumerate() {
62            // Subject id: STAC feature "id", or a stable index fallback.
63            let id = feature
64                .get("id")
65                .and_then(|v| v.as_str())
66                .map(|s| s.to_string())
67                .unwrap_or_else(|| format!("stac:item:{}", i));
68            let subject = generate_60bit_token(id.as_bytes());
69
70            // Always: title (the item id) and rdf:type = STAC Item.
71            quins.push(quin(subject, title_p, generate_60bit_token(id.as_bytes())));
72            quins.push(quin(subject, type_p, kind_o));
73
74            let props = feature.get("properties");
75
76            // properties.datetime -> dc:created
77            if let Some(dt) = props
78                .and_then(|p| p.get("datetime"))
79                .and_then(|v| v.as_str())
80            {
81                quins.push(quin(
82                    subject,
83                    created_p,
84                    generate_60bit_token(dt.as_bytes()),
85                ));
86            }
87
88            // properties.license -> dc:license
89            if let Some(lic) = props
90                .and_then(|p| p.get("license"))
91                .and_then(|v| v.as_str())
92            {
93                quins.push(quin(
94                    subject,
95                    license_p,
96                    generate_60bit_token(lic.as_bytes()),
97                ));
98            }
99
100            // First asset href -> dc:source
101            if let Some(href) = feature
102                .get("assets")
103                .and_then(|a| a.as_object())
104                .and_then(|assets| assets.values().next())
105                .and_then(|asset| asset.get("href"))
106                .and_then(|v| v.as_str())
107            {
108                quins.push(quin(
109                    subject,
110                    source_p,
111                    generate_60bit_token(href.as_bytes()),
112                ));
113            }
114
115            // bbox (>=4 numbers) -> centre lat/long as raw f64 bits.
116            if let Some(bbox) = feature.get("bbox").and_then(|v| v.as_array()) {
117                if bbox.len() >= 4 {
118                    let coords: Option<Vec<f64>> =
119                        bbox.iter().take(4).map(|v| v.as_f64()).collect();
120                    if let Some(c) = coords {
121                        let long_c = (c[0] + c[2]) / 2.0;
122                        let lat_c = (c[1] + c[3]) / 2.0;
123                        quins.push(quin(subject, lat_p, lat_c.to_bits()));
124                        quins.push(quin(subject, long_p, long_c.to_bits()));
125                    }
126                }
127            }
128        }
129
130        Ok(quins)
131    }
132}
133
134impl DataAdapter for StacAdapter {
135    fn adapter_id(&self) -> &'static str {
136        self.id
137    }
138
139    fn build_fetch_request(
140        &self,
141        bbox: (f64, f64, f64, f64),
142        time_range: (u64, u64),
143        registry: &NetworkDisclosureRegistry,
144    ) -> Result<AdapterHttpRequest, String> {
145        if !registry.check_egress_consent(self.adapter_id(), &self.endpoint) {
146            return Err(format!(
147                "Consent denied or unregistered for STAC endpoint {}",
148                self.endpoint
149            ));
150        }
151
152        // Translate the bounding box and time_range into STAC API parameters
153        let stac_bbox = format!("{},{},{},{}", bbox.0, bbox.1, bbox.2, bbox.3);
154        let stac_datetime = format!("{}/{}", time_range.0, time_range.1); // Stub: properly format ISO8601 strings
155
156        let mut url = format!(
157            "{}/search?bbox={}&datetime={}",
158            self.endpoint.trim_end_matches('/'),
159            stac_bbox,
160            stac_datetime
161        );
162        if let Some(c) = &self.collection {
163            url.push_str(&format!("&collections={}", c));
164        }
165
166        Ok(AdapterHttpRequest::get(url, "STAC"))
167    }
168
169    fn parse_response(&self, body: &str) -> Result<Vec<crate::NQuin>, String> {
170        self.parse_features(body)
171    }
172
173    fn primary_endpoint(&self) -> &str {
174        &self.endpoint
175    }
176
177    fn estimate_tile_count(&self, _bbox: (f64, f64, f64, f64)) -> u32 {
178        // STAC search is usually a single API query that returns multiple item records
179        1
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_stac_adapter_consent_denied() {
189        let registry = NetworkDisclosureRegistry::new();
190        let adapter = StacAdapter::new(
191            "stac_adapter",
192            "https://planetarycomputer.microsoft.com/api/stac/v1",
193            Some("landsat-c2-l2"),
194        );
195
196        let res = adapter.fetch_region((0.0, 0.0, 1.0, 1.0), (0, 0), &registry);
197        assert!(res.is_err());
198    }
199
200    #[test]
201    fn test_stac_parse_features() {
202        use crate::lexicon::generate_60bit_token;
203
204        let body = r#"{
205            "type": "FeatureCollection",
206            "features": [
207                {
208                    "type": "Feature",
209                    "id": "S2A_31UFU_20230501",
210                    "bbox": [4.5, 51.0, 5.5, 52.0],
211                    "properties": {
212                        "datetime": "2023-05-01T10:00:00Z",
213                        "license": "proprietary",
214                        "platform": "sentinel-2a"
215                    },
216                    "assets": {
217                        "visual": {"href": "https://example/S2A.tif", "type": "image/tiff"}
218                    },
219                    "collection": "sentinel-2-l2a"
220                }
221            ]
222        }"#;
223
224        let adapter = StacAdapter::new("stac_adapter", "https://example/stac/v1", None);
225        let quins = adapter.parse_features(body).expect("valid STAC JSON");
226
227        // title + type + created + license + source + lat + long = 7 quins.
228        assert_eq!(quins.len(), 7, "unexpected quin count: {}", quins.len());
229
230        // The CREATED quin must be present with the exact expected hashes.
231        let subject = generate_60bit_token(b"S2A_31UFU_20230501");
232        let created_p = generate_60bit_token(b"http://purl.org/dc/terms/created");
233        let created_o = generate_60bit_token(b"2023-05-01T10:00:00Z");
234        assert!(
235            quins
236                .iter()
237                .any(|q| q.subject == subject && q.predicate == created_p && q.object == created_o),
238            "expected CREATED quin not found"
239        );
240    }
241}