From c0005e06bc3b6ab91f78af3752df75e61eeeaf51 Mon Sep 17 00:00:00 2001 From: Enola Knezevic Date: Sun, 2 Feb 2025 23:41:20 +0100 Subject: [PATCH 1/3] Incisive API calling --- Cargo.toml | 2 +- src/ast.rs | 2 +- src/config.rs | 4 +- src/errors.rs | 4 + src/eucaim_api.rs | 193 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 52 ++++++++++++- 6 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 src/eucaim_api.rs diff --git a/Cargo.toml b/Cargo.toml index 806e22b..38c0c33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ tokio-test = "0.4.2" build-data = "0" [profile.release] -#opt-level = "z" # Optimize for size. +#opt-level = "z" # Optimize for size. lto = true # Enable Link Time Optimization codegen-units = 1 # Reduce number of codegen units to increase optimizations. panic = "abort" # Abort on panic diff --git a/src/ast.rs b/src/ast.rs index e53ff58..8c77515 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -9,7 +9,7 @@ pub enum Child { Condition(Condition), } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "UPPERCASE")] pub enum Operand { //this is operator, of course, but rename would need to be coordinated with all the Lenses, EUCAIM providers, etc And, diff --git a/src/config.rs b/src/config.rs index 9eca57d..1a5de5d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,7 +19,8 @@ pub enum Obfuscate { #[derive(clap::ValueEnum, Clone, PartialEq, Debug, Copy)] pub enum EndpointType { Blaze, - Omop, + Omop, // endpoint is URL of a query mediator translating AST to provider specific SQL + EucaimApi, // endpoint is URL of custom API for querying EUCAIM provider #[cfg(feature = "query-sql")] BlazeAndSql, #[cfg(feature = "query-sql")] @@ -31,6 +32,7 @@ impl fmt::Display for EndpointType { match self { EndpointType::Blaze => write!(f, "blaze"), EndpointType::Omop => write!(f, "omop"), + EndpointType::EucaimApi => write!(f, "eucaim_api"), #[cfg(feature = "query-sql")] EndpointType::BlazeAndSql => write!(f, "blaze_and_sql"), #[cfg(feature = "query-sql")] diff --git a/src/errors.rs b/src/errors.rs index 1470b09..e0d1253 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -39,6 +39,10 @@ pub enum FocusError { SerializationError(String), #[error("Unable to post AST: {0}")] UnableToPostAst(reqwest::Error), + #[error("Unable to post EUCAIM API query: {0}")] + UnableToPostEucaimApiQuery(reqwest::Error), + #[error("EUCAIM API query generation error")] + EucaimApiQueryGenerationError, #[error("Unable to post Exporter query: {0}")] UnableToPostExporterQuery(reqwest::Error), #[error("Unable to get Exporter query status: {0}")] diff --git a/src/eucaim_api.rs b/src/eucaim_api.rs new file mode 100644 index 0000000..6410714 --- /dev/null +++ b/src/eucaim_api.rs @@ -0,0 +1,193 @@ +use reqwest::{ + header::{self, HeaderMap, HeaderValue}, + StatusCode, +}; +use tracing::{debug, error, warn}; + +use once_cell::sync::Lazy; +use std::collections::HashMap; + +use crate::ast; +use crate::config::CONFIG; +use crate::errors::FocusError; + +pub static CATEGORY: Lazy> = Lazy::new(|| { + let mut map: HashMap<&'static str, &'static str> = HashMap::new(); + map.insert("SNOMEDCT263495000", "gender"); + map.insert("SNOMEDCT439401001", "diagnosis"); + map.insert("RID10311", "modality"); + map.insert("SNOMEDCT123037004", "bodyPart"); + map.insert("C25392", "manufacturer"); + + map +}); + +pub static CRITERION: Lazy> = Lazy::new(|| { + let mut map: HashMap<&'static str, &'static str> = HashMap::new(); + map.insert("SNOMEDCT248153007", "male"); + map.insert("SNOMEDCT248152002", "female"); + map.insert("SNOMEDCT74964007", "other"); + map.insert("SNOMEDCT261665006", "unknown"); + map.insert("SNOMEDCT363406005", "SNOMEDCT363406005"); // colon cancer + map.insert("SNOMEDCT254837009", "SNOMEDCT254837009"); // breast cancer + map.insert("SNOMEDCT363358000", "SNOMEDCT363358000"); // lung cancer + map.insert("SNOMEDCT363484005", "SNOMEDCT363484005"); // pelvis cancer + map.insert("SNOMEDCT399068003", "SNOMEDCT399068003"); // prostate cancer + map.insert("RID10312", "MR"); + map.insert("RID10337", "PET"); + map.insert("RID10334", "SPECT"); + map.insert("RID10321", "CT"); + map.insert("RID10321", "CT"); + map.insert("SNOMEDCT76752008", "breast"); + map.insert("SNOMEDCT71854001", "colon"); + map.insert("SNOMEDCT39607008", "lung"); + map.insert("SNOMEDCT12921003", "pelvis"); + map.insert("SNOMEDCT41216001", "prostate"); + map.insert("C200140", "Siemens"); + map.insert("birnlex_3066", "Siemens"); + map.insert("birnlex_12833", "General%20Electric"); + map.insert("birnlex_3065", "Philips"); + map.insert("birnlex_3067", "Toshiba"); + + map +}); + +pub fn build_eucaim_api_query_url(ast: ast::Ast) -> Result { + let mut url: String = CONFIG.endpoint_url.to_string(); + + let mut parameters: Vec = Vec::new(); + + let children = ast.ast.children; + + if children.len() > 1 { + error!("Too many children! AND/OR queries not supported."); + return Err(FocusError::EucaimApiQueryGenerationError); + } + + for child in children { + // will be either 0 or 1 + match child { + ast::Child::Operation(operation) => { + if operation.operand == ast::Operand::Or { + error!("OR found as first level operator"); + return Err(FocusError::EucaimApiQueryGenerationError); + } + for grandchild in operation.children { + match grandchild { + ast::Child::Operation(operation) => { + if operation.operand == ast::Operand::And { + error!("AND found as second level operator"); + return Err(FocusError::EucaimApiQueryGenerationError); + } + let greatgrandchildren = operation.children; + if greatgrandchildren.len() > 1 { + error!("Too many children! OR operator between criteria of the same type not supported."); + return Err(FocusError::EucaimApiQueryGenerationError); + } + + for greatgrandchild in greatgrandchildren { + match greatgrandchild { + ast::Child::Operation(_) => { + error!( + "Search tree has too many levels. Query not supported" + ); + return Err(FocusError::EucaimApiQueryGenerationError); + } + ast::Child::Condition(condition) => { + let category = CATEGORY.get(&(condition.key).as_str()); + if let Some(cat) = category { + match condition.value { + ast::ConditionValue::String(value) => { + let criterion = + CRITERION.get(&(value).as_str()); + if let Some(crit) = criterion { + parameters + .push(cat.to_string() + "=" + crit); + dbg!(¶meters); + } + } + _ => { + error!("The only supported condition value type is string"); + return Err( + FocusError::EucaimApiQueryGenerationError, + ); + } + } + } + } + } + } + } + ast::Child::Condition(_) => { + // must be operation + error!("Condition found as second level child"); + return Err(FocusError::EucaimApiQueryGenerationError); + } + } + } + } + ast::Child::Condition(_) => { + // must be operation + error!("Condition found as first level child"); + return Err(FocusError::EucaimApiQueryGenerationError); + } + } + } + + url += parameters.join("&").as_str(); + + dbg!(&url); + + Ok(url) +} + +pub async fn send_eucaim_api_query(ast: ast::Ast) -> Result { + debug!("Posting EUCAIM API query..."); + + let eucaim_api_query = if let Ok(query) = build_eucaim_api_query_url(ast) { + query + } else { + return Err(FocusError::EucaimApiQueryGenerationError); + }; + + let mut headers = HeaderMap::new(); + + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + + if let Some(auth_header_value) = CONFIG.auth_header.clone() { + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(auth_header_value.as_str()) + .map_err(FocusError::InvalidHeaderValue)?, + ); + } + + let resp = CONFIG + .client + .get(&eucaim_api_query) + .headers(headers) + .send() + .await + .map_err(FocusError::UnableToPostEucaimApiQuery)?; + + debug!("Posted EUCAIM API query..."); + + let text = match resp.status() { + StatusCode::OK => resp.text().await.map_err(FocusError::UnableToPostAst)?, + code => { + warn!( + "Got unexpected code {code} while posting EUCAIM API query; reply was `{}`, debug info: {:?}", + eucaim_api_query, resp + ); + return Err(FocusError::AstPostingErrorReqwest(format!( + "Error while posting AST `{}`: {:?}", + eucaim_api_query, resp + ))); + } + }; + + Ok(text) +} diff --git a/src/main.rs b/src/main.rs index 6aa85b4..9af0c47 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod logger; mod exporter; mod intermediate_rep; +mod eucaim_api; mod projects; mod task_processing; mod util; @@ -160,7 +161,7 @@ async fn main_loop() -> ExitCode { }; let endpoint_service_available: fn() -> BoxFuture<'static, bool> = match CONFIG.endpoint_type { EndpointType::Blaze => || blaze::check_availability().boxed(), - EndpointType::Omop => || async { true }.boxed(), // TODO health check + EndpointType::Omop | EndpointType::EucaimApi => || async { true }.boxed(), // TODO health check #[cfg(feature = "query-sql")] EndpointType::BlazeAndSql => || blaze::check_availability().boxed(), #[cfg(feature = "query-sql")] @@ -344,6 +345,18 @@ async fn process_task( let ast: ast::Ast = serde_json::from_slice(&query_decoded)?; Ok(run_intermediate_rep_query(task, ast).await?) + }, + EndpointType::EucaimApi => { + let decoded = util::base64_decode(&task.body)?; + let intermediate_rep_query: intermediate_rep::IntermediateRepQuery = + serde_json::from_slice(&decoded)?; + //TODO check that the language is ast + let query_decoded = general_purpose::STANDARD + .decode(intermediate_rep_query.query) + .map_err(FocusError::DecodeError)?; + let ast: ast::Ast = serde_json::from_slice(&query_decoded)?; + + Ok(run_eucaim_api_query(task, ast).await?) } } } @@ -476,6 +489,43 @@ async fn run_intermediate_rep_query( Ok(result) } +async fn run_eucaim_api_query( + task: &BeamTask, + ast: ast::Ast, +) -> Result { + let mut err = beam::beam_result::perm_failed( + CONFIG.beam_app_id_long.clone(), + vec![task.to_owned().from], + task.to_owned().id, + String::new(), + ); + + let mut eucaim_api_query_result = eucaim_api::send_eucaim_api_query(ast).await?; + + if let Some(provider_icon) = CONFIG.provider_icon.clone() { + eucaim_api_query_result = eucaim_api_query_result.replacen( + '{', + format!(r#"{{"provider_icon":"{}","#, provider_icon).as_str(), + 1, + ); + } + + if let Some(provider) = CONFIG.provider.clone() { + eucaim_api_query_result = eucaim_api_query_result.replacen( + '{', + format!(r#"{{"provider":"{}","#, provider).as_str(), + 1, + ); + } + + let result = beam_result(task.to_owned(), eucaim_api_query_result).unwrap_or_else(|e| { + err.body = beam_lib::RawString(e.to_string()); + err + }); + + Ok(result) +} + async fn run_exporter_query( task: &BeamTask, body: &String, From cd765edda6e260f3d77ec6fa17c8762726882d3d Mon Sep 17 00:00:00 2001 From: Enola Knezevic Date: Mon, 3 Feb 2025 18:27:07 +0100 Subject: [PATCH 2/3] EUCAIM API tests --- src/eucaim_api.rs | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/eucaim_api.rs b/src/eucaim_api.rs index 6410714..9c179b0 100644 --- a/src/eucaim_api.rs +++ b/src/eucaim_api.rs @@ -1,6 +1,7 @@ use reqwest::{ header::{self, HeaderMap, HeaderValue}, StatusCode, + Url }; use tracing::{debug, error, warn}; @@ -37,7 +38,6 @@ pub static CRITERION: Lazy> = Lazy::new(|| { map.insert("RID10337", "PET"); map.insert("RID10334", "SPECT"); map.insert("RID10321", "CT"); - map.insert("RID10321", "CT"); map.insert("SNOMEDCT76752008", "breast"); map.insert("SNOMEDCT71854001", "colon"); map.insert("SNOMEDCT39607008", "lung"); @@ -52,8 +52,8 @@ pub static CRITERION: Lazy> = Lazy::new(|| { map }); -pub fn build_eucaim_api_query_url(ast: ast::Ast) -> Result { - let mut url: String = CONFIG.endpoint_url.to_string(); +pub fn build_eucaim_api_query_url(base_url: Url, ast: ast::Ast) -> Result { + let mut url: String = base_url.to_string() + "?"; let mut parameters: Vec = Vec::new(); @@ -144,7 +144,7 @@ pub fn build_eucaim_api_query_url(ast: ast::Ast) -> Result { pub async fn send_eucaim_api_query(ast: ast::Ast) -> Result { debug!("Posting EUCAIM API query..."); - let eucaim_api_query = if let Ok(query) = build_eucaim_api_query_url(ast) { + let eucaim_api_query = if let Ok(query) = build_eucaim_api_query_url(CONFIG.endpoint_url.clone(), ast) { query } else { return Err(FocusError::EucaimApiQueryGenerationError); @@ -191,3 +191,33 @@ pub async fn send_eucaim_api_query(ast: ast::Ast) -> Result Ok(text) } + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions; + + const EMPTY: &str = r#"{"ast":{"children":[],"operand":"OR"},"id":"ef8bae78-522c-498c-b7db-3f96f279a1a0__search__ef8bae78-522c-498c-b7db-3f96f279a1a0"}"#; + + const JUST_RIGHT: &str = r#"{"ast":{"children":[{"children":[{"children":[{"key":"SNOMEDCT263495000","system":"","type":"EQUALS","value":"SNOMEDCT248153007"}],"operand":"OR"},{"children":[{"key":"SNOMEDCT439401001","system":"urn:snomed-org/sct","type":"EQUALS","value":"SNOMEDCT399068003"}],"operand":"OR"},{"children":[{"key":"RID10311","system":"urn:oid:2.16.840.1.113883.6.256","type":"EQUALS","value":"RID10312"}],"operand":"OR"},{"children":[{"key":"SNOMEDCT123037004","system":"urn:snomed-org/sct","type":"EQUALS","value":"SNOMEDCT76752008"}],"operand":"OR"},{"children":[{"key":"C25392","system":"http://bioontology.org/projects/ontologies/birnlex","type":"EQUALS","value":"birnlex_3065"}],"operand":"OR"}],"operand":"AND"}],"operand":"OR"},"id":"66b8bbf4-ded2-4f94-87ab-3a3ca2f4edc0__search__66b8bbf4-ded2-4f94-87ab-3a3ca2f4edc0"}"#; + + const TOO_MUCH: &str = r#"{"ast":{"children":[{"children":[{"children":[{"key":"SNOMEDCT263495000","system":"","type":"EQUALS","value":"SNOMEDCT248153007"},{"key":"SNOMEDCT263495000","system":"","type":"EQUALS","value":"SNOMEDCT248152002"}],"operand":"OR"},{"children":[{"key":"SNOMEDCT439401001","system":"urn:snomed-org/sct","type":"EQUALS","value":"SNOMEDCT399068003"},{"key":"SNOMEDCT439401001","system":"urn:snomed-org/sct","type":"EQUALS","value":"SNOMEDCT254837009"}],"operand":"OR"},{"children":[{"key":"RID10311","system":"urn:oid:2.16.840.1.113883.6.256","type":"EQUALS","value":"RID10312"},{"key":"RID10311","system":"urn:oid:2.16.840.1.113883.6.256","type":"EQUALS","value":"RID10337"}],"operand":"OR"},{"children":[{"key":"SNOMEDCT123037004","system":"urn:snomed-org/sct","type":"EQUALS","value":"SNOMEDCT76752008"},{"key":"SNOMEDCT123037004","system":"urn:snomed-org/sct","type":"EQUALS","value":"SNOMEDCT41216001"}],"operand":"OR"},{"children":[{"key":"C25392","system":"http://bioontology.org/projects/ontologies/birnlex","type":"EQUALS","value":"birnlex_3065"},{"key":"C25392","system":"http://bioontology.org/projects/ontologies/birnlex","type":"EQUALS","value":"birnlex_3067"}],"operand":"OR"}],"operand":"AND"}],"operand":"OR"},"id":"c57e075c-19de-4c5a-ba9c-b8f697a98dfc__search__c57e075c-19de-4c5a-ba9c-b8f697a98dfc"}"#; + + #[test] + fn test_build_url_empty() { + let url = build_eucaim_api_query_url(Url::parse("http://base.info/search").unwrap(), serde_json::from_str(EMPTY).unwrap()).unwrap(); + pretty_assertions::assert_eq!(url, "http://base.info/search?"); + } + + #[test] + fn test_build_url_just_right() { + let url = build_eucaim_api_query_url(Url::parse("http://base.info/search").unwrap(), serde_json::from_str(JUST_RIGHT).unwrap()).unwrap(); + pretty_assertions::assert_eq!(url, "http://base.info/search?gender=male&diagnosis=SNOMEDCT399068003&modality=MR&bodyPart=breast&manufacturer=Philips"); + } + + #[test] + fn test_build_url_too_much() { + assert!(build_eucaim_api_query_url(Url::parse("http://base.info/search").unwrap(), serde_json::from_str(TOO_MUCH).unwrap()).is_err()); + } + +} From 465d5c85b8d0e8342e7d162475972d51e28cca31 Mon Sep 17 00:00:00 2001 From: Enola Knezevic Date: Mon, 3 Feb 2025 18:33:02 +0100 Subject: [PATCH 3/3] no content type for get request --- src/eucaim_api.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/eucaim_api.rs b/src/eucaim_api.rs index 9c179b0..61fa949 100644 --- a/src/eucaim_api.rs +++ b/src/eucaim_api.rs @@ -152,11 +152,6 @@ pub async fn send_eucaim_api_query(ast: ast::Ast) -> Result let mut headers = HeaderMap::new(); - headers.insert( - header::CONTENT_TYPE, - HeaderValue::from_static("application/json"), - ); - if let Some(auth_header_value) = CONFIG.auth_header.clone() { headers.insert( header::AUTHORIZATION,