qualia_client_core/wellfair/
ccf_resolver.rs1use serde::{Deserialize, Serialize};
14use wellfare_core::anatomy::AnatomyModel;
15
16pub const HRA_SPARQL_ENDPOINT: &str = "https://lod.humanatlas.io/sparql";
18
19const HTTP_USER_AGENT: &str = "QualiaDB-anatomy/1.0";
22
23pub fn ref_organ_glb_query() -> String {
26 "SELECT DISTINCT ?glb WHERE { \
27 GRAPH ?g { ?s <http://xmlns.com/foaf/0.1/depiction> ?glb } \
28 FILTER(STRENDS(LCASE(STR(?glb)), \".glb\") && CONTAINS(STR(?glb), \"/ref-organ/\")) \
29 } ORDER BY ?glb"
30 .to_string()
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct RefOrgan {
37 pub filename: String,
39 pub glb_url: String,
41 pub model: AnatomyModel,
43}
44
45pub fn parse_ref_organs(sparql_json: &str) -> Vec<RefOrgan> {
51 let root: serde_json::Value = match serde_json::from_str(sparql_json) {
52 Ok(v) => v,
53 Err(_) => return Vec::new(),
54 };
55 let bindings = root
56 .get("results")
57 .and_then(|r| r.get("bindings"))
58 .and_then(|b| b.as_array());
59 let mut out = Vec::new();
60 if let Some(bindings) = bindings {
61 for b in bindings {
62 let Some(url) = b
63 .get("glb")
64 .and_then(|g| g.get("value"))
65 .and_then(|v| v.as_str())
66 else {
67 continue;
68 };
69 let filename = url.rsplit('/').next().unwrap_or(url).to_string();
70 let Some(model) = model_from_filename(&filename) else {
71 continue;
72 };
73 out.push(RefOrgan {
74 filename,
75 glb_url: url.to_string(),
76 model,
77 });
78 }
79 }
80 out
81}
82
83fn model_from_filename(filename: &str) -> Option<AnatomyModel> {
85 if filename.contains("-f-") {
86 Some(AnatomyModel::Female)
87 } else if filename.contains("-m-") {
88 Some(AnatomyModel::Male)
89 } else {
90 None
91 }
92}
93
94pub fn organs_for_model(organs: &[RefOrgan], model: AnatomyModel) -> Vec<RefOrgan> {
96 organs
97 .iter()
98 .filter(|o| o.model == model)
99 .cloned()
100 .collect()
101}
102
103#[cfg(not(target_arch = "wasm32"))]
105#[derive(Debug)]
106pub enum CcfError {
107 Http(reqwest::Error),
108}
109
110#[cfg(not(target_arch = "wasm32"))]
111impl std::fmt::Display for CcfError {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 match self {
114 CcfError::Http(e) => write!(f, "CCF HTTP: {e}"),
115 }
116 }
117}
118
119#[cfg(not(target_arch = "wasm32"))]
120impl std::error::Error for CcfError {}
121
122#[cfg(not(target_arch = "wasm32"))]
123impl From<reqwest::Error> for CcfError {
124 fn from(e: reqwest::Error) -> Self {
125 CcfError::Http(e)
126 }
127}
128
129#[cfg(not(target_arch = "wasm32"))]
133pub fn discover_ref_organs(endpoint: &str) -> Result<Vec<RefOrgan>, CcfError> {
134 let json = reqwest::blocking::Client::new()
136 .post(endpoint)
137 .header(reqwest::header::USER_AGENT, HTTP_USER_AGENT)
138 .header(reqwest::header::CONTENT_TYPE, "application/sparql-query")
139 .header(reqwest::header::ACCEPT, "application/sparql-results+json")
140 .body(ref_organ_glb_query())
141 .send()?
142 .error_for_status()?
143 .text()?;
144 Ok(parse_ref_organs(&json))
145}
146
147#[cfg(not(target_arch = "wasm32"))]
149pub fn fetch_glb(glb_url: &str) -> Result<Vec<u8>, CcfError> {
150 let bytes = reqwest::blocking::Client::new()
151 .get(glb_url)
152 .header(reqwest::header::USER_AGENT, HTTP_USER_AGENT)
153 .send()?
154 .error_for_status()?
155 .bytes()?;
156 Ok(bytes.to_vec())
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 const SAMPLE_JSON: &str = r#"{
166 "head": { "vars": ["glb"] },
167 "results": { "bindings": [
168 { "glb": { "datatype": "http://www.w3.org/2001/XMLSchema#anyURI", "type": "literal",
169 "value": "https://cdn.humanatlas.io/digital-objects/ref-organ/liver-male/v1.2/assets/3d-vh-m-liver.glb" } },
170 { "glb": { "datatype": "http://www.w3.org/2001/XMLSchema#anyURI", "type": "literal",
171 "value": "https://cdn.humanatlas.io/digital-objects/ref-organ/kidney-female-left/v1.3/assets/3d-vh-f-kidney-l.glb" } },
172 { "glb": { "datatype": "http://www.w3.org/2001/XMLSchema#anyURI", "type": "literal",
173 "value": "https://cdn.humanatlas.io/digital-objects/ref-organ/uterus-female/v1.2/assets/3d-vh-f-uterus.glb" } }
174 ]}
175 }"#;
176
177 #[test]
178 fn query_is_stable_and_targets_ref_organ_depictions() {
179 let q = ref_organ_glb_query();
180 assert!(q.contains("foaf/0.1/depiction"));
181 assert!(q.contains("/ref-organ/"));
182 assert!(q.contains("ORDER BY ?glb"), "deterministic manifest");
183 }
184
185 #[test]
186 fn parses_real_endpoint_json_into_typed_manifest() {
187 let organs = parse_ref_organs(SAMPLE_JSON);
188 assert_eq!(organs.len(), 3);
189 assert_eq!(organs[0].filename, "3d-vh-m-liver.glb");
191 assert!(organs[0].glb_url.starts_with("https://cdn.humanatlas.io/"));
192 assert_eq!(organs[0].model, AnatomyModel::Male);
194 assert_eq!(organs[1].model, AnatomyModel::Female);
195 assert_eq!(organs[2].model, AnatomyModel::Female);
196 }
197
198 #[test]
199 fn model_filter_splits_the_body() {
200 let organs = parse_ref_organs(SAMPLE_JSON);
201 assert_eq!(organs_for_model(&organs, AnatomyModel::Male).len(), 1);
202 assert_eq!(organs_for_model(&organs, AnatomyModel::Female).len(), 2);
203 }
204
205 #[test]
206 fn discovered_filenames_resolve_to_body_systems() {
207 use wellfare_core::anatomy::body_system_for_organ;
209 let organs = parse_ref_organs(SAMPLE_JSON);
210 assert_eq!(
211 body_system_for_organ(&organs[0].filename),
212 Some("digestive")
213 ); assert_eq!(body_system_for_organ(&organs[1].filename), Some("urinary")); assert_eq!(
216 body_system_for_organ(&organs[2].filename),
217 Some("reproductive")
218 ); }
220
221 #[test]
222 fn malformed_json_and_unsexed_assets_are_handled() {
223 assert!(parse_ref_organs("not json").is_empty());
224 let no_sex = r#"{"results":{"bindings":[
226 {"glb":{"type":"literal","value":"https://cdn.humanatlas.io/digital-objects/ref-organ/x/v1/assets/3d-vh-mystery.glb"}}
227 ]}}"#;
228 assert!(parse_ref_organs(no_sex).is_empty());
229 }
230}