Skip to main content

qualia_core_db/domains/geospatial/adapters/
gbif_adapter.rs

1use crate::domains::geospatial::adapters::AdapterHttpRequest;
2use crate::net::disclosure::NetworkDisclosureRegistry;
3
4/// Stub adapter for GBIF occurrence API bbox queries.
5pub struct GbifAdapter {
6    pub id: &'static str,
7    pub occurrence_endpoint: String,
8}
9
10impl GbifAdapter {
11    pub fn new(id: &'static str, occurrence_endpoint: &str) -> Self {
12        Self {
13            id,
14            occurrence_endpoint: occurrence_endpoint.to_string(),
15        }
16    }
17}
18
19impl GbifAdapter {
20    pub fn adapter_id(&self) -> &'static str {
21        self.id
22    }
23
24    pub fn build_fetch_request(
25        &self,
26        bbox: (f64, f64, f64, f64),
27        time_range: (u64, u64),
28        registry: &NetworkDisclosureRegistry,
29    ) -> Result<AdapterHttpRequest, String> {
30        if !registry.check_egress_consent(self.adapter_id(), &self.occurrence_endpoint) {
31            return Err(format!(
32                "Consent denied or unregistered for endpoint {} by adapter {}",
33                self.occurrence_endpoint,
34                self.adapter_id()
35            ));
36        }
37
38        let gbif_query = format!(
39            "{}?decimalLatitude={},{}&decimalLongitude={},{}&year={},{}",
40            self.occurrence_endpoint, bbox.1, bbox.3, bbox.0, bbox.2, time_range.0, time_range.1
41        );
42
43        Ok(AdapterHttpRequest::get(gbif_query, "GBIF"))
44    }
45
46    /// Parse a GBIF occurrence-search JSON response body into provenance `NQuin`s.
47    ///
48    /// The `results` array (each element an occurrence record) is mapped to a set of
49    /// Dublin Core / WGS84 provenance quins per record, using `generate_60bit_token`
50    /// for IRI/literal hashing so the emitted data is queryable by the SPARQL layer
51    /// (which hashes IRIs the same way). If `results` is absent an empty `Vec` is
52    /// returned. Only invalid JSON yields an `Err`.
53    pub fn parse_features(&self, body: &str) -> Result<Vec<crate::NQuin>, String> {
54        use crate::lexicon::generate_60bit_token;
55
56        // Predicate / type-object hashes (constant per call).
57        let title = generate_60bit_token(b"http://purl.org/dc/terms/title");
58        let ty = generate_60bit_token(b"http://www.w3.org/1999/02/22-rdf-syntax-ns#type");
59        let license = generate_60bit_token(b"http://purl.org/dc/terms/license");
60        let created = generate_60bit_token(b"http://purl.org/dc/terms/created");
61        let source = generate_60bit_token(b"http://purl.org/dc/terms/source");
62        let lat_p = generate_60bit_token(b"http://www.w3.org/2003/01/geo/wgs84_pos#lat");
63        let long_p = generate_60bit_token(b"http://www.w3.org/2003/01/geo/wgs84_pos#long");
64        let kind = generate_60bit_token(b"https://www.gbif.org/occurrence");
65
66        fn quin(s: u64, p: u64, o: u64) -> crate::NQuin {
67            crate::NQuin {
68                subject: s,
69                predicate: p,
70                object: o,
71                context: 0,
72                metadata: 0,
73                parity: s ^ p ^ o,
74            }
75        }
76
77        let json: serde_json::Value = serde_json::from_str(body).map_err(|e| e.to_string())?;
78
79        let mut quins = Vec::new();
80        let results = match json.get("results").and_then(|r| r.as_array()) {
81            Some(arr) => arr,
82            None => return Ok(quins),
83        };
84
85        for rec in results {
86            // `key` identifies the occurrence; skip records without one.
87            let key = match rec.get("key").and_then(|k| k.as_u64()) {
88                Some(k) => k,
89                None => continue,
90            };
91            let occ_iri = format!("https://www.gbif.org/occurrence/{key}");
92            let subject = generate_60bit_token(occ_iri.as_bytes());
93
94            // rdf:type -> gbif occurrence (always).
95            quins.push(quin(subject, ty, kind));
96
97            if let Some(name) = rec.get("scientificName").and_then(|v| v.as_str()) {
98                quins.push(quin(subject, title, generate_60bit_token(name.as_bytes())));
99            }
100            if let Some(lic) = rec.get("license").and_then(|v| v.as_str()) {
101                quins.push(quin(subject, license, generate_60bit_token(lic.as_bytes())));
102            }
103            if let Some(date) = rec.get("eventDate").and_then(|v| v.as_str()) {
104                quins.push(quin(
105                    subject,
106                    created,
107                    generate_60bit_token(date.as_bytes()),
108                ));
109            }
110
111            // dc:source -> occurrence IRI (always).
112            quins.push(quin(
113                subject,
114                source,
115                generate_60bit_token(occ_iri.as_bytes()),
116            ));
117
118            if let Some(lat) = rec.get("decimalLatitude").and_then(|v| v.as_f64()) {
119                quins.push(quin(subject, lat_p, lat.to_bits()));
120            }
121            if let Some(lon) = rec.get("decimalLongitude").and_then(|v| v.as_f64()) {
122                quins.push(quin(subject, long_p, lon.to_bits()));
123            }
124        }
125
126        Ok(quins)
127    }
128
129    pub fn fetch_region(
130        &self,
131        bbox: (f64, f64, f64, f64),
132        time_range: (u64, u64),
133        registry: &NetworkDisclosureRegistry,
134    ) -> Result<(), String> {
135        let request = self.build_fetch_request(bbox, time_range, registry)?;
136        super::execute_http_request_status(&request)?;
137        // Parsing JSON and emitting 4D semantic points is deferred.
138        Ok(())
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_gbif_adapter_egress() {
148        let adapter = GbifAdapter::new("gbif_adapter", "https://api.gbif.org/v1/occurrence/search");
149        let registry = NetworkDisclosureRegistry::new();
150
151        let res1 = adapter.fetch_region((10.0, 45.0, 11.0, 46.0), (2020, 2024), &registry);
152        assert!(res1.is_err());
153
154        let mut registry = registry;
155        registry.register_egress(
156            adapter.adapter_id(),
157            "https://api.gbif.org/v1/occurrence/search",
158            "Fetch species occurrence records in bbox",
159            "User enables biodiversity layer",
160        );
161
162        let res2 = adapter.fetch_region((10.0, 45.0, 11.0, 46.0), (2020, 2024), &registry);
163        assert!(res2.is_ok());
164    }
165
166    #[test]
167    fn test_gbif_parse_features() {
168        use crate::lexicon::generate_60bit_token;
169
170        let adapter = GbifAdapter::new("gbif_adapter", "https://api.gbif.org/v1/occurrence/search");
171
172        // Realistic 2-occurrence fixture.
173        // Record 1: full record -> type, title, license, created, source, lat, long = 7 quins.
174        // Record 2: minimal (no license/eventDate/coords) -> type, title, source = 3 quins.
175        let body = r#"{
176            "offset": 0,
177            "limit": 20,
178            "endOfRecords": false,
179            "count": 1234,
180            "results": [
181                {
182                    "key": 123456,
183                    "scientificName": "Panthera leo",
184                    "decimalLatitude": -1.2,
185                    "decimalLongitude": 36.8,
186                    "eventDate": "2019-05-01T00:00:00",
187                    "datasetKey": "abc-def",
188                    "license": "http://creativecommons.org/licenses/by/4.0/legalcode"
189                },
190                {
191                    "key": 654321,
192                    "scientificName": "Loxodonta africana"
193                }
194            ]
195        }"#;
196
197        let quins = adapter.parse_features(body).expect("valid JSON parses");
198        // 7 from record 1 + 3 from record 2 = 10.
199        assert_eq!(quins.len(), 10, "expected 10 quins, got {}", quins.len());
200
201        // Recompute expected hashes the same way the parser does.
202        let title = generate_60bit_token(b"http://purl.org/dc/terms/title");
203        let subj1 = generate_60bit_token(b"https://www.gbif.org/occurrence/123456");
204        let title_obj1 = generate_60bit_token(b"Panthera leo");
205
206        assert!(
207            quins
208                .iter()
209                .any(|q| q.subject == subj1 && q.predicate == title && q.object == title_obj1),
210            "expected TITLE quin for Panthera leo not found"
211        );
212
213        // Absent-results object -> empty Vec, not an error.
214        let empty = adapter
215            .parse_features(r#"{"offset":0,"count":0}"#)
216            .expect("no results field is not an error");
217        assert!(empty.is_empty());
218
219        // Invalid JSON -> Err.
220        assert!(adapter.parse_features("{not json").is_err());
221    }
222}