dicom_web/
lib.rs

1//! This crate contains a DICOMweb client for querying and retrieving DICOM objects.
2//!
3//! It supports the QIDO-RS and WADO-RS DICOMweb services, which are used to query and retrieve DICOM objects respectively.
4//! As of now, the STOW-RS service is not supported.
5//! The HTTP requests are made using the reqwest crate, which is a high-level HTTP client for Rust.
6//!
7//! # Examples
8//!
9//! Query all studies from a DICOMweb server (with authentication):
10//!
11//! ```no_run
12//! use dicom_dictionary_std::tags;
13//! use dicom_web::DicomWebClient;
14//!
15//! async fn foo()
16//! {
17//!   let mut client = DicomWebClient::with_single_url("http://localhost:8042");
18//!   client.set_basic_auth("orthanc", "orthanc");
19//!
20//!   let studies = client.query_studies().run().await.unwrap();
21//!
22//!   for study in studies {
23//!       let study_instance_uid = study.element(tags::STUDY_INSTANCE_UID).unwrap().to_str().unwrap();
24//!       println!("Study: {}", study_instance_uid);
25//!   }
26//! }
27//! ```
28//!
29//! To retrieve a DICOM study from a DICOMweb server:
30//! ```no_run
31//! use dicom_dictionary_std::tags;
32//! use dicom_web::DicomWebClient;
33//! use futures_util::StreamExt;
34//!
35//! async fn foo()
36//! {
37//!   let mut client = DicomWebClient::with_single_url("http://localhost:8042");
38//!   client.set_basic_auth("orthanc", "orthanc");
39//!   
40//!   let study_instance_uid = "1.2.276.0.89.300.10035584652.20181014.93645";
41//!   
42//!   let mut study_objects = client.retrieve_study(study_instance_uid).run().await.unwrap();
43//!
44//!   while let Some(object) = study_objects.next().await {
45//!       let object = object.unwrap();
46//!       let sop_instance_uid = object.element(tags::SOP_INSTANCE_UID).unwrap().to_str().unwrap();
47//!       println!("Instance: {}", sop_instance_uid);
48//!   }
49//! }
50//! ```
51use mediatype::MediaTypeError;
52use multipart_rs::MultipartType;
53use reqwest::StatusCode;
54use snafu::Snafu;
55use std::collections::HashMap;
56
57mod mwl;
58mod qido;
59mod stow;
60mod wado;
61/// The DICOMweb client for querying and retrieving DICOM objects.
62/// Can be reused for multiple requests.
63#[derive(Debug, Clone)]
64pub struct DicomWebClient {
65    wado_url: String,
66    qido_url: String,
67    stow_url: String,
68
69    // Basic Auth
70    pub(crate) username: Option<String>,
71    pub(crate) password: Option<String>,
72    // Bearer Token
73    pub(crate) bearer_token: Option<String>,
74    // Headers
75    pub(crate) extra_headers: HashMap<String, String>,
76
77    pub(crate) client: reqwest::Client,
78}
79
80/// An error returned when parsing an invalid tag range.
81#[derive(Debug, Snafu)]
82#[snafu(visibility(pub(crate)))]
83pub enum DicomWebError {
84    #[snafu(display("Failed to perform HTTP request"))]
85    RequestFailed { url: String, source: reqwest::Error },
86    #[snafu(display("Failed to deserialize response from server"))]
87    DeserializationFailed { source: reqwest::Error },
88    #[snafu(display("Failed to parse multipart response"))]
89    MultipartReaderFailed {
90        source: multipart_rs::MultipartError,
91    },
92    #[snafu(display("Failed to read DICOM object from multipart item"))]
93    DicomReaderFailed { source: dicom_object::ReadError },
94    #[snafu(display("HTTP status code indicates failure"))]
95    HttpStatusFailure { status_code: StatusCode },
96    #[snafu(display("Multipart item missing Content-Type header"))]
97    MissingContentTypeHeader,
98    #[snafu(display("Unexpected content type: {}", content_type))]
99    UnexpectedContentType { content_type: String },
100    #[snafu(display("Failed to parse content type: {}", source))]
101    ContentTypeParseFailed { source: MediaTypeError },
102    #[snafu(display("Unexpected multipart type: {:?}", multipart_type))]
103    UnexpectedMultipartType { multipart_type: MultipartType },
104    #[snafu(display("Empty response"))]
105    EmptyResponse,
106}
107
108impl DicomWebClient {
109    /// Set the basic authentication for the DICOMWeb client. Will be passed in the Authorization header.
110    pub fn set_basic_auth(&mut self, username: &str, password: &str) -> &Self {
111        self.username = Some(username.to_string());
112        self.password = Some(password.to_string());
113        self
114    }
115
116    /// Set the bearer token for the DICOMWeb client. Will be passed in the Authorization header.
117    pub fn set_bearer_token(&mut self, token: &str) -> &Self {
118        self.bearer_token = Some(token.to_string());
119        self
120    }
121
122    pub fn add_header(&mut self, key: &str, value: &str) -> Result<&Self, std::io::Error> {
123        self.extra_headers
124            .insert(key.to_string(), value.to_string());
125        Ok(self)
126    }
127
128    /// Create a new DICOMWeb client with the same URL for all services (WADO-RS, QIDO-RS, STOW-RS).
129    pub fn with_single_url(url: &str) -> DicomWebClient {
130        DicomWebClient {
131            wado_url: url.to_string(),
132            qido_url: url.to_string(),
133            stow_url: url.to_string(),
134            client: reqwest::Client::new(),
135            extra_headers: HashMap::new(),
136            bearer_token: None,
137            username: None,
138            password: None,
139        }
140    }
141
142    /// Create a new DICOMWeb client with separate URLs for each service.
143    pub fn with_separate_urls(wado_url: &str, qido_url: &str, stow_url: &str) -> DicomWebClient {
144        DicomWebClient {
145            wado_url: wado_url.to_string(),
146            qido_url: qido_url.to_string(),
147            stow_url: stow_url.to_string(),
148            extra_headers: HashMap::new(),
149            client: reqwest::Client::new(),
150            bearer_token: None,
151            username: None,
152            password: None,
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use dicom_dictionary_std::uids;
160    use dicom_object::{FileMetaTableBuilder, InMemDicomObject};
161    use serde_json::json;
162    use wiremock::MockServer;
163
164    use super::*;
165
166    async fn mock_qido(mock_server: &MockServer) {
167        // STUDIES endpoint
168        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
169            .and(wiremock::matchers::header_exists("Accept"))
170            .and(wiremock::matchers::path("/studies"))
171            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
172        mock_server.register(mock).await;
173        // SERIES endpoint
174        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
175            .and(wiremock::matchers::header_exists("Accept"))
176            .and(wiremock::matchers::path("/series"))
177            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
178        mock_server.register(mock).await;
179        // INSTANCES endpoint
180        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
181            .and(wiremock::matchers::header_exists("Accept"))
182            .and(wiremock::matchers::path("/instances"))
183            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
184        mock_server.register(mock).await;
185        // STUDIES/{STUDY_UID}/SERIES endpoint
186        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
187            .and(wiremock::matchers::header_exists("Accept"))
188            .and(wiremock::matchers::path_regex("^/studies/[0-9.]+/series$"))
189            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
190        mock_server.register(mock).await;
191        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/INSTANCES endpoint
192        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
193            .and(wiremock::matchers::header_exists("Accept"))
194            .and(wiremock::matchers::path_regex(
195                "^/studies/[0-9.]+/series/[0-9.]+/instances$",
196            ))
197            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
198        mock_server.register(mock).await;
199    }
200
201    async fn mock_wado(mock_server: &MockServer) {
202        let dcm_multipart_response = wiremock::ResponseTemplate::new(200).set_body_raw(
203            "--1234\r\nContent-Type: application/dicom\r\n\r\n--1234--",
204            "multipart/related; boundary=1234",
205        );
206
207        // STUDIES/{STUDY_UID} endpoint
208        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
209            .and(wiremock::matchers::header_exists("Accept"))
210            .and(wiremock::matchers::path_regex("^/studies/[0-9.]+$"))
211            .respond_with(dcm_multipart_response.clone());
212        mock_server.register(mock).await;
213        // STUDIES/{STUDY_UID}/METADATA endpoint
214        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
215            .and(wiremock::matchers::header_exists("Accept"))
216            .and(wiremock::matchers::path_regex(
217                "^/studies/[0-9.]+/metadata$",
218            ))
219            .respond_with(
220                wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/dicom+json"),
221            );
222        mock_server.register(mock).await;
223        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID} endpoint
224        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
225            .and(wiremock::matchers::header_exists("Accept"))
226            .and(wiremock::matchers::path_regex(
227                r"^/studies/[0-9.]+/series/[0-9.]+$",
228            ))
229            .respond_with(dcm_multipart_response.clone());
230        mock_server.register(mock).await;
231        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/METADATA endpoint
232        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
233            .and(wiremock::matchers::header_exists("Accept"))
234            .and(wiremock::matchers::path_regex(
235                r"^/studies/[0-9.]+/series/[0-9.]+/metadata$",
236            ))
237            .respond_with(
238                wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/dicom+json"),
239            );
240        mock_server.register(mock).await;
241        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/INSTANCES/{INSTANCE_UID} endpoint
242        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
243            .and(wiremock::matchers::header_exists("Accept"))
244            .and(wiremock::matchers::path_regex(
245                r"^/studies/[0-9.]+/series/[0-9.]+/instances/[0-9.]+$",
246            ))
247            .respond_with(dcm_multipart_response.clone());
248        mock_server.register(mock).await;
249        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/INSTANCES/{INSTANCE_UID}/METADATA endpoint
250        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
251            .and(wiremock::matchers::header_exists("Accept"))
252            .and(wiremock::matchers::path_regex(
253                r"^/studies/[0-9.]+/series/[0-9.]+/instances/[0-9.]+/metadata$",
254            ))
255            .respond_with(
256                wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/dicom+json"),
257            );
258        mock_server.register(mock).await;
259        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/INSTANCES/{INSTANCE_UID}/frames/{framelist} endpoint
260        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
261            .and(wiremock::matchers::header_exists("Accept"))
262            .and(wiremock::matchers::path_regex(
263                r"^/studies/[0-9.]+/series/[0-9.]+/instances/[0-9.]+/frames/[0-9,]+$",
264            ))
265            .respond_with(dcm_multipart_response);
266        mock_server.register(mock).await;
267    }
268
269    async fn mock_stow(mock_server: &MockServer) {
270        // STUDIES endpoint for STOW-RS
271        let mock = wiremock::Mock::given(wiremock::matchers::method("POST"))
272            .and(wiremock::matchers::header_exists("Content-Type"))
273            .and(wiremock::matchers::path("/studies"))
274            .respond_with(wiremock::ResponseTemplate::new(201));
275        mock_server.register(mock).await;
276    }
277
278    // Create a DICOMWeb mock server
279    async fn start_dicomweb_mock_server() -> MockServer {
280        let mock_server = MockServer::start().await;
281        mock_qido(&mock_server).await;
282        mock_wado(&mock_server).await;
283        mock_stow(&mock_server).await;
284        mock_server
285    }
286
287    #[tokio::test]
288    async fn query_study_test() {
289        let mock_server = start_dicomweb_mock_server().await;
290        let client = DicomWebClient::with_single_url(&mock_server.uri());
291        // Perform QIDO-RS request
292        let result = client.query_studies().run().await;
293        assert!(result.is_ok());
294    }
295
296    #[tokio::test]
297    async fn query_series_test() {
298        let mock_server = start_dicomweb_mock_server().await;
299        let client = DicomWebClient::with_single_url(&mock_server.uri());
300        // Perform QIDO-RS request
301        let result = client.query_series().run().await;
302        assert!(result.is_ok());
303    }
304
305    #[tokio::test]
306    async fn query_instances_test() {
307        let mock_server = start_dicomweb_mock_server().await;
308        let client = DicomWebClient::with_single_url(&mock_server.uri());
309        // Perform QIDO-RS request
310        let result = client.query_instances().run().await;
311        assert!(result.is_ok());
312    }
313
314    #[tokio::test]
315    async fn query_series_in_study_test() {
316        let mock_server = start_dicomweb_mock_server().await;
317        let client = DicomWebClient::with_single_url(&mock_server.uri());
318        // Perform QIDO-RS request
319        let result = client
320            .query_series_in_study("1.2.276.0.89.300.10035584652.20181014.93645")
321            .run()
322            .await;
323        assert!(result.is_ok());
324    }
325
326    #[tokio::test]
327    async fn query_instances_in_series_test() {
328        let mock_server = start_dicomweb_mock_server().await;
329        let client = DicomWebClient::with_single_url(&mock_server.uri());
330        // Perform QIDO-RS request
331        let result = client
332            .query_instances_in_series("1.2.276.0.89.300.10035584652.20181014.93645", "1.1.1.1")
333            .run()
334            .await;
335        assert!(result.is_ok());
336    }
337
338    #[tokio::test]
339    async fn retrieve_study_test() {
340        let mock_server = start_dicomweb_mock_server().await;
341        let client = DicomWebClient::with_single_url(&mock_server.uri());
342        // Perform WADO-RS request
343        let result = client
344            .retrieve_study("1.2.276.0.89.300.10035584652.20181014.93645")
345            .run()
346            .await;
347
348        assert!(result.is_ok());
349    }
350
351    #[tokio::test]
352    async fn retrieve_study_metadata_test() {
353        let mock_server = start_dicomweb_mock_server().await;
354        let client = DicomWebClient::with_single_url(&mock_server.uri());
355        // Perform WADO-RS request
356        let result = client
357            .retrieve_study_metadata("1.2.276.0.89.300.10035584652.20181014.93645")
358            .run()
359            .await;
360
361        assert!(result.is_ok());
362    }
363
364    #[tokio::test]
365    async fn retrieve_series_test() {
366        let mock_server = start_dicomweb_mock_server().await;
367        let client = DicomWebClient::with_single_url(&mock_server.uri());
368        // Perform WADO-RS request
369        let result = client
370            .retrieve_series(
371                "1.2.276.0.89.300.10035584652.20181014.93645",
372                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
373            )
374            .run()
375            .await;
376
377        assert!(result.is_ok());
378    }
379
380    #[tokio::test]
381    async fn retrieve_series_metadata_test() {
382        let mock_server = start_dicomweb_mock_server().await;
383        let client = DicomWebClient::with_single_url(&mock_server.uri());
384        // Perform WADO-RS request
385        let result = client
386            .retrieve_series_metadata(
387                "1.2.276.0.89.300.10035584652.20181014.93645",
388                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
389            )
390            .run()
391            .await;
392
393        assert!(result.is_ok());
394    }
395
396    #[tokio::test]
397    async fn retrieve_instance_test() {
398        let mock_server = start_dicomweb_mock_server().await;
399        let client = DicomWebClient::with_single_url(&mock_server.uri());
400        // Perform WADO-RS request
401        let result = client
402            .retrieve_instance(
403                "1.2.276.0.89.300.10035584652.20181014.93645",
404                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
405                "1.2.392.200036.9125.9.0.454007928.521494544.1883970570",
406            )
407            .run()
408            .await;
409        assert!(result.is_err_and(|e| e.to_string().contains("Empty")));
410    }
411
412    #[tokio::test]
413    async fn retrieve_instance_metadata_test() {
414        let mock_server = start_dicomweb_mock_server().await;
415        let client = DicomWebClient::with_single_url(&mock_server.uri());
416        // Perform WADO-RS request
417        let result = client
418            .retrieve_instance_metadata(
419                "1.2.276.0.89.300.10035584652.20181014.93645",
420                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
421                "1.2.392.200036.9125.9.0.454007928.521494544.1883970570",
422            )
423            .run()
424            .await;
425        assert!(result.is_ok());
426    }
427
428    #[tokio::test]
429    async fn retrieve_frames_test() {
430        let mock_server = start_dicomweb_mock_server().await;
431        let mut client = DicomWebClient::with_single_url(&mock_server.uri());
432        client.set_basic_auth("orthanc", "orthanc");
433        // Perform WADO-RS request
434        let result = client
435            .retrieve_frames(
436                "1.2.276.0.89.300.10035584652.20181014.93645",
437                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
438                "1.2.392.200036.9125.9.0.454007928.521494544.1883970570",
439                &[1],
440            )
441            .run()
442            .await;
443        assert!(result.is_ok());
444    }
445
446    #[tokio::test]
447    async fn store_instances_test() {
448        let mock_server = start_dicomweb_mock_server().await;
449        let mut client = DicomWebClient::with_single_url(&mock_server.uri());
450        client.set_basic_auth("orthanc", "orthanc");
451        // Create new empty DICOM instance
452        let instance = InMemDicomObject::new_empty()
453            .with_meta(
454                FileMetaTableBuilder::new()
455                    // Implicit VR Little Endian
456                    .transfer_syntax(uids::IMPLICIT_VR_LITTLE_ENDIAN)
457                    // Computed Radiography image storage
458                    .media_storage_sop_class_uid("1.2.840.10008.5.1.4.1.1.1"),
459            )
460            .unwrap();
461        // Create a stream with the instance
462        let stream = futures_util::stream::once(async move { instance });
463
464        // Perform WADO-RS request
465        let result = client.store_instances().with_instances(stream).run().await;
466        assert!(result.is_ok());
467    }
468}