1use 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#[derive(Debug, Clone)]
65pub struct DicomWebClient {
66 wado_url: String,
67 qido_url: String,
68 stow_url: String,
69
70 pub(crate) username: Option<String>,
72 pub(crate) password: Option<String>,
73 pub(crate) bearer_token: Option<String>,
75 pub(crate) extra_headers: HashMap<String, String>,
77
78 pub(crate) client: reqwest::Client,
79}
80
81#[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 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 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 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 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
158pub(crate) fn selector_to_string(selector: &AttributeSelector) -> String {
160 let mut result = String::new();
161
162 for step in selector.iter() {
163 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 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
190pub(crate) fn apply_auth_and_headers(
192 mut request: reqwest::RequestBuilder,
193 client: &DicomWebClient,
194) -> reqwest::RequestBuilder {
195 if let Some(username) = &client.username {
197 request = request.basic_auth(username, client.password.as_ref());
198 }
199 else if let Some(bearer_token) = &client.bearer_token {
201 request = request.bearer_auth(bearer_token);
202 }
203
204 for (key, value) in &client.extra_headers {
206 request = request.header(key, value);
207 }
208
209 request
210}
211
212pub(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 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
231pub(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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let instance = InMemDicomObject::new_empty()
576 .with_meta(
577 FileMetaTableBuilder::new()
578 .transfer_syntax(uids::IMPLICIT_VR_LITTLE_ENDIAN)
580 .media_storage_sop_class_uid("1.2.840.10008.5.1.4.1.1.1"),
582 )
583 .unwrap();
584 let stream = futures_util::stream::once(async move { instance });
586
587 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 let result = client
598 .query_modality_scheduled_procedure_steps()
599 .run()
600 .await;
601 assert!(result.is_ok());
602 }
603}