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