1use mediatype::MediaTypeError;
52use multipart_rs::MultipartType;
53use reqwest::StatusCode;
54use snafu::Snafu;
55
56mod mwl;
57mod qido;
58mod stow;
59mod wado;
60#[derive(Debug, Clone)]
63pub struct DicomWebClient {
64 wado_url: String,
65 qido_url: String,
66 stow_url: String,
67
68 pub(crate) username: Option<String>,
70 pub(crate) password: Option<String>,
71 pub(crate) bearer_token: Option<String>,
73
74 pub(crate) client: reqwest::Client,
75}
76
77#[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 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 pub fn set_bearer_token(&mut self, token: &str) -> &Self {
115 self.bearer_token = Some(token.to_string());
116 self
117 }
118
119 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let instance = InMemDicomObject::new_empty()
442 .with_meta(
443 FileMetaTableBuilder::new()
444 .transfer_syntax(uids::IMPLICIT_VR_LITTLE_ENDIAN)
446 .media_storage_sop_class_uid("1.2.840.10008.5.1.4.1.1.1"),
448 )
449 .unwrap();
450 let stream = futures_util::stream::once(async move { instance });
452
453 let result = client.store_instances().with_instances(stream).run().await;
455 assert!(result.is_ok());
456 }
457}