qualia_core_db/domains/geospatial/adapters/
stac_adapter.rs1use crate::domains::geospatial::adapters::{AdapterHttpRequest, DataAdapter};
2use crate::net::disclosure::NetworkDisclosureRegistry;
3
4pub struct StacAdapter {
7 pub id: &'static str,
8 pub endpoint: String,
9 pub collection: Option<String>,
10}
11
12fn 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 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 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 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 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 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 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 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 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 let stac_bbox = format!("{},{},{},{}", bbox.0, bbox.1, bbox.2, bbox.3);
154 let stac_datetime = format!("{}/{}", time_range.0, time_range.1); 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 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), ®istry);
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 assert_eq!(quins.len(), 7, "unexpected quin count: {}", quins.len());
229
230 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}