diff --git a/src/api/mod.rs b/src/api/mod.rs index 2c4fc02..1405d65 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -3,7 +3,8 @@ use std::fmt::Formatter; use crate::AppState; use axum::Router; -use dicom::core::dictionary::{DataDictionaryEntry, DataDictionaryEntryRef}; +use dicom::core::dictionary::DataDictionaryEntry; +use dicom::core::ops::AttributeSelector; use dicom::core::{DataDictionary, PrimitiveValue, Tag, VR}; use dicom::object::StandardDataDictionary; use serde::de::{Error, SeqAccess, Visitor}; @@ -39,10 +40,10 @@ pub fn routes(base_path: &str) -> Router { /// Match Query Parameters for QIDO and MWL requests. #[derive(Debug, Deserialize, PartialEq)] #[serde(try_from = "HashMap")] -pub struct MatchCriteria(Vec<(Tag, PrimitiveValue)>); +pub struct MatchCriteria(Vec<(AttributeSelector, PrimitiveValue)>); impl MatchCriteria { - pub fn into_inner(self) -> Vec<(Tag, PrimitiveValue)> { + pub fn into_inner(self) -> Vec<(AttributeSelector, PrimitiveValue)> { self.0 } } @@ -51,15 +52,15 @@ impl TryFrom> for MatchCriteria { type Error = String; fn try_from(value: HashMap) -> Result { - let criteria: Vec<(Tag, PrimitiveValue)> = value + let criteria: Vec<(AttributeSelector, PrimitiveValue)> = value .into_iter() .map(|(key, value)| { StandardDataDictionary - .by_expr(&key) - .ok_or(format!("Cannot use unknown attribute {key} for matching.")) - .and_then(|entry| { - to_primitive_value(entry, &value) - .map(|primitive| (entry.tag.inner(), primitive)) + .parse_selector(&key) + .map_err(|err| format!("invalid attribute selector {key}: {err}")) + .and_then(|selector| { + to_primitive_value(selector.last_tag(), &value) + .map(|primitive| (selector, primitive)) }) }) .collect::>()?; @@ -68,14 +69,15 @@ impl TryFrom> for MatchCriteria { } /// helper function to convert a query parameter value to a PrimitiveValue -fn to_primitive_value( - entry: &DataDictionaryEntryRef, - raw_value: &str, -) -> Result { +fn to_primitive_value(tag: Tag, raw_value: &str) -> Result { if raw_value.is_empty() { return Ok(PrimitiveValue::Empty); } - match entry.vr.relaxed() { + let vr = StandardDataDictionary + .by_tag(tag) + .ok_or_else(|| format!("unknown tag {tag}"))? + .vr(); + match vr.relaxed() { // String-like VRs, no parsing required VR::AE | VR::AS @@ -133,9 +135,8 @@ fn to_primitive_value( Ok(PrimitiveValue::from(value)) } _ => Err(format!( - "Attribute {} cannot be used for matching due to unsupported VR {}", - entry.tag(), - entry.vr.relaxed() + "Attribute {} cannot be used for matching due to unsupported VR {:?}", + tag, vr )), } } diff --git a/src/api/mwl/service.rs b/src/api/mwl/service.rs index 0ed07f8..3a118de 100644 --- a/src/api/mwl/service.rs +++ b/src/api/mwl/service.rs @@ -81,6 +81,7 @@ pub enum MwlSearchError { mod tests { use axum::extract::Query; use axum::http::Uri; + use dicom::core::ops::AttributeSelector; use dicom::core::PrimitiveValue; use dicom::dictionary_std::tags; @@ -100,7 +101,7 @@ mod tests { limit: 42, include_field: IncludeField::List(vec![tags::PATIENT_WEIGHT]), match_criteria: MatchCriteria(vec![( - tags::PATIENT_NAME, + AttributeSelector::from(tags::PATIENT_NAME), PrimitiveValue::from("MUSTERMANN^MAX") )]), fuzzy_matching: false, @@ -108,6 +109,29 @@ mod tests { ); } + #[test] + fn parse_query_params_nested() { + let uri = Uri::from_static("http://test?00400100.00400010=CTSCANNER"); + let Query(params) = Query::::try_from_uri(&uri).unwrap(); + + assert_eq!( + params, + MwlQueryParameters { + offset: 0, + limit: 200, + include_field: IncludeField::List(vec![]), + match_criteria: MatchCriteria(vec![( + AttributeSelector::from(( + tags::SCHEDULED_PROCEDURE_STEP_SEQUENCE, + tags::SCHEDULED_STATION_NAME + )), + PrimitiveValue::from("CTSCANNER") + )]), + fuzzy_matching: false, + } + ); + } + #[test] fn parse_query_params_multiple_includefield() { let uri = diff --git a/src/api/qido/service.rs b/src/api/qido/service.rs index 681b1f6..2fd088e 100644 --- a/src/api/qido/service.rs +++ b/src/api/qido/service.rs @@ -1,214 +1,215 @@ -use crate::types::QueryRetrieveLevel; -use crate::types::UI; -use async_trait::async_trait; -use dicom::object::InMemDicomObject; -use futures::stream::BoxStream; -use serde::Deserialize; -use thiserror::Error; - -use crate::api::{deserialize_includefield, IncludeField, MatchCriteria}; - -/// Provides the functionality of a search transaction. -/// -/// -#[async_trait] -pub trait QidoService: Send + Sync { - async fn search(&self, request: SearchRequest) -> SearchResponse; -} - -pub struct SearchRequest { - pub query: ResourceQuery, - pub parameters: QueryParameters, - pub headers: RequestHeaderFields, -} - -/// Query parameters for a QIDO-RS request. -/// -/// -#[derive(Debug, Deserialize, PartialEq)] -#[serde(default)] -pub struct QueryParameters { - #[serde(flatten)] - pub match_criteria: MatchCriteria, - #[serde(rename = "fuzzymatching")] - pub fuzzy_matching: bool, - #[serde(rename = "includefield")] - #[serde(deserialize_with = "deserialize_includefield")] - pub include_field: IncludeField, - pub limit: usize, - pub offset: usize, -} - -impl Default for QueryParameters { - fn default() -> Self { - Self { - match_criteria: MatchCriteria(Vec::new()), - fuzzy_matching: false, - include_field: IncludeField::List(Vec::new()), - limit: 200, - offset: 0, - } - } -} - -#[derive(Debug, Default)] -pub struct RequestHeaderFields { - pub accept: Option, - pub accept_charset: Option, -} - -/// -#[derive(Debug, Default)] -pub struct ResponseHeaderFields { - /// The DICOM Media Type of the response payload. - /// Shall be present if the response has a payload. - pub content_type: Option, - /// Shall be present if no transfer coding has been applied to the payload. - pub content_length: Option, - /// Shall be present if a transfer encoding has been applied to the payload. - pub transfer_encoding: Option, - pub warning: Vec, -} - -pub struct SearchResponse<'a> { - pub stream: BoxStream<'a, Result>, -} - -/// Data used to identify a specific search transaction resource. -/// -/// As an example, the "Study's Series" resource searches for all series in a specified study. -/// This information can be represented as follows: -/// ``` -/// let studys_series_query = ResourceQuery { -/// // Search for series... -/// query_retrieve_level: QueryRetrieveLevel::Series, -/// // for the study with UID 123. -/// study_instance_uid: Some("123"), -/// // Not used as we want to select *all* series. -/// series_instance_uid: None -/// }; -/// ``` -#[derive(Debug)] -pub struct ResourceQuery { - /// The query retrieve level. - pub query_retrieve_level: QueryRetrieveLevel, - /// The UID of the study. - pub study_instance_uid: Option, - /// The UID of the series. - pub series_instance_uid: Option, -} - -#[derive(Debug, Error)] -pub enum SearchError { - #[error(transparent)] - Backend { source: Box }, -} - -#[cfg(test)] -mod tests { - use axum::extract::Query; - use axum::http::Uri; - use dicom::core::PrimitiveValue; - use dicom::dictionary_std::tags; - - use super::*; - - #[test] - fn parse_query_params() { - let uri = Uri::from_static( - "http://test?offset=1&limit=42&includefield=PatientWeight&PatientName=MUSTERMANN^MAX", - ); - let Query(params) = Query::::try_from_uri(&uri).unwrap(); - - assert_eq!( - params, - QueryParameters { - offset: 1, - limit: 42, - include_field: IncludeField::List(vec![tags::PATIENT_WEIGHT]), - match_criteria: MatchCriteria(vec![( - tags::PATIENT_NAME, - PrimitiveValue::from("MUSTERMANN^MAX") - )]), - fuzzy_matching: false, - } - ); - } - - #[test] - fn parse_query_params_multiple_includefield() { - let uri = - Uri::from_static("http://test?offset=1&limit=42&includefield=PatientWeight,00100010"); - let Query(params) = Query::::try_from_uri(&uri).unwrap(); - - assert_eq!( - params, - QueryParameters { - offset: 1, - limit: 42, - include_field: IncludeField::List(vec![tags::PATIENT_WEIGHT, tags::PATIENT_NAME]), - match_criteria: MatchCriteria(vec![]), - fuzzy_matching: false, - } - ); - } - - #[test] - fn parse_query_params_uid_list_match() { - let uri = Uri::from_static("http://test?StudyInstanceUID=1,2,3"); - let Query(params) = Query::::try_from_uri(&uri).unwrap(); - - assert_eq!( - params, - QueryParameters { - offset: 0, - limit: 200, - include_field: IncludeField::List(Vec::new()), - match_criteria: MatchCriteria(vec![( - tags::STUDY_INSTANCE_UID, - PrimitiveValue::Strs( - vec![String::from("1"), String::from("2"), String::from("3")].into() - ) - )]), - fuzzy_matching: false, - } - ); - } - - #[test] - fn parse_query_params_uid_single_value() { - let uri = Uri::from_static("http://test?StudyInstanceUID=1.2.3"); - let Query(params) = Query::::try_from_uri(&uri).unwrap(); - - assert_eq!( - params, - QueryParameters { - offset: 0, - limit: 200, - include_field: IncludeField::List(Vec::new()), - match_criteria: MatchCriteria(vec![( - tags::STUDY_INSTANCE_UID, - PrimitiveValue::from("1.2.3") - )]), - fuzzy_matching: false, - } - ); - } - - #[test] - fn parse_query_params_default() { - let uri = Uri::from_static("http://test"); - let Query(params) = Query::::try_from_uri(&uri).unwrap(); - - assert_eq!( - params, - QueryParameters { - offset: 0, - limit: 200, - include_field: IncludeField::List(Vec::new()), - match_criteria: MatchCriteria(Vec::new()), - fuzzy_matching: false, - } - ); - } -} +use crate::types::QueryRetrieveLevel; +use crate::types::UI; +use async_trait::async_trait; +use dicom::object::InMemDicomObject; +use futures::stream::BoxStream; +use serde::Deserialize; +use thiserror::Error; + +use crate::api::{deserialize_includefield, IncludeField, MatchCriteria}; + +/// Provides the functionality of a search transaction. +/// +/// +#[async_trait] +pub trait QidoService: Send + Sync { + async fn search(&self, request: SearchRequest) -> SearchResponse; +} + +pub struct SearchRequest { + pub query: ResourceQuery, + pub parameters: QueryParameters, + pub headers: RequestHeaderFields, +} + +/// Query parameters for a QIDO-RS request. +/// +/// +#[derive(Debug, Deserialize, PartialEq)] +#[serde(default)] +pub struct QueryParameters { + #[serde(flatten)] + pub match_criteria: MatchCriteria, + #[serde(rename = "fuzzymatching")] + pub fuzzy_matching: bool, + #[serde(rename = "includefield")] + #[serde(deserialize_with = "deserialize_includefield")] + pub include_field: IncludeField, + pub limit: usize, + pub offset: usize, +} + +impl Default for QueryParameters { + fn default() -> Self { + Self { + match_criteria: MatchCriteria(Vec::new()), + fuzzy_matching: false, + include_field: IncludeField::List(Vec::new()), + limit: 200, + offset: 0, + } + } +} + +#[derive(Debug, Default)] +pub struct RequestHeaderFields { + pub accept: Option, + pub accept_charset: Option, +} + +/// +#[derive(Debug, Default)] +pub struct ResponseHeaderFields { + /// The DICOM Media Type of the response payload. + /// Shall be present if the response has a payload. + pub content_type: Option, + /// Shall be present if no transfer coding has been applied to the payload. + pub content_length: Option, + /// Shall be present if a transfer encoding has been applied to the payload. + pub transfer_encoding: Option, + pub warning: Vec, +} + +pub struct SearchResponse<'a> { + pub stream: BoxStream<'a, Result>, +} + +/// Data used to identify a specific search transaction resource. +/// +/// As an example, the "Study's Series" resource searches for all series in a specified study. +/// This information can be represented as follows: +/// ``` +/// let studys_series_query = ResourceQuery { +/// // Search for series... +/// query_retrieve_level: QueryRetrieveLevel::Series, +/// // for the study with UID 123. +/// study_instance_uid: Some("123"), +/// // Not used as we want to select *all* series. +/// series_instance_uid: None +/// }; +/// ``` +#[derive(Debug)] +pub struct ResourceQuery { + /// The query retrieve level. + pub query_retrieve_level: QueryRetrieveLevel, + /// The UID of the study. + pub study_instance_uid: Option, + /// The UID of the series. + pub series_instance_uid: Option, +} + +#[derive(Debug, Error)] +pub enum SearchError { + #[error(transparent)] + Backend { source: Box }, +} + +#[cfg(test)] +mod tests { + use axum::extract::Query; + use axum::http::Uri; + use dicom::core::ops::AttributeSelector; + use dicom::core::PrimitiveValue; + use dicom::dictionary_std::tags; + + use super::*; + + #[test] + fn parse_query_params() { + let uri = Uri::from_static( + "http://test?offset=1&limit=42&includefield=PatientWeight&PatientName=MUSTERMANN^MAX", + ); + let Query(params) = Query::::try_from_uri(&uri).unwrap(); + + assert_eq!( + params, + QueryParameters { + offset: 1, + limit: 42, + include_field: IncludeField::List(vec![tags::PATIENT_WEIGHT]), + match_criteria: MatchCriteria(vec![( + AttributeSelector::from(tags::PATIENT_NAME), + PrimitiveValue::from("MUSTERMANN^MAX") + )]), + fuzzy_matching: false, + } + ); + } + + #[test] + fn parse_query_params_multiple_includefield() { + let uri = + Uri::from_static("http://test?offset=1&limit=42&includefield=PatientWeight,00100010"); + let Query(params) = Query::::try_from_uri(&uri).unwrap(); + + assert_eq!( + params, + QueryParameters { + offset: 1, + limit: 42, + include_field: IncludeField::List(vec![tags::PATIENT_WEIGHT, tags::PATIENT_NAME]), + match_criteria: MatchCriteria(vec![]), + fuzzy_matching: false, + } + ); + } + + #[test] + fn parse_query_params_uid_list_match() { + let uri = Uri::from_static("http://test?StudyInstanceUID=1,2,3"); + let Query(params) = Query::::try_from_uri(&uri).unwrap(); + + assert_eq!( + params, + QueryParameters { + offset: 0, + limit: 200, + include_field: IncludeField::List(Vec::new()), + match_criteria: MatchCriteria(vec![( + AttributeSelector::from(tags::STUDY_INSTANCE_UID), + PrimitiveValue::Strs( + vec![String::from("1"), String::from("2"), String::from("3")].into() + ) + )]), + fuzzy_matching: false, + } + ); + } + + #[test] + fn parse_query_params_uid_single_value() { + let uri = Uri::from_static("http://test?StudyInstanceUID=1.2.3"); + let Query(params) = Query::::try_from_uri(&uri).unwrap(); + + assert_eq!( + params, + QueryParameters { + offset: 0, + limit: 200, + include_field: IncludeField::List(Vec::new()), + match_criteria: MatchCriteria(vec![( + AttributeSelector::from(tags::STUDY_INSTANCE_UID), + PrimitiveValue::from("1.2.3") + )]), + fuzzy_matching: false, + } + ); + } + + #[test] + fn parse_query_params_default() { + let uri = Uri::from_static("http://test"); + let Query(params) = Query::::try_from_uri(&uri).unwrap(); + + assert_eq!( + params, + QueryParameters { + offset: 0, + limit: 200, + include_field: IncludeField::List(Vec::new()), + match_criteria: MatchCriteria(Vec::new()), + fuzzy_matching: false, + } + ); + } +} diff --git a/src/backend/dimse/mwl.rs b/src/backend/dimse/mwl.rs index 744d15e..b421cc4 100644 --- a/src/backend/dimse/mwl.rs +++ b/src/backend/dimse/mwl.rs @@ -37,11 +37,11 @@ impl MwlService for DimseMwlService { let default_tags = WORKITEM_SEARCH_TAGS; for tag in default_tags { - attributes.push((*tag, PrimitiveValue::Empty)); + attributes.push((AttributeSelector::from(*tag), PrimitiveValue::Empty)); } - for (tag, value) in request.parameters.match_criteria.into_inner() { - attributes.push((tag, value)); + for (selector, value) in request.parameters.match_criteria.into_inner() { + attributes.push((selector, value)); } match request.parameters.include_field { @@ -52,15 +52,14 @@ impl MwlService for DimseMwlService { } IncludeField::List(tags) => { for tag in tags { - attributes.push((tag, PrimitiveValue::Empty)); + attributes.push((AttributeSelector::from(tag), PrimitiveValue::Empty)); } } }; - for (tag, value) in attributes { - if let Err(err) = identifier.apply(AttributeOp::new( - AttributeSelector::from(tag), - AttributeAction::Set(value), - )) { + for (selector, value) in attributes { + if let Err(err) = + identifier.apply(AttributeOp::new(selector, AttributeAction::Set(value))) + { warn!("Skipped attribute operation: {err}"); } } diff --git a/src/backend/dimse/qido.rs b/src/backend/dimse/qido.rs index 9cb316f..d78c4f1 100644 --- a/src/backend/dimse/qido.rs +++ b/src/backend/dimse/qido.rs @@ -45,11 +45,11 @@ impl QidoService for DimseQidoService { }; for tag in default_tags { - attributes.push((*tag, PrimitiveValue::Empty)); + attributes.push((AttributeSelector::from(*tag), PrimitiveValue::Empty)); } - for (tag, value) in request.parameters.match_criteria.into_inner() { - attributes.push((tag, value)); + for (selector, value) in request.parameters.match_criteria.into_inner() { + attributes.push((selector, value)); } match request.parameters.include_field { @@ -60,29 +60,34 @@ impl QidoService for DimseQidoService { } IncludeField::List(tags) => { for tag in tags { - attributes.push((tag, PrimitiveValue::Empty)); + attributes.push((AttributeSelector::from(tag), PrimitiveValue::Empty)); } } }; attributes.push(( - tags::QUERY_RETRIEVE_LEVEL, + AttributeSelector::from(tags::QUERY_RETRIEVE_LEVEL), PrimitiveValue::from(request.query.query_retrieve_level), )); if let Some(study) = request.query.study_instance_uid { - attributes.push((tags::STUDY_INSTANCE_UID, PrimitiveValue::from(study))); + attributes.push(( + AttributeSelector::from(tags::STUDY_INSTANCE_UID), + PrimitiveValue::from(study), + )); } if let Some(series) = request.query.series_instance_uid { - attributes.push((tags::SERIES_INSTANCE_UID, PrimitiveValue::from(series))); + attributes.push(( + AttributeSelector::from(tags::SERIES_INSTANCE_UID), + PrimitiveValue::from(series), + )); } - for (tag, value) in attributes { - if let Err(err) = identifier.apply(AttributeOp::new( - AttributeSelector::from(tag), - AttributeAction::Set(value), - )) { + for (selector, value) in attributes { + if let Err(err) = + identifier.apply(AttributeOp::new(selector, AttributeAction::Set(value))) + { warn!("Skipped attribute operation: {err}"); } }