From 9c0038b80362b1abb1ca7919f3f3c957a97eb2cf Mon Sep 17 00:00:00 2001 From: Nicolas Kamzol Date: Thu, 12 Feb 2026 09:55:29 +0100 Subject: [PATCH 1/3] Support uid-list-match syntax --- src/api/mwl/service.rs | 49 ++++++++++++++++++++++++++++++++++++++++- src/api/qido/service.rs | 49 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/api/mwl/service.rs b/src/api/mwl/service.rs index 0e3e414..f35019d 100644 --- a/src/api/mwl/service.rs +++ b/src/api/mwl/service.rs @@ -72,9 +72,14 @@ fn to_value(entry: &DataDictionaryEntryRef, raw_value: &str) -> Result Ok(PrimitiveValue::from(raw_value)), + // UID list matching: comma-separated UIDs become a multi-valued element + // See https://dicom.nema.org/medical/dicom/current/output/html/part18.html#sect_8.3.4.1 + VR::UI => { + let uids: Vec = raw_value.split(',').map(|s| s.trim().to_owned()).collect(); + Ok(PrimitiveValue::Strs(uids.into())) + } // Numeric VRs, parsing required VR::SS => { let value = raw_value.parse::().map_err(|err| err.to_string())?; @@ -310,4 +315,46 @@ mod tests { } ); } + + #[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, + MwlQueryParameters { + 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, + MwlQueryParameters { + 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, + } + ); + } } diff --git a/src/api/qido/service.rs b/src/api/qido/service.rs index c02b21b..1d7c73c 100644 --- a/src/api/qido/service.rs +++ b/src/api/qido/service.rs @@ -75,9 +75,14 @@ fn to_value(entry: &DataDictionaryEntryRef, raw_value: &str) -> Result Ok(PrimitiveValue::from(raw_value)), + // uid-list-match: a comma-separated list of UIDs + // See https://dicom.nema.org/medical/dicom/current/output/html/part18.html#sect_8.3.4.1 + VR::UI => { + let uids: Vec = raw_value.split(',').map(|s| s.trim().to_owned()).collect(); + Ok(PrimitiveValue::Strs(uids.into())) + } // Numeric VRs, parsing required VR::SS => { let value = raw_value.parse::().map_err(|err| err.to_string())?; @@ -321,6 +326,48 @@ mod tests { ); } + #[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"); From 25e491bd738170764e2f2a04a8886034f1bfefc7 Mon Sep 17 00:00:00 2001 From: Nicolas Kamzol Date: Thu, 12 Feb 2026 11:04:06 +0100 Subject: [PATCH 2/3] Remove duplicated MatchCriteria and IncludeField --- src/api/mod.rs | 184 +++++++++++++++++++++++++++++++ src/api/mwl/service.rs | 223 +------------------------------------- src/api/qido/service.rs | 181 +------------------------------ src/backend/dimse/mwl.rs | 5 +- src/backend/dimse/qido.rs | 3 +- 5 files changed, 196 insertions(+), 400 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index e1a76c0..2c4fc02 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,5 +1,13 @@ +use std::collections::HashMap; +use std::fmt::Formatter; + use crate::AppState; use axum::Router; +use dicom::core::dictionary::{DataDictionaryEntry, DataDictionaryEntryRef}; +use dicom::core::{DataDictionary, PrimitiveValue, Tag, VR}; +use dicom::object::StandardDataDictionary; +use serde::de::{Error, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer}; mod aets; mod home; @@ -27,3 +35,179 @@ pub fn routes(base_path: &str) -> Router { base_path => Router::new().nest(base_path, router), } } + +/// Match Query Parameters for QIDO and MWL requests. +#[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_primitive_value(entry, &value) + .map(|primitive| (entry.tag.inner(), primitive)) + }) + }) + .collect::>()?; + Ok(Self(criteria)) + } +} + +/// helper function to convert a query parameter value to a PrimitiveValue +fn to_primitive_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::UR + | VR::UT => Ok(PrimitiveValue::from(raw_value)), + // uid-list-match: a comma-separated list of UIDs + // See https://dicom.nema.org/medical/dicom/current/output/html/part18.html#sect_8.3.4.1 + VR::UI => { + let uids: Vec = raw_value.split(',').map(|s| s.trim().to_owned()).collect(); + Ok(PrimitiveValue::Strs(uids.into())) + } + // 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, 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) +} diff --git a/src/api/mwl/service.rs b/src/api/mwl/service.rs index f35019d..0ed07f8 100644 --- a/src/api/mwl/service.rs +++ b/src/api/mwl/service.rs @@ -1,15 +1,11 @@ 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 serde::Deserialize; use thiserror::Error; +use crate::api::{deserialize_includefield, IncludeField, MatchCriteria}; + /// Provides the functionality of a modality worklist transaction. /// /// @@ -52,176 +48,6 @@ impl Default for MwlQueryParameters { } } -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::UR - | VR::UT => Ok(PrimitiveValue::from(raw_value)), - // UID list matching: comma-separated UIDs become a multi-valued element - // See https://dicom.nema.org/medical/dicom/current/output/html/part18.html#sect_8.3.4.1 - VR::UI => { - let uids: Vec = raw_value.split(',').map(|s| s.trim().to_owned()).collect(); - Ok(PrimitiveValue::Strs(uids.into())) - } - // 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, @@ -255,6 +81,7 @@ pub enum MwlSearchError { mod tests { use axum::extract::Query; use axum::http::Uri; + use dicom::core::PrimitiveValue; use dicom::dictionary_std::tags; use super::*; @@ -315,46 +142,4 @@ mod tests { } ); } - - #[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, - MwlQueryParameters { - 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, - MwlQueryParameters { - 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, - } - ); - } } diff --git a/src/api/qido/service.rs b/src/api/qido/service.rs index 1d7c73c..681b1f6 100644 --- a/src/api/qido/service.rs +++ b/src/api/qido/service.rs @@ -1,17 +1,13 @@ use crate::types::QueryRetrieveLevel; use crate::types::UI; 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 serde::Deserialize; use thiserror::Error; +use crate::api::{deserialize_includefield, IncludeField, MatchCriteria}; + /// Provides the functionality of a search transaction. /// /// @@ -55,176 +51,6 @@ impl Default for QueryParameters { } } -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::UR - | VR::UT => Ok(PrimitiveValue::from(raw_value)), - // uid-list-match: a comma-separated list of UIDs - // See https://dicom.nema.org/medical/dicom/current/output/html/part18.html#sect_8.3.4.1 - VR::UI => { - let uids: Vec = raw_value.split(',').map(|s| s.trim().to_owned()).collect(); - Ok(PrimitiveValue::Strs(uids.into())) - } - // 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 RequestHeaderFields { pub accept: Option, @@ -282,6 +108,7 @@ pub enum SearchError { mod tests { use axum::extract::Query; use axum::http::Uri; + use dicom::core::PrimitiveValue; use dicom::dictionary_std::tags; use super::*; diff --git a/src/backend/dimse/mwl.rs b/src/backend/dimse/mwl.rs index c098235..744d15e 100644 --- a/src/backend/dimse/mwl.rs +++ b/src/backend/dimse/mwl.rs @@ -1,7 +1,6 @@ use crate::api::mwl::WORKITEM_SEARCH_TAGS; -use crate::api::mwl::{ - IncludeField, MwlSearchError, MwlSearchRequest, MwlSearchResponse, MwlService, -}; +use crate::api::mwl::{MwlSearchError, MwlSearchRequest, MwlSearchResponse, MwlService}; +use crate::api::IncludeField; use crate::backend::dimse::association; use crate::backend::dimse::cfind::findscu::{FindServiceClassUser, FindServiceClassUserOptions}; use crate::backend::dimse::next_message_id; diff --git a/src/backend/dimse/qido.rs b/src/backend/dimse/qido.rs index b6adfa0..9cb316f 100644 --- a/src/backend/dimse/qido.rs +++ b/src/backend/dimse/qido.rs @@ -1,5 +1,6 @@ -use crate::api::qido::{IncludeField, QidoService, SearchError, SearchRequest, SearchResponse}; +use crate::api::qido::{QidoService, SearchError, SearchRequest, SearchResponse}; use crate::api::qido::{INSTANCE_SEARCH_TAGS, SERIES_SEARCH_TAGS, STUDY_SEARCH_TAGS}; +use crate::api::IncludeField; use crate::backend::dimse::association; use crate::backend::dimse::cfind::findscu::{FindServiceClassUser, FindServiceClassUserOptions}; use crate::backend::dimse::next_message_id; From 76b87ae95ef91b957482f16675742b9ed07e0f56 Mon Sep 17 00:00:00 2001 From: Nicolas Kamzol Date: Thu, 12 Feb 2026 11:49:43 +0100 Subject: [PATCH 3/3] changelog: add entry for uid-list-matching --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8143e4..5e177a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- QIDO-RS and MWL services now support `uid-list-matching` syntax for match query parameters. + ## [0.3.0] ### Added