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;
55
56mod mwl;
57mod qido;
58mod stow;
59mod wado;
60/// The DICOMweb client for querying and retrieving DICOM objects.
61/// Can be reused for multiple requests.
62#[derive(Debug, Clone)]
63pub struct DicomWebClient {
64    wado_url: String,
65    qido_url: String,
66    stow_url: String,
67
68    // Basic Auth
69    pub(crate) username: Option<String>,
70    pub(crate) password: Option<String>,
71    // Bearer Token
72    pub(crate) bearer_token: Option<String>,
73
74    pub(crate) client: reqwest::Client,
75}
76
77/// An error returned when parsing an invalid tag range.
78#[derive(Debug, Snafu)]
79#[snafu(visibility(pub(crate)))]
80pub enum DicomWebError {
81    #[snafu(display("Failed to perform HTTP request"))]
82    RequestFailed { url: String, source: reqwest::Error },
83    #[snafu(display("Failed to deserialize response from server"))]
84    DeserializationFailed { source: reqwest::Error },
85    #[snafu(display("Failed to parse multipart response"))]
86    MultipartReaderFailed {
87        source: multipart_rs::MultipartError,
88    },
89    #[snafu(display("Failed to read DICOM object from multipart item"))]
90    DicomReaderFailed { source: dicom_object::ReadError },
91    #[snafu(display("HTTP status code indicates failure"))]
92    HttpStatusFailure { status_code: StatusCode },
93    #[snafu(display("Multipart item missing Content-Type header"))]
94    MissingContentTypeHeader,
95    #[snafu(display("Unexpected content type: {}", content_type))]
96    UnexpectedContentType { content_type: String },
97    #[snafu(display("Failed to parse content type: {}", source))]
98    ContentTypeParseFailed { source: MediaTypeError },
99    #[snafu(display("Unexpected multipart type: {:?}", multipart_type))]
100    UnexpectedMultipartType { multipart_type: MultipartType },
101    #[snafu(display("Empty response"))]
102    EmptyResponse,
103}
104
105impl DicomWebClient {
106    /// Set the basic authentication for the DICOMWeb client. Will be passed in the Authorization header.
107    pub fn set_basic_auth(&mut self, username: &str, password: &str) -> &Self {
108        self.username = Some(username.to_string());
109        self.password = Some(password.to_string());
110        self
111    }
112
113    /// Set the bearer token for the DICOMWeb client. Will be passed in the Authorization header.
114    pub fn set_bearer_token(&mut self, token: &str) -> &Self {
115        self.bearer_token = Some(token.to_string());
116        self
117    }
118
119    /// Create a new DICOMWeb client with the same URL for all services (WADO-RS, QIDO-RS, STOW-RS).
120    pub fn with_single_url(url: &str) -> DicomWebClient {
121        DicomWebClient {
122            wado_url: url.to_string(),
123            qido_url: url.to_string(),
124            stow_url: url.to_string(),
125            client: reqwest::Client::new(),
126            bearer_token: None,
127            username: None,
128            password: None,
129        }
130    }
131
132    /// Create a new DICOMWeb client with separate URLs for each service.
133    pub fn with_separate_urls(wado_url: &str, qido_url: &str, stow_url: &str) -> DicomWebClient {
134        DicomWebClient {
135            wado_url: wado_url.to_string(),
136            qido_url: qido_url.to_string(),
137            stow_url: stow_url.to_string(),
138            client: reqwest::Client::new(),
139            bearer_token: None,
140            username: None,
141            password: None,
142        }
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use serde_json::json;
149    use wiremock::MockServer;
150
151    use super::*;
152
153    async fn mock_qido(mock_server: &MockServer) {
154        // STUDIES endpoint
155        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
156            .and(wiremock::matchers::header_exists("Accept"))
157            .and(wiremock::matchers::path("/studies"))
158            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
159        mock_server.register(mock).await;
160        // SERIES endpoint
161        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
162            .and(wiremock::matchers::header_exists("Accept"))
163            .and(wiremock::matchers::path("/series"))
164            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
165        mock_server.register(mock).await;
166        // INSTANCES endpoint
167        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
168            .and(wiremock::matchers::header_exists("Accept"))
169            .and(wiremock::matchers::path("/instances"))
170            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
171        mock_server.register(mock).await;
172        // STUDIES/{STUDY_UID}/SERIES endpoint
173        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
174            .and(wiremock::matchers::header_exists("Accept"))
175            .and(wiremock::matchers::path_regex("^/studies/[0-9.]+/series$"))
176            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
177        mock_server.register(mock).await;
178        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/INSTANCES endpoint
179        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
180            .and(wiremock::matchers::header_exists("Accept"))
181            .and(wiremock::matchers::path_regex(
182                "^/studies/[0-9.]+/series/[0-9.]+/instances$",
183            ))
184            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
185        mock_server.register(mock).await;
186    }
187
188    async fn mock_wado(mock_server: &MockServer) {
189        let dcm_multipart_response = wiremock::ResponseTemplate::new(200).set_body_raw(
190            "--1234\r\nContent-Type: application/dicom\r\n\r\n--1234--",
191            "multipart/related; boundary=1234",
192        );
193
194        // STUDIES/{STUDY_UID} endpoint
195        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
196            .and(wiremock::matchers::header_exists("Accept"))
197            .and(wiremock::matchers::path_regex("^/studies/[0-9.]+$"))
198            .respond_with(dcm_multipart_response.clone());
199        mock_server.register(mock).await;
200        // STUDIES/{STUDY_UID}/METADATA endpoint
201        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
202            .and(wiremock::matchers::header_exists("Accept"))
203            .and(wiremock::matchers::path_regex(
204                "^/studies/[0-9.]+/metadata$",
205            ))
206            .respond_with(
207                wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/dicom+json"),
208            );
209        mock_server.register(mock).await;
210        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID} endpoint
211        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
212            .and(wiremock::matchers::header_exists("Accept"))
213            .and(wiremock::matchers::path_regex(
214                r"^/studies/[0-9.]+/series/[0-9.]+$",
215            ))
216            .respond_with(dcm_multipart_response.clone());
217        mock_server.register(mock).await;
218        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/METADATA endpoint
219        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
220            .and(wiremock::matchers::header_exists("Accept"))
221            .and(wiremock::matchers::path_regex(
222                r"^/studies/[0-9.]+/series/[0-9.]+/metadata$",
223            ))
224            .respond_with(
225                wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/dicom+json"),
226            );
227        mock_server.register(mock).await;
228        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/INSTANCES/{INSTANCE_UID} endpoint
229        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
230            .and(wiremock::matchers::header_exists("Accept"))
231            .and(wiremock::matchers::path_regex(
232                r"^/studies/[0-9.]+/series/[0-9.]+/instances/[0-9.]+$",
233            ))
234            .respond_with(dcm_multipart_response.clone());
235        mock_server.register(mock).await;
236        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/INSTANCES/{INSTANCE_UID}/METADATA endpoint
237        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
238            .and(wiremock::matchers::header_exists("Accept"))
239            .and(wiremock::matchers::path_regex(
240                r"^/studies/[0-9.]+/series/[0-9.]+/instances/[0-9.]+/metadata$",
241            ))
242            .respond_with(
243                wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/dicom+json"),
244            );
245        mock_server.register(mock).await;
246        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/INSTANCES/{INSTANCE_UID}/frames/{framelist} endpoint
247        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
248            .and(wiremock::matchers::header_exists("Accept"))
249            .and(wiremock::matchers::path_regex(
250                r"^/studies/[0-9.]+/series/[0-9.]+/instances/[0-9.]+/frames/[0-9,]+$",
251            ))
252            .respond_with(dcm_multipart_response);
253        mock_server.register(mock).await;
254    }
255
256    // Create a DICOMWeb mock server
257    async fn start_dicomweb_mock_server() -> MockServer {
258        let mock_server = MockServer::start().await;
259        mock_qido(&mock_server).await;
260        mock_wado(&mock_server).await;
261        mock_server
262    }
263
264    #[tokio::test]
265    async fn query_study_test() {
266        let mock_server = start_dicomweb_mock_server().await;
267        let client = DicomWebClient::with_single_url(&mock_server.uri());
268        // Perform QIDO-RS request
269        let result = client.query_studies().run().await;
270        assert!(result.is_ok());
271    }
272
273    #[tokio::test]
274    async fn query_series_test() {
275        let mock_server = start_dicomweb_mock_server().await;
276        let client = DicomWebClient::with_single_url(&mock_server.uri());
277        // Perform QIDO-RS request
278        let result = client.query_series().run().await;
279        assert!(result.is_ok());
280    }
281
282    #[tokio::test]
283    async fn query_instances_test() {
284        let mock_server = start_dicomweb_mock_server().await;
285        let client = DicomWebClient::with_single_url(&mock_server.uri());
286        // Perform QIDO-RS request
287        let result = client.query_instances().run().await;
288        assert!(result.is_ok());
289    }
290
291    #[tokio::test]
292    async fn query_series_in_study_test() {
293        let mock_server = start_dicomweb_mock_server().await;
294        let client = DicomWebClient::with_single_url(&mock_server.uri());
295        // Perform QIDO-RS request
296        let result = client
297            .query_series_in_study("1.2.276.0.89.300.10035584652.20181014.93645")
298            .run()
299            .await;
300        assert!(result.is_ok());
301    }
302
303    #[tokio::test]
304    async fn query_instances_in_series_test() {
305        let mock_server = start_dicomweb_mock_server().await;
306        let client = DicomWebClient::with_single_url(&mock_server.uri());
307        // Perform QIDO-RS request
308        let result = client
309            .query_instances_in_series("1.2.276.0.89.300.10035584652.20181014.93645", "1.1.1.1")
310            .run()
311            .await;
312        assert!(result.is_ok());
313    }
314
315    #[tokio::test]
316    async fn retrieve_study_test() {
317        let mock_server = start_dicomweb_mock_server().await;
318        let client = DicomWebClient::with_single_url(&mock_server.uri());
319        // Perform WADO-RS request
320        let result = client
321            .retrieve_study("1.2.276.0.89.300.10035584652.20181014.93645")
322            .run()
323            .await;
324
325        assert!(result.is_ok());
326    }
327
328    #[tokio::test]
329    async fn retrieve_study_metadata_test() {
330        let mock_server = start_dicomweb_mock_server().await;
331        let client = DicomWebClient::with_single_url(&mock_server.uri());
332        // Perform WADO-RS request
333        let result = client
334            .retrieve_study_metadata("1.2.276.0.89.300.10035584652.20181014.93645")
335            .run()
336            .await;
337
338        assert!(result.is_ok());
339    }
340
341    #[tokio::test]
342    async fn retrieve_series_test() {
343        let mock_server = start_dicomweb_mock_server().await;
344        let client = DicomWebClient::with_single_url(&mock_server.uri());
345        // Perform WADO-RS request
346        let result = client
347            .retrieve_series(
348                "1.2.276.0.89.300.10035584652.20181014.93645",
349                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
350            )
351            .run()
352            .await;
353
354        assert!(result.is_ok());
355    }
356
357    #[tokio::test]
358    async fn retrieve_series_metadata_test() {
359        let mock_server = start_dicomweb_mock_server().await;
360        let client = DicomWebClient::with_single_url(&mock_server.uri());
361        // Perform WADO-RS request
362        let result = client
363            .retrieve_series_metadata(
364                "1.2.276.0.89.300.10035584652.20181014.93645",
365                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
366            )
367            .run()
368            .await;
369
370        assert!(result.is_ok());
371    }
372
373    #[tokio::test]
374    async fn retrieve_instance_test() {
375        let mock_server = start_dicomweb_mock_server().await;
376        let client = DicomWebClient::with_single_url(&mock_server.uri());
377        // Perform WADO-RS request
378        let result = client
379            .retrieve_instance(
380                "1.2.276.0.89.300.10035584652.20181014.93645",
381                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
382                "1.2.392.200036.9125.9.0.454007928.521494544.1883970570",
383            )
384            .run()
385            .await;
386        assert!(result.is_err_and(|e| e.to_string().contains("Empty")));
387    }
388
389    #[tokio::test]
390    async fn retrieve_instance_metadata_test() {
391        let mock_server = start_dicomweb_mock_server().await;
392        let client = DicomWebClient::with_single_url(&mock_server.uri());
393        // Perform WADO-RS request
394        let result = client
395            .retrieve_instance_metadata(
396                "1.2.276.0.89.300.10035584652.20181014.93645",
397                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
398                "1.2.392.200036.9125.9.0.454007928.521494544.1883970570",
399            )
400            .run()
401            .await;
402        assert!(result.is_ok());
403    }
404
405    #[tokio::test]
406    async fn retrieve_frames_test() {
407        let mock_server = start_dicomweb_mock_server().await;
408        let mut client = DicomWebClient::with_single_url(&mock_server.uri());
409        client.set_basic_auth("orthanc", "orthanc");
410        // Perform WADO-RS request
411        let result = client
412            .retrieve_frames(
413                "1.2.276.0.89.300.10035584652.20181014.93645",
414                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
415                "1.2.392.200036.9125.9.0.454007928.521494544.1883970570",
416                &[1],
417            )
418            .run()
419            .await;
420        assert!(result.is_ok());
421    }
422}