diff --git a/src/api/mod.rs b/src/api/mod.rs index 2c7b5b1..45c45c4 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2,6 +2,7 @@ use crate::AppState; use axum::Router; mod aets; +pub mod mwl; pub mod qido; pub mod stow; pub mod wado; @@ -12,6 +13,7 @@ pub fn routes() -> Router { Router::new() .merge(qido::routes()) .merge(wado::routes()) - .merge(stow::routes()), + .merge(stow::routes()) + .merge(mwl::routes()), ) } diff --git a/src/api/mwl/mod.rs b/src/api/mwl/mod.rs new file mode 100644 index 0000000..375f3ee --- /dev/null +++ b/src/api/mwl/mod.rs @@ -0,0 +1,39 @@ +mod routes; +mod service; + +pub use routes::routes; +pub use service::*; + +use dicom::core::Tag; +use dicom::dictionary_std::tags; + +/// +pub const WORKITEM_SEARCH_TAGS: &[Tag] = &[ + // Scheduled Procedure Step + tags::SCHEDULED_PROCEDURE_STEP_SEQUENCE, + tags::SCHEDULED_STATION_AE_TITLE, + tags::SCHEDULED_PROCEDURE_STEP_START_DATE, + tags::SCHEDULED_PROCEDURE_STEP_START_TIME, + tags::MODALITY, + tags::SCHEDULED_PERFORMING_PHYSICIAN_NAME, + tags::SCHEDULED_PROCEDURE_STEP_DESCRIPTION, + tags::SCHEDULED_STATION_NAME, + tags::SCHEDULED_PROCEDURE_STEP_LOCATION, + tags::REFERENCED_DEFINED_PROTOCOL_SEQUENCE, + tags::REFERENCED_SOP_CLASS_UID, + tags::REFERENCED_SOP_INSTANCE_UID, + // Requested Procedure + tags::REQUESTED_PROCEDURE_ID, + tags::REQUESTED_PROCEDURE_DESCRIPTION, + tags::REQUESTED_PROCEDURE_CODE_SEQUENCE, + tags::STUDY_INSTANCE_UID, + tags::STUDY_DATE, + tags::STUDY_TIME, + // Patient Identification + tags::PATIENT_NAME, + tags::PATIENT_ID, + tags::ISSUER_OF_PATIENT_ID, + // Patient Demographics + tags::PATIENT_BIRTH_DATE, + tags::PATIENT_SEX, +]; diff --git a/src/api/mwl/routes.rs b/src/api/mwl/routes.rs new file mode 100644 index 0000000..7d82c54 --- /dev/null +++ b/src/api/mwl/routes.rs @@ -0,0 +1,70 @@ +use crate::backend::ServiceProvider; +use crate::AppState; +use axum::http::header; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::Router; +use axum_extra::extract::Query; +use axum_streams::StreamBodyAs; +use dicom::object::InMemDicomObject; +use dicom_json::DicomJson; +use futures::TryStreamExt; +use tracing::instrument; + +use super::{MwlQueryParameters, MwlRequestHeaderFields, MwlSearchError, MwlSearchRequest}; + +/// HTTP Router for the Modality Worklist. +/// +/// +#[rustfmt::skip] +pub fn routes() -> Router { + Router::new() + .route("/modality-scheduled-procedure-steps", get(all_workitems)) +} + +// MWL-RS implementation +async fn mwl_handler(provider: ServiceProvider, request: MwlSearchRequest) -> impl IntoResponse { + if let Some(mwl) = provider.mwl { + let response = mwl.search(request).await; + let matches: Result, MwlSearchError> = + response.stream.try_collect().await; + + match matches { + Ok(matches) => { + if matches.is_empty() { + StatusCode::NO_CONTENT.into_response() + } else { + let json: Vec> = + matches.into_iter().map(DicomJson::from).collect(); + + axum::response::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(StreamBodyAs::json_array(futures::stream::iter(json))) + .unwrap() + .into_response() + } + } + Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), + } + } else { + ( + StatusCode::SERVICE_UNAVAILABLE, + "MWL-RS endpoint is disabled", + ) + .into_response() + } +} + +#[instrument(skip_all)] +async fn all_workitems( + provider: ServiceProvider, + Query(parameters): Query, +) -> impl IntoResponse { + let request = MwlSearchRequest { + parameters, + headers: MwlRequestHeaderFields::default(), + }; + mwl_handler(provider, request).await +} diff --git a/src/api/mwl/service.rs b/src/api/mwl/service.rs new file mode 100644 index 0000000..0e3e414 --- /dev/null +++ b/src/api/mwl/service.rs @@ -0,0 +1,313 @@ +use async_trait::async_trait; +use dicom::core::dictionary::{DataDictionaryEntry, DataDictionaryEntryRef}; +use dicom::core::{DataDictionary, PrimitiveValue, Tag, VR}; +use dicom::dictionary_std::StandardDataDictionary; +use dicom::object::InMemDicomObject; +use futures::stream::BoxStream; +use serde::de::{Error, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer}; +use std::collections::HashMap; +use std::fmt::Formatter; +use thiserror::Error; + +/// Provides the functionality of a modality worklist transaction. +/// +/// +#[async_trait] +pub trait MwlService: Send + Sync { + async fn search(&self, request: MwlSearchRequest) -> MwlSearchResponse; +} + +pub struct MwlSearchRequest { + pub parameters: MwlQueryParameters, + pub headers: MwlRequestHeaderFields, +} + +/// Query parameters for a MWL-RS request. +/// +/// +#[derive(Debug, Deserialize, PartialEq)] +#[serde(default)] +pub struct MwlQueryParameters { + #[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 MwlQueryParameters { + fn default() -> Self { + Self { + match_criteria: MatchCriteria(Vec::new()), + fuzzy_matching: false, + include_field: IncludeField::List(Vec::new()), + limit: 200, + offset: 0, + } + } +} + +fn to_value(entry: &DataDictionaryEntryRef, raw_value: &str) -> Result { + if raw_value.is_empty() { + return Ok(PrimitiveValue::Empty); + } + match entry.vr.relaxed() { + // String-like VRs, no parsing required + VR::AE + | VR::AS + | VR::CS + | VR::DA + | VR::DS + | VR::DT + | VR::IS + | VR::LO + | VR::LT + | VR::PN + | VR::SH + | VR::ST + | VR::TM + | VR::UC + | VR::UI + | VR::UR + | VR::UT => Ok(PrimitiveValue::from(raw_value)), + // Numeric VRs, parsing required + VR::SS => { + let value = raw_value.parse::().map_err(|err| err.to_string())?; + Ok(PrimitiveValue::from(value)) + } + VR::US => { + let value = raw_value.parse::().map_err(|err| err.to_string())?; + Ok(PrimitiveValue::from(value)) + } + VR::SL => { + let value = raw_value.parse::().map_err(|err| err.to_string())?; + Ok(PrimitiveValue::from(value)) + } + VR::UL => { + let value = raw_value.parse::().map_err(|err| err.to_string())?; + Ok(PrimitiveValue::from(value)) + } + VR::SV => { + let value = raw_value.parse::().map_err(|err| err.to_string())?; + Ok(PrimitiveValue::from(value)) + } + VR::UV => { + let value = raw_value.parse::().map_err(|err| err.to_string())?; + Ok(PrimitiveValue::from(value)) + } + VR::FL => { + let value = raw_value.parse::().map_err(|err| err.to_string())?; + Ok(PrimitiveValue::from(value)) + } + VR::FD => { + let value = raw_value.parse::().map_err(|err| err.to_string())?; + Ok(PrimitiveValue::from(value)) + } + _ => Err(format!( + "Attribute {} cannot be used for matching due to unsupported VR {}", + entry.tag(), + entry.vr.relaxed() + )), + } +} + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(try_from = "HashMap")] +pub struct MatchCriteria(Vec<(Tag, PrimitiveValue)>); + +impl MatchCriteria { + pub fn into_inner(self) -> Vec<(Tag, PrimitiveValue)> { + self.0 + } +} + +impl TryFrom> for MatchCriteria { + type Error = String; + + fn try_from(value: HashMap) -> Result { + let criteria: Vec<(Tag, 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_value(entry, &value).map(|primitive| (entry.tag.inner(), primitive)) + }) + }) + .collect::>()?; + Ok(Self(criteria)) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum IncludeField { + All, + List(Vec), +} + +impl Default for IncludeField { + fn default() -> Self { + Self::List(Vec::new()) + } +} + +/// Custom deserialization visitor for repeated `includefield` query parameters. +/// It collects all `includefield` parameters in [`crate::dicomweb::qido::IncludeField::List`]. +/// If at least one `includefield` parameter has the value `all`, +/// [`crate::dicomweb::qido::IncludeField::All`] is returned instead. +struct IncludeFieldVisitor; + +impl<'a> Visitor<'a> for IncludeFieldVisitor { + type Value = IncludeField; + + fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { + write!(formatter, "a value of <{{attribute}}* | all>") + } + + fn visit_str(self, v: &str) -> Result + where + E: Error, + { + if v.to_lowercase() == "all" { + Ok(IncludeField::All) + } else { + v.split(',') + .map(|v| { + let entry = StandardDataDictionary + .by_expr(v) + .ok_or_else(|| E::custom(format!("unknown tag {v}")))?; + Ok(entry.tag()) + }) + .collect::, _>>() + .map(IncludeField::List) + } + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'a>, + { + let mut items = Vec::new(); + while let Some(item) = seq.next_element::()? { + // If includefield=all, then all other includefield parameters are ignored + if &item.to_lowercase() == "all" { + return Ok(IncludeField::All); + } + + let entry = StandardDataDictionary + .by_expr(&item) + .ok_or_else(|| Error::custom(format!("unknown tag {item}")))?; + items.push(entry.tag()); + } + Ok(IncludeField::List(items)) + } +} + +/// See [`IncludeFieldVisitor`]. +fn deserialize_includefield<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + deserializer.deserialize_any(IncludeFieldVisitor) +} + +#[derive(Debug, Default)] +pub struct MwlRequestHeaderFields { + 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 MwlSearchResponse<'a> { + pub stream: BoxStream<'a, Result>, +} + +#[derive(Debug, Error)] +pub enum MwlSearchError { + #[error(transparent)] + Backend { source: Box }, +} + +#[cfg(test)] +mod tests { + use axum::extract::Query; + use axum::http::Uri; + 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, + MwlQueryParameters { + 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, + MwlQueryParameters { + 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_default() { + let uri = Uri::from_static("http://test"); + let Query(params) = Query::::try_from_uri(&uri).unwrap(); + + assert_eq!( + params, + MwlQueryParameters { + offset: 0, + limit: 200, + include_field: IncludeField::List(Vec::new()), + match_criteria: MatchCriteria(Vec::new()), + fuzzy_matching: false, + } + ); + } +} diff --git a/src/backend/dimse/mod.rs b/src/backend/dimse/mod.rs index a4d1786..3537a89 100644 --- a/src/backend/dimse/mod.rs +++ b/src/backend/dimse/mod.rs @@ -3,6 +3,7 @@ //! - WADO-RS is implemented as a move service class user (C-MOVE service). //! It depends on a store service class provider that must run in the background. //! - STOR-RS is implemented as a store service class user (C-STORE service). +//! - MWL-RS is implemented as a find service class user (C-FIND service). //! mod cecho; @@ -11,6 +12,7 @@ pub mod cmove; mod cstore; pub mod association; +pub mod mwl; pub mod qido; pub mod stow; pub mod wado; diff --git a/src/backend/dimse/mwl.rs b/src/backend/dimse/mwl.rs new file mode 100644 index 0000000..96aee43 --- /dev/null +++ b/src/backend/dimse/mwl.rs @@ -0,0 +1,88 @@ +use crate::api::mwl::WORKITEM_SEARCH_TAGS; +use crate::api::mwl::{ + IncludeField, MwlSearchError, MwlSearchRequest, MwlSearchResponse, MwlService, +}; +use crate::backend::dimse::association; +use crate::backend::dimse::cfind::findscu::{FindServiceClassUser, FindServiceClassUserOptions}; +use crate::backend::dimse::next_message_id; +use crate::types::Priority; +use crate::types::QueryInformationModel; +use crate::types::QueryRetrieveLevel; +use association::pool::AssociationPool; +use async_trait::async_trait; +use dicom::core::ops::{ApplyOp, AttributeAction, AttributeOp, AttributeSelector}; +use dicom::core::PrimitiveValue; +use dicom::dictionary_std::tags; +use dicom::object::InMemDicomObject; +use futures::{StreamExt, TryStreamExt}; +use std::time::Duration; +use tracing::warn; + +pub struct DimseMwlService { + findscu: FindServiceClassUser, +} + +impl DimseMwlService { + pub const fn new(pool: AssociationPool, timeout: Duration) -> Self { + let findscu = FindServiceClassUser::new(pool, timeout); + Self { findscu } + } +} + +#[async_trait] +impl MwlService for DimseMwlService { + async fn search(&self, request: MwlSearchRequest) -> MwlSearchResponse { + let mut identifier = InMemDicomObject::new_empty(); + + // There are always at least 10 attributes + the query retrieve level + let mut attributes = Vec::with_capacity(11); + + let default_tags = WORKITEM_SEARCH_TAGS; + + for tag in default_tags { + attributes.push((*tag, PrimitiveValue::Empty)); + } + + for (tag, value) in request.parameters.match_criteria.into_inner() { + attributes.push((tag, value)); + } + + match request.parameters.include_field { + IncludeField::All => { + // TODO: includefield=all + // It is not known which tags are returned by the origin server, but at least all + // tags marked as optional for the respective QueryRetrieveLevels can be returned + } + IncludeField::List(tags) => { + for tag in tags { + attributes.push((tag, PrimitiveValue::Empty)); + } + } + }; + for (tag, value) in attributes { + if let Err(err) = identifier.apply(AttributeOp::new( + AttributeSelector::from(tag), + AttributeAction::Set(value), + )) { + warn!("Skipped attribute operation: {err}"); + } + } + let options = FindServiceClassUserOptions { + query_information_model: QueryInformationModel::Worklist, + message_id: next_message_id(), + priority: Priority::Medium, + identifier, + }; + let stream = self + .findscu + .invoke(options) + .map_err(|err| MwlSearchError::Backend { + source: Box::new(err), + }) + .skip(request.parameters.offset) + .take(request.parameters.limit) + .boxed(); + + MwlSearchResponse { stream } + } +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 842f1a8..38aec3e 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -1,6 +1,8 @@ +use crate::api::mwl::MwlService; use crate::api::qido::QidoService; use crate::api::stow::StowService; use crate::api::wado::WadoService; +use crate::backend::dimse::mwl::DimseMwlService; use crate::backend::dimse::qido::DimseQidoService; use crate::backend::dimse::stow::DimseStowService; use crate::backend::dimse::wado::DimseWadoService; @@ -24,6 +26,7 @@ pub struct ServiceProvider { pub qido: Option>, pub wado: Option>, pub stow: Option>, + pub mwl: Option>, } #[async_trait] @@ -74,6 +77,10 @@ where pool.to_owned(), Duration::from_millis(ae_config.stow.timeout), ))), + mwl: Some(Box::new(DimseMwlService::new( + pool.to_owned(), + Duration::from_millis(ae_config.mwl.timeout), + ))), } } // For some reason serde doesn't work with feature-gated enum variants. @@ -83,11 +90,13 @@ where qido: None, wado: None, stow: None, + mwl: None, }, BackendConfig::S3(config) => Self { qido: None, wado: Some(Box::new(S3WadoService::new(&config))), stow: None, + mwl: None, }, }; diff --git a/src/config/mod.rs b/src/config/mod.rs index b84c61c..5bc1da2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -29,6 +29,8 @@ pub struct ApplicationEntityConfig { pub wado: WadoConfig, #[serde(default, rename = "stow-rs")] pub stow: StowConfig, + #[serde(default, rename = "mwl-rs")] + pub mwl: MwlConfig, } #[derive(Debug, Clone, Deserialize)] @@ -179,6 +181,18 @@ impl Default for StowConfig { } } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct MwlConfig { + pub timeout: u64, +} + +impl Default for MwlConfig { + fn default() -> Self { + Self { timeout: 30_000 } + } +} + impl AppConfig { /// Loads the application configuration from the following sources: /// 1. Defaults (defined in `defaults.toml`)