Skip to main content

dicom_web/
lib.rs

1//! This crate contains a DICOMweb client for querying and retrieving DICOM objects.
2//!
3//! It supports the QIDO-RS, WADO-RS, STOW-RS, ASDO-RS and MWL-RS DICOMweb services for querying, retrieving, and storing DICOM objects.
4//! The HTTP requests are made using the reqwest crate, which is a high-level HTTP client for Rust.
5//!
6//! # Examples
7//!
8//! Query all studies from a DICOMweb server (with authentication):
9//!
10//! ```no_run
11//! use dicom_dictionary_std::tags;
12//! use dicom_web::DicomWebClient;
13//!
14//! async fn foo()
15//! {
16//!   let mut client = DicomWebClient::with_single_url("http://localhost:8042");
17//!   client.set_basic_auth("orthanc", "orthanc");
18//!
19//!   let studies = client.query_studies().run().await.unwrap();
20//!
21//!   for study in studies {
22//!       let study_instance_uid = study.element(tags::STUDY_INSTANCE_UID).unwrap().to_str().unwrap();
23//!       println!("Study: {}", study_instance_uid);
24//!   }
25//! }
26//! ```
27//!
28//! To retrieve a DICOM study from a DICOMweb server:
29//! ```no_run
30//! use dicom_dictionary_std::tags;
31//! use dicom_web::DicomWebClient;
32//! use futures_util::StreamExt;
33//!
34//! async fn foo()
35//! {
36//!   let mut client = DicomWebClient::with_single_url("http://localhost:8042");
37//!   client.set_basic_auth("orthanc", "orthanc");
38//!   
39//!   let study_instance_uid = "1.2.276.0.89.300.10035584652.20181014.93645";
40//!   
41//!   let mut study_objects = client.retrieve_study(study_instance_uid).run().await.unwrap();
42//!
43//!   while let Some(object) = study_objects.next().await {
44//!       let object = object.unwrap();
45//!       let sop_instance_uid = object.element(tags::SOP_INSTANCE_UID).unwrap().to_str().unwrap();
46//!       println!("Instance: {}", sop_instance_uid);
47//!   }
48//! }
49//! ```
50use dicom_core::ops::{AttributeSelector, AttributeSelectorStep};
51use mediatype::names::{APPLICATION, DICOM, JSON, OCTET_STREAM};
52use mediatype::{MediaType, MediaTypeError};
53use multipart_rs::MultipartType;
54use reqwest::StatusCode;
55use snafu::Snafu;
56use std::collections::HashMap;
57
58mod asdo;
59mod mwl;
60mod qido;
61mod stow;
62mod wado;
63/// The DICOMweb client for querying and retrieving DICOM objects.
64/// Can be reused for multiple requests.
65#[derive(Debug, Clone)]
66pub struct DicomWebClient {
67    wado_url: String,
68    qido_url: String,
69    stow_url: String,
70
71    // Basic Auth
72    pub(crate) username: Option<String>,
73    pub(crate) password: Option<String>,
74    // Bearer Token
75    pub(crate) bearer_token: Option<String>,
76    // Headers
77    pub(crate) extra_headers: HashMap<String, String>,
78
79    pub(crate) client: reqwest::Client,
80}
81
82/// An error returned when parsing an invalid tag range.
83#[derive(Debug, Snafu)]
84#[snafu(visibility(pub(crate)))]
85pub enum DicomWebError {
86    #[snafu(display("Failed to perform HTTP request"))]
87    RequestFailed { url: String, source: reqwest::Error },
88    #[snafu(display("Failed to deserialize response from server"))]
89    DeserializationFailed { source: reqwest::Error },
90    #[snafu(display("Failed to parse multipart response"))]
91    MultipartReaderFailed {
92        source: multipart_rs::MultipartError,
93    },
94    #[snafu(display("Failed to read DICOM object from multipart item"))]
95    DicomReaderFailed { source: dicom_object::ReadError },
96    #[snafu(display("HTTP status code indicates failure: {}", status_code))]
97    HttpStatusFailure { status_code: StatusCode },
98    #[snafu(display("Multipart item missing Content-Type header"))]
99    MissingContentTypeHeader,
100    #[snafu(display("Unexpected content type: {}", content_type))]
101    UnexpectedContentType { content_type: String },
102    #[snafu(display("Failed to parse content type: {}", source))]
103    ContentTypeParseFailed { source: MediaTypeError },
104    #[snafu(display("Unexpected multipart type: {:?}", multipart_type))]
105    UnexpectedMultipartType { multipart_type: MultipartType },
106    #[snafu(display("Empty response"))]
107    EmptyResponse,
108    #[snafu(display("Other error: {}", message))]
109    Other { message: String },
110}
111
112impl DicomWebClient {
113    /// Set the basic authentication for the DICOMWeb client. Will be passed in the Authorization header.
114    pub fn set_basic_auth(&mut self, username: &str, password: &str) -> &Self {
115        self.username = Some(username.to_string());
116        self.password = Some(password.to_string());
117        self
118    }
119
120    /// Set the bearer token for the DICOMWeb client. Will be passed in the Authorization header.
121    pub fn set_bearer_token(&mut self, token: &str) -> &Self {
122        self.bearer_token = Some(token.to_string());
123        self
124    }
125
126    pub fn add_header(&mut self, key: &str, value: &str) -> &Self {
127        self.extra_headers
128            .insert(key.to_string(), value.to_string());
129        self
130    }
131
132    /// Create a new DICOMWeb client with the same URL for all services (WADO-RS, QIDO-RS, STOW-RS).
133    pub fn with_single_url(url: &str) -> DicomWebClient {
134        DicomWebClient {
135            wado_url: url.to_string(),
136            qido_url: url.to_string(),
137            stow_url: url.to_string(),
138            client: reqwest::Client::new(),
139            extra_headers: HashMap::new(),
140            bearer_token: None,
141            username: None,
142            password: None,
143        }
144    }
145
146    /// Create a new DICOMWeb client with separate URLs for each service.
147    pub fn with_separate_urls(wado_url: &str, qido_url: &str, stow_url: &str) -> DicomWebClient {
148        DicomWebClient {
149            wado_url: wado_url.to_string(),
150            qido_url: qido_url.to_string(),
151            stow_url: stow_url.to_string(),
152            extra_headers: HashMap::new(),
153            client: reqwest::Client::new(),
154            bearer_token: None,
155            username: None,
156            password: None,
157        }
158    }
159}
160
161/// Helper function to convert an AttributeSelector to a string for use in query parameters
162pub(crate) fn selector_to_string(selector: &AttributeSelector) -> String {
163    let mut result = String::new();
164
165    for step in selector.iter() {
166        // If this is not the first step, we need to add a dot separator
167        if !result.is_empty() {
168            result.push_str(".");
169        }
170
171        match step {
172            AttributeSelectorStep::Tag(tag) => {
173                result.push_str(&format!("{:04x}{:04x}", tag.group(), tag.element()));
174            }
175            AttributeSelectorStep::Nested { tag, item } => {
176                if *item == 0 {
177                    // If the item index is 0, we can omit it (it defaults to 1 in DICOMweb)
178                    result.push_str(&format!("{:04x}{:04x}", tag.group(), tag.element()));
179                } else {
180                    result.push_str(&format!(
181                        "{:04x}{:04x}[{}]",
182                        tag.group(),
183                        tag.element(),
184                        item
185                    ));
186                }
187            }
188        }
189    }
190    result
191}
192
193/// Helper function to apply authentication and extra headers to a request
194pub(crate) fn apply_auth_and_headers(
195    mut request: reqwest::RequestBuilder,
196    client: &DicomWebClient,
197) -> reqwest::RequestBuilder {
198    // Basic authentication
199    if let Some(username) = &client.username {
200        request = request.basic_auth(username, client.password.as_ref());
201    }
202    // Bearer token (only if no basic auth)
203    else if let Some(bearer_token) = &client.bearer_token {
204        request = request.bearer_auth(bearer_token);
205    }
206
207    // Extra headers
208    for (key, value) in &client.extra_headers {
209        request = request.header(key, value);
210    }
211
212    request
213}
214
215/// Helper function to validate and parse content-type headers for DICOM JSON responses
216pub(crate) fn validate_dicom_json_content_type(
217    content_type_str: &str,
218) -> Result<(), DicomWebError> {
219    let media_type = MediaType::parse(content_type_str)
220        .map_err(|e| DicomWebError::ContentTypeParseFailed { source: e })?;
221
222    // Check if we have a DICOM-JSON, application/dicom+json, or JSON content type
223    if media_type.essence() != MediaType::new(APPLICATION, JSON)
224        && media_type.essence() != MediaType::from_parts(APPLICATION, DICOM, Some(JSON), &[])
225    {
226        return Err(DicomWebError::UnexpectedContentType {
227            content_type: content_type_str.to_string(),
228        });
229    }
230
231    Ok(())
232}
233
234/// Helper function to validate content type from a multipart DICOM item.
235/// Accepts `application/dicom` and `application/octet-stream`.
236pub(crate) fn validate_multipart_item_content_type(ct: &str) -> Result<(), DicomWebError> {
237    let media_type =
238        MediaType::parse(ct).map_err(|e| DicomWebError::ContentTypeParseFailed { source: e })?;
239
240    // WADO-RS multipart items carry binary DICOM data (application/dicom)
241    // or raw octet streams (application/octet-stream)
242    if media_type.essence() != MediaType::new(APPLICATION, DICOM)
243        && media_type.essence() != MediaType::new(APPLICATION, OCTET_STREAM)
244    {
245        return Err(DicomWebError::UnexpectedContentType {
246            content_type: ct.to_string(),
247        });
248    }
249
250    Ok(())
251}
252
253#[cfg(test)]
254mod tests {
255    use dicom_dictionary_std::{tags, uids};
256    use dicom_object::{FileMetaTableBuilder, InMemDicomObject};
257    use serde_json::json;
258    use wiremock::MockServer;
259
260    use super::*;
261
262    #[test_log::test]
263    fn selector_to_string_test() {
264        let selector = AttributeSelector::new(vec![
265            AttributeSelectorStep::Tag(tags::PATIENT_NAME),
266            AttributeSelectorStep::Nested {
267                tag: tags::REFERENCED_STUDY_SEQUENCE,
268                item: 1,
269            },
270            AttributeSelectorStep::Tag(tags::STUDY_INSTANCE_UID),
271        ])
272        .unwrap();
273
274        let result = selector_to_string(&selector);
275        assert_eq!(result, "00100010.00081110[1].0020000d");
276    }
277
278    async fn mock_mwl(mock_server: &MockServer) {
279        // MWL endpoint
280        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
281            .and(wiremock::matchers::header_exists("Accept"))
282            .and(wiremock::matchers::path(
283                "/modality-scheduled-procedure-steps",
284            ))
285            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
286        mock_server.register(mock).await;
287    }
288
289    async fn mock_qido(mock_server: &MockServer) {
290        // STUDIES endpoint
291        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
292            .and(wiremock::matchers::header_exists("Accept"))
293            .and(wiremock::matchers::path("/studies"))
294            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
295        mock_server.register(mock).await;
296        // SERIES endpoint
297        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
298            .and(wiremock::matchers::header_exists("Accept"))
299            .and(wiremock::matchers::path("/series"))
300            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
301        mock_server.register(mock).await;
302        // INSTANCES endpoint
303        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
304            .and(wiremock::matchers::header_exists("Accept"))
305            .and(wiremock::matchers::path("/instances"))
306            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
307        mock_server.register(mock).await;
308        // STUDIES/{STUDY_UID}/SERIES endpoint
309        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
310            .and(wiremock::matchers::header_exists("Accept"))
311            .and(wiremock::matchers::path_regex("^/studies/[0-9.]+/series$"))
312            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
313        mock_server.register(mock).await;
314        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/INSTANCES endpoint
315        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
316            .and(wiremock::matchers::header_exists("Accept"))
317            .and(wiremock::matchers::path_regex(
318                "^/studies/[0-9.]+/series/[0-9.]+/instances$",
319            ))
320            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
321        mock_server.register(mock).await;
322    }
323
324    async fn mock_wado(mock_server: &MockServer) {
325        let dcm_multipart_response = wiremock::ResponseTemplate::new(200).set_body_raw(
326            "--1234\r\nContent-Type: application/dicom\r\n\r\n--1234--",
327            "multipart/related; boundary=1234",
328        );
329
330        // STUDIES/{STUDY_UID} endpoint
331        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
332            .and(wiremock::matchers::header_exists("Accept"))
333            .and(wiremock::matchers::path_regex("^/studies/[0-9.]+$"))
334            .respond_with(dcm_multipart_response.clone());
335        mock_server.register(mock).await;
336        // STUDIES/{STUDY_UID}/METADATA endpoint
337        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
338            .and(wiremock::matchers::header_exists("Accept"))
339            .and(wiremock::matchers::path_regex(
340                "^/studies/[0-9.]+/metadata$",
341            ))
342            .respond_with(
343                wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/dicom+json"),
344            );
345        mock_server.register(mock).await;
346        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID} endpoint
347        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
348            .and(wiremock::matchers::header_exists("Accept"))
349            .and(wiremock::matchers::path_regex(
350                r"^/studies/[0-9.]+/series/[0-9.]+$",
351            ))
352            .respond_with(dcm_multipart_response.clone());
353        mock_server.register(mock).await;
354        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/METADATA endpoint
355        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
356            .and(wiremock::matchers::header_exists("Accept"))
357            .and(wiremock::matchers::path_regex(
358                r"^/studies/[0-9.]+/series/[0-9.]+/metadata$",
359            ))
360            .respond_with(
361                wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/dicom+json"),
362            );
363        mock_server.register(mock).await;
364        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/INSTANCES/{INSTANCE_UID} endpoint
365        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
366            .and(wiremock::matchers::header_exists("Accept"))
367            .and(wiremock::matchers::path_regex(
368                r"^/studies/[0-9.]+/series/[0-9.]+/instances/[0-9.]+$",
369            ))
370            .respond_with(dcm_multipart_response.clone());
371        mock_server.register(mock).await;
372        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/INSTANCES/{INSTANCE_UID}/METADATA endpoint
373        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
374            .and(wiremock::matchers::header_exists("Accept"))
375            .and(wiremock::matchers::path_regex(
376                r"^/studies/[0-9.]+/series/[0-9.]+/instances/[0-9.]+/metadata$",
377            ))
378            .respond_with(
379                wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/dicom+json"),
380            );
381        mock_server.register(mock).await;
382        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/INSTANCES/{INSTANCE_UID}/frames/{framelist} endpoint
383        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
384            .and(wiremock::matchers::header_exists("Accept"))
385            .and(wiremock::matchers::path_regex(
386                r"^/studies/[0-9.]+/series/[0-9.]+/instances/[0-9.]+/frames/[0-9,]+$",
387            ))
388            .respond_with(dcm_multipart_response);
389        mock_server.register(mock).await;
390    }
391
392    async fn mock_stow(mock_server: &MockServer) {
393        // STUDIES endpoint for STOW-RS
394        let mock = wiremock::Mock::given(wiremock::matchers::method("POST"))
395            .and(wiremock::matchers::header_exists("Content-Type"))
396            .and(wiremock::matchers::path("/studies"))
397            .respond_with(
398                wiremock::ResponseTemplate::new(200).set_body_raw("{}", "application/dicom+json"),
399            );
400        mock_server.register(mock).await;
401    }
402
403    // Create a DICOMWeb mock server
404    async fn start_dicomweb_mock_server() -> MockServer {
405        let mock_server = MockServer::start().await;
406        mock_qido(&mock_server).await;
407        mock_wado(&mock_server).await;
408        mock_stow(&mock_server).await;
409        mock_mwl(&mock_server).await;
410        mock_server
411    }
412
413    #[test_log::test(tokio::test)]
414    async fn query_study_test() {
415        let mock_server = start_dicomweb_mock_server().await;
416        let client = DicomWebClient::with_single_url(&mock_server.uri());
417        // Perform QIDO-RS request
418        let result = client.query_studies().run().await;
419        assert!(result.is_ok());
420    }
421
422    #[test_log::test(tokio::test)]
423    async fn query_series_test() {
424        let mock_server = start_dicomweb_mock_server().await;
425        let client = DicomWebClient::with_single_url(&mock_server.uri());
426        // Perform QIDO-RS request
427        let result = client.query_series().run().await;
428        assert!(result.is_ok());
429    }
430
431    #[test_log::test(tokio::test)]
432    async fn query_instances_test() {
433        let mock_server = start_dicomweb_mock_server().await;
434        let client = DicomWebClient::with_single_url(&mock_server.uri());
435        // Perform QIDO-RS request
436        let result = client.query_instances().run().await;
437        assert!(result.is_ok());
438    }
439
440    #[test_log::test(tokio::test)]
441    async fn query_series_in_study_test() {
442        let mock_server = start_dicomweb_mock_server().await;
443        let client = DicomWebClient::with_single_url(&mock_server.uri());
444        // Perform QIDO-RS request
445        let result = client
446            .query_series_in_study("1.2.276.0.89.300.10035584652.20181014.93645")
447            .run()
448            .await;
449        assert!(result.is_ok());
450    }
451
452    #[test_log::test(tokio::test)]
453    async fn query_instances_in_series_test() {
454        let mock_server = start_dicomweb_mock_server().await;
455        let client = DicomWebClient::with_single_url(&mock_server.uri());
456        // Perform QIDO-RS request
457        let result = client
458            .query_instances_in_series("1.2.276.0.89.300.10035584652.20181014.93645", "1.1.1.1")
459            .run()
460            .await;
461        assert!(result.is_ok());
462    }
463
464    #[test_log::test(tokio::test)]
465    async fn retrieve_study_test() {
466        let mock_server = start_dicomweb_mock_server().await;
467        let client = DicomWebClient::with_single_url(&mock_server.uri());
468        // Perform WADO-RS request
469        let result = client
470            .retrieve_study("1.2.276.0.89.300.10035584652.20181014.93645")
471            .run()
472            .await;
473
474        assert!(result.is_ok());
475    }
476
477    #[test_log::test(tokio::test)]
478    async fn retrieve_study_metadata_test() {
479        let mock_server = start_dicomweb_mock_server().await;
480        let client = DicomWebClient::with_single_url(&mock_server.uri());
481        // Perform WADO-RS request
482        let result = client
483            .retrieve_study_metadata("1.2.276.0.89.300.10035584652.20181014.93645")
484            .run()
485            .await;
486
487        assert!(result.is_ok());
488    }
489
490    #[test_log::test(tokio::test)]
491    async fn retrieve_series_test() {
492        let mock_server = start_dicomweb_mock_server().await;
493        let client = DicomWebClient::with_single_url(&mock_server.uri());
494        // Perform WADO-RS request
495        let result = client
496            .retrieve_series(
497                "1.2.276.0.89.300.10035584652.20181014.93645",
498                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
499            )
500            .run()
501            .await;
502
503        assert!(result.is_ok());
504    }
505
506    #[test_log::test(tokio::test)]
507    async fn retrieve_series_metadata_test() {
508        let mock_server = start_dicomweb_mock_server().await;
509        let client = DicomWebClient::with_single_url(&mock_server.uri());
510        // Perform WADO-RS request
511        let result = client
512            .retrieve_series_metadata(
513                "1.2.276.0.89.300.10035584652.20181014.93645",
514                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
515            )
516            .run()
517            .await;
518
519        assert!(result.is_ok());
520    }
521
522    #[test_log::test(tokio::test)]
523    async fn retrieve_instance_test() {
524        let mock_server = start_dicomweb_mock_server().await;
525        let client = DicomWebClient::with_single_url(&mock_server.uri());
526        // Perform WADO-RS request
527        let result = client
528            .retrieve_instance(
529                "1.2.276.0.89.300.10035584652.20181014.93645",
530                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
531                "1.2.392.200036.9125.9.0.454007928.521494544.1883970570",
532            )
533            .run()
534            .await;
535        assert!(result.is_err_and(|e| e.to_string().contains("Empty")));
536    }
537
538    #[test_log::test(tokio::test)]
539    async fn retrieve_instance_metadata_test() {
540        let mock_server = start_dicomweb_mock_server().await;
541        let client = DicomWebClient::with_single_url(&mock_server.uri());
542        // Perform WADO-RS request
543        let result = client
544            .retrieve_instance_metadata(
545                "1.2.276.0.89.300.10035584652.20181014.93645",
546                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
547                "1.2.392.200036.9125.9.0.454007928.521494544.1883970570",
548            )
549            .run()
550            .await;
551        assert!(result.is_ok());
552    }
553
554    #[test_log::test(tokio::test)]
555    async fn retrieve_frames_test() {
556        let mock_server = start_dicomweb_mock_server().await;
557        let mut client = DicomWebClient::with_single_url(&mock_server.uri());
558        client.set_basic_auth("orthanc", "orthanc");
559        // Perform WADO-RS request
560        let result = client
561            .retrieve_frames(
562                "1.2.276.0.89.300.10035584652.20181014.93645",
563                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
564                "1.2.392.200036.9125.9.0.454007928.521494544.1883970570",
565                &[1],
566            )
567            .run()
568            .await;
569        assert!(result.is_ok());
570    }
571
572    #[test_log::test(tokio::test)]
573    async fn store_instances_test() {
574        let mock_server = start_dicomweb_mock_server().await;
575        let mut client = DicomWebClient::with_single_url(&mock_server.uri());
576        client.set_basic_auth("orthanc", "orthanc");
577        // Create new empty DICOM instance
578        let instance = InMemDicomObject::new_empty()
579            .with_meta(
580                FileMetaTableBuilder::new()
581                    // Implicit VR Little Endian
582                    .transfer_syntax(uids::IMPLICIT_VR_LITTLE_ENDIAN)
583                    // Computed Radiography image storage
584                    .media_storage_sop_class_uid("1.2.840.10008.5.1.4.1.1.1"),
585            )
586            .unwrap();
587        // Create a stream with the instance
588        let stream = futures_util::stream::once(async move { instance });
589
590        // Perform WADO-RS request
591        let result = client.store_instances().with_instances(stream).run().await;
592        assert!(result.is_ok());
593    }
594
595    #[test_log::test(tokio::test)]
596    async fn query_modality_scheduled_procedure_steps_test() {
597        let mock_server = start_dicomweb_mock_server().await;
598        let client = DicomWebClient::with_single_url(&mock_server.uri());
599        // Perform MWL-RS request
600        let result = client
601            .query_modality_scheduled_procedure_steps()
602            .run()
603            .await;
604        assert!(result.is_ok());
605    }
606}