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 dicom_dictionary_std::uids;
149    use dicom_object::{FileMetaTableBuilder, InMemDicomObject};
150    use serde_json::json;
151    use wiremock::MockServer;
152
153    use super::*;
154
155    async fn mock_qido(mock_server: &MockServer) {
156        // STUDIES endpoint
157        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
158            .and(wiremock::matchers::header_exists("Accept"))
159            .and(wiremock::matchers::path("/studies"))
160            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
161        mock_server.register(mock).await;
162        // SERIES endpoint
163        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
164            .and(wiremock::matchers::header_exists("Accept"))
165            .and(wiremock::matchers::path("/series"))
166            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
167        mock_server.register(mock).await;
168        // INSTANCES endpoint
169        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
170            .and(wiremock::matchers::header_exists("Accept"))
171            .and(wiremock::matchers::path("/instances"))
172            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
173        mock_server.register(mock).await;
174        // STUDIES/{STUDY_UID}/SERIES endpoint
175        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
176            .and(wiremock::matchers::header_exists("Accept"))
177            .and(wiremock::matchers::path_regex("^/studies/[0-9.]+/series$"))
178            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
179        mock_server.register(mock).await;
180        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/INSTANCES endpoint
181        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
182            .and(wiremock::matchers::header_exists("Accept"))
183            .and(wiremock::matchers::path_regex(
184                "^/studies/[0-9.]+/series/[0-9.]+/instances$",
185            ))
186            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
187        mock_server.register(mock).await;
188    }
189
190    async fn mock_wado(mock_server: &MockServer) {
191        let dcm_multipart_response = wiremock::ResponseTemplate::new(200).set_body_raw(
192            "--1234\r\nContent-Type: application/dicom\r\n\r\n--1234--",
193            "multipart/related; boundary=1234",
194        );
195
196        // STUDIES/{STUDY_UID} endpoint
197        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
198            .and(wiremock::matchers::header_exists("Accept"))
199            .and(wiremock::matchers::path_regex("^/studies/[0-9.]+$"))
200            .respond_with(dcm_multipart_response.clone());
201        mock_server.register(mock).await;
202        // STUDIES/{STUDY_UID}/METADATA endpoint
203        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
204            .and(wiremock::matchers::header_exists("Accept"))
205            .and(wiremock::matchers::path_regex(
206                "^/studies/[0-9.]+/metadata$",
207            ))
208            .respond_with(
209                wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/dicom+json"),
210            );
211        mock_server.register(mock).await;
212        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID} endpoint
213        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
214            .and(wiremock::matchers::header_exists("Accept"))
215            .and(wiremock::matchers::path_regex(
216                r"^/studies/[0-9.]+/series/[0-9.]+$",
217            ))
218            .respond_with(dcm_multipart_response.clone());
219        mock_server.register(mock).await;
220        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/METADATA endpoint
221        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
222            .and(wiremock::matchers::header_exists("Accept"))
223            .and(wiremock::matchers::path_regex(
224                r"^/studies/[0-9.]+/series/[0-9.]+/metadata$",
225            ))
226            .respond_with(
227                wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/dicom+json"),
228            );
229        mock_server.register(mock).await;
230        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/INSTANCES/{INSTANCE_UID} endpoint
231        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
232            .and(wiremock::matchers::header_exists("Accept"))
233            .and(wiremock::matchers::path_regex(
234                r"^/studies/[0-9.]+/series/[0-9.]+/instances/[0-9.]+$",
235            ))
236            .respond_with(dcm_multipart_response.clone());
237        mock_server.register(mock).await;
238        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/INSTANCES/{INSTANCE_UID}/METADATA endpoint
239        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
240            .and(wiremock::matchers::header_exists("Accept"))
241            .and(wiremock::matchers::path_regex(
242                r"^/studies/[0-9.]+/series/[0-9.]+/instances/[0-9.]+/metadata$",
243            ))
244            .respond_with(
245                wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/dicom+json"),
246            );
247        mock_server.register(mock).await;
248        // STUDIES/{STUDY_UID}/SERIES/{SERIES_UID}/INSTANCES/{INSTANCE_UID}/frames/{framelist} endpoint
249        let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
250            .and(wiremock::matchers::header_exists("Accept"))
251            .and(wiremock::matchers::path_regex(
252                r"^/studies/[0-9.]+/series/[0-9.]+/instances/[0-9.]+/frames/[0-9,]+$",
253            ))
254            .respond_with(dcm_multipart_response);
255        mock_server.register(mock).await;
256    }
257
258    async fn mock_stow(mock_server: &MockServer) {
259        // STUDIES endpoint for STOW-RS
260        let mock = wiremock::Mock::given(wiremock::matchers::method("POST"))
261            .and(wiremock::matchers::header_exists("Content-Type"))
262            .and(wiremock::matchers::path("/studies"))
263            .respond_with(wiremock::ResponseTemplate::new(201));
264        mock_server.register(mock).await;
265    }
266
267    // Create a DICOMWeb mock server
268    async fn start_dicomweb_mock_server() -> MockServer {
269        let mock_server = MockServer::start().await;
270        mock_qido(&mock_server).await;
271        mock_wado(&mock_server).await;
272        mock_stow(&mock_server).await;
273        mock_server
274    }
275
276    #[tokio::test]
277    async fn query_study_test() {
278        let mock_server = start_dicomweb_mock_server().await;
279        let client = DicomWebClient::with_single_url(&mock_server.uri());
280        // Perform QIDO-RS request
281        let result = client.query_studies().run().await;
282        assert!(result.is_ok());
283    }
284
285    #[tokio::test]
286    async fn query_series_test() {
287        let mock_server = start_dicomweb_mock_server().await;
288        let client = DicomWebClient::with_single_url(&mock_server.uri());
289        // Perform QIDO-RS request
290        let result = client.query_series().run().await;
291        assert!(result.is_ok());
292    }
293
294    #[tokio::test]
295    async fn query_instances_test() {
296        let mock_server = start_dicomweb_mock_server().await;
297        let client = DicomWebClient::with_single_url(&mock_server.uri());
298        // Perform QIDO-RS request
299        let result = client.query_instances().run().await;
300        assert!(result.is_ok());
301    }
302
303    #[tokio::test]
304    async fn query_series_in_study_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_series_in_study("1.2.276.0.89.300.10035584652.20181014.93645")
310            .run()
311            .await;
312        assert!(result.is_ok());
313    }
314
315    #[tokio::test]
316    async fn query_instances_in_series_test() {
317        let mock_server = start_dicomweb_mock_server().await;
318        let client = DicomWebClient::with_single_url(&mock_server.uri());
319        // Perform QIDO-RS request
320        let result = client
321            .query_instances_in_series("1.2.276.0.89.300.10035584652.20181014.93645", "1.1.1.1")
322            .run()
323            .await;
324        assert!(result.is_ok());
325    }
326
327    #[tokio::test]
328    async fn retrieve_study_test() {
329        let mock_server = start_dicomweb_mock_server().await;
330        let client = DicomWebClient::with_single_url(&mock_server.uri());
331        // Perform WADO-RS request
332        let result = client
333            .retrieve_study("1.2.276.0.89.300.10035584652.20181014.93645")
334            .run()
335            .await;
336
337        assert!(result.is_ok());
338    }
339
340    #[tokio::test]
341    async fn retrieve_study_metadata_test() {
342        let mock_server = start_dicomweb_mock_server().await;
343        let client = DicomWebClient::with_single_url(&mock_server.uri());
344        // Perform WADO-RS request
345        let result = client
346            .retrieve_study_metadata("1.2.276.0.89.300.10035584652.20181014.93645")
347            .run()
348            .await;
349
350        assert!(result.is_ok());
351    }
352
353    #[tokio::test]
354    async fn retrieve_series_test() {
355        let mock_server = start_dicomweb_mock_server().await;
356        let client = DicomWebClient::with_single_url(&mock_server.uri());
357        // Perform WADO-RS request
358        let result = client
359            .retrieve_series(
360                "1.2.276.0.89.300.10035584652.20181014.93645",
361                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
362            )
363            .run()
364            .await;
365
366        assert!(result.is_ok());
367    }
368
369    #[tokio::test]
370    async fn retrieve_series_metadata_test() {
371        let mock_server = start_dicomweb_mock_server().await;
372        let client = DicomWebClient::with_single_url(&mock_server.uri());
373        // Perform WADO-RS request
374        let result = client
375            .retrieve_series_metadata(
376                "1.2.276.0.89.300.10035584652.20181014.93645",
377                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
378            )
379            .run()
380            .await;
381
382        assert!(result.is_ok());
383    }
384
385    #[tokio::test]
386    async fn retrieve_instance_test() {
387        let mock_server = start_dicomweb_mock_server().await;
388        let client = DicomWebClient::with_single_url(&mock_server.uri());
389        // Perform WADO-RS request
390        let result = client
391            .retrieve_instance(
392                "1.2.276.0.89.300.10035584652.20181014.93645",
393                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
394                "1.2.392.200036.9125.9.0.454007928.521494544.1883970570",
395            )
396            .run()
397            .await;
398        assert!(result.is_err_and(|e| e.to_string().contains("Empty")));
399    }
400
401    #[tokio::test]
402    async fn retrieve_instance_metadata_test() {
403        let mock_server = start_dicomweb_mock_server().await;
404        let client = DicomWebClient::with_single_url(&mock_server.uri());
405        // Perform WADO-RS request
406        let result = client
407            .retrieve_instance_metadata(
408                "1.2.276.0.89.300.10035584652.20181014.93645",
409                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
410                "1.2.392.200036.9125.9.0.454007928.521494544.1883970570",
411            )
412            .run()
413            .await;
414        assert!(result.is_ok());
415    }
416
417    #[tokio::test]
418    async fn retrieve_frames_test() {
419        let mock_server = start_dicomweb_mock_server().await;
420        let mut client = DicomWebClient::with_single_url(&mock_server.uri());
421        client.set_basic_auth("orthanc", "orthanc");
422        // Perform WADO-RS request
423        let result = client
424            .retrieve_frames(
425                "1.2.276.0.89.300.10035584652.20181014.93645",
426                "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
427                "1.2.392.200036.9125.9.0.454007928.521494544.1883970570",
428                &[1],
429            )
430            .run()
431            .await;
432        assert!(result.is_ok());
433    }
434
435    #[tokio::test]
436    async fn store_instances_test() {
437        let mock_server = start_dicomweb_mock_server().await;
438        let mut client = DicomWebClient::with_single_url(&mock_server.uri());
439        client.set_basic_auth("orthanc", "orthanc");
440        // Create new empty DICOM instance
441        let instance = InMemDicomObject::new_empty()
442            .with_meta(
443                FileMetaTableBuilder::new()
444                    // Implicit VR Little Endian
445                    .transfer_syntax(uids::IMPLICIT_VR_LITTLE_ENDIAN)
446                    // Computed Radiography image storage
447                    .media_storage_sop_class_uid("1.2.840.10008.5.1.4.1.1.1"),
448            )
449            .unwrap();
450        // Create a stream with the instance
451        let stream = futures_util::stream::once(async move { instance });
452
453        // Perform WADO-RS request
454        let result = client.store_instances().with_instances(stream).run().await;
455        assert!(result.is_ok());
456    }
457}