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