1use 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#[derive(Debug, Clone)]
64pub struct DicomWebClient {
65 wado_url: String,
66 qido_url: String,
67 stow_url: String,
68
69 pub(crate) username: Option<String>,
71 pub(crate) password: Option<String>,
72 pub(crate) bearer_token: Option<String>,
74 pub(crate) extra_headers: HashMap<String, String>,
76
77 pub(crate) client: reqwest::Client,
78}
79
80#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let instance = InMemDicomObject::new_empty()
453 .with_meta(
454 FileMetaTableBuilder::new()
455 .transfer_syntax(uids::IMPLICIT_VR_LITTLE_ENDIAN)
457 .media_storage_sop_class_uid("1.2.840.10008.5.1.4.1.1.1"),
459 )
460 .unwrap();
461 let stream = futures_util::stream::once(async move { instance });
463
464 let result = client.store_instances().with_instances(stream).run().await;
466 assert!(result.is_ok());
467 }
468}