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 serde_json::json;
149 use wiremock::MockServer;
150
151 use super::*;
152
153 async fn mock_qido(mock_server: &MockServer) {
154 let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
156 .and(wiremock::matchers::header_exists("Accept"))
157 .and(wiremock::matchers::path("/studies"))
158 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
159 mock_server.register(mock).await;
160 let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
162 .and(wiremock::matchers::header_exists("Accept"))
163 .and(wiremock::matchers::path("/series"))
164 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
165 mock_server.register(mock).await;
166 let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
168 .and(wiremock::matchers::header_exists("Accept"))
169 .and(wiremock::matchers::path("/instances"))
170 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
171 mock_server.register(mock).await;
172 let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
174 .and(wiremock::matchers::header_exists("Accept"))
175 .and(wiremock::matchers::path_regex("^/studies/[0-9.]+/series$"))
176 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
177 mock_server.register(mock).await;
178 let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
180 .and(wiremock::matchers::header_exists("Accept"))
181 .and(wiremock::matchers::path_regex(
182 "^/studies/[0-9.]+/series/[0-9.]+/instances$",
183 ))
184 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!([])));
185 mock_server.register(mock).await;
186 }
187
188 async fn mock_wado(mock_server: &MockServer) {
189 let dcm_multipart_response = wiremock::ResponseTemplate::new(200).set_body_raw(
190 "--1234\r\nContent-Type: application/dicom\r\n\r\n--1234--",
191 "multipart/related; boundary=1234",
192 );
193
194 let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
196 .and(wiremock::matchers::header_exists("Accept"))
197 .and(wiremock::matchers::path_regex("^/studies/[0-9.]+$"))
198 .respond_with(dcm_multipart_response.clone());
199 mock_server.register(mock).await;
200 let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
202 .and(wiremock::matchers::header_exists("Accept"))
203 .and(wiremock::matchers::path_regex(
204 "^/studies/[0-9.]+/metadata$",
205 ))
206 .respond_with(
207 wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/dicom+json"),
208 );
209 mock_server.register(mock).await;
210 let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
212 .and(wiremock::matchers::header_exists("Accept"))
213 .and(wiremock::matchers::path_regex(
214 r"^/studies/[0-9.]+/series/[0-9.]+$",
215 ))
216 .respond_with(dcm_multipart_response.clone());
217 mock_server.register(mock).await;
218 let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
220 .and(wiremock::matchers::header_exists("Accept"))
221 .and(wiremock::matchers::path_regex(
222 r"^/studies/[0-9.]+/series/[0-9.]+/metadata$",
223 ))
224 .respond_with(
225 wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/dicom+json"),
226 );
227 mock_server.register(mock).await;
228 let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
230 .and(wiremock::matchers::header_exists("Accept"))
231 .and(wiremock::matchers::path_regex(
232 r"^/studies/[0-9.]+/series/[0-9.]+/instances/[0-9.]+$",
233 ))
234 .respond_with(dcm_multipart_response.clone());
235 mock_server.register(mock).await;
236 let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
238 .and(wiremock::matchers::header_exists("Accept"))
239 .and(wiremock::matchers::path_regex(
240 r"^/studies/[0-9.]+/series/[0-9.]+/instances/[0-9.]+/metadata$",
241 ))
242 .respond_with(
243 wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/dicom+json"),
244 );
245 mock_server.register(mock).await;
246 let mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
248 .and(wiremock::matchers::header_exists("Accept"))
249 .and(wiremock::matchers::path_regex(
250 r"^/studies/[0-9.]+/series/[0-9.]+/instances/[0-9.]+/frames/[0-9,]+$",
251 ))
252 .respond_with(dcm_multipart_response);
253 mock_server.register(mock).await;
254 }
255
256 async fn start_dicomweb_mock_server() -> MockServer {
258 let mock_server = MockServer::start().await;
259 mock_qido(&mock_server).await;
260 mock_wado(&mock_server).await;
261 mock_server
262 }
263
264 #[tokio::test]
265 async fn query_study_test() {
266 let mock_server = start_dicomweb_mock_server().await;
267 let client = DicomWebClient::with_single_url(&mock_server.uri());
268 let result = client.query_studies().run().await;
270 assert!(result.is_ok());
271 }
272
273 #[tokio::test]
274 async fn query_series_test() {
275 let mock_server = start_dicomweb_mock_server().await;
276 let client = DicomWebClient::with_single_url(&mock_server.uri());
277 let result = client.query_series().run().await;
279 assert!(result.is_ok());
280 }
281
282 #[tokio::test]
283 async fn query_instances_test() {
284 let mock_server = start_dicomweb_mock_server().await;
285 let client = DicomWebClient::with_single_url(&mock_server.uri());
286 let result = client.query_instances().run().await;
288 assert!(result.is_ok());
289 }
290
291 #[tokio::test]
292 async fn query_series_in_study_test() {
293 let mock_server = start_dicomweb_mock_server().await;
294 let client = DicomWebClient::with_single_url(&mock_server.uri());
295 let result = client
297 .query_series_in_study("1.2.276.0.89.300.10035584652.20181014.93645")
298 .run()
299 .await;
300 assert!(result.is_ok());
301 }
302
303 #[tokio::test]
304 async fn query_instances_in_series_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_instances_in_series("1.2.276.0.89.300.10035584652.20181014.93645", "1.1.1.1")
310 .run()
311 .await;
312 assert!(result.is_ok());
313 }
314
315 #[tokio::test]
316 async fn retrieve_study_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 .retrieve_study("1.2.276.0.89.300.10035584652.20181014.93645")
322 .run()
323 .await;
324
325 assert!(result.is_ok());
326 }
327
328 #[tokio::test]
329 async fn retrieve_study_metadata_test() {
330 let mock_server = start_dicomweb_mock_server().await;
331 let client = DicomWebClient::with_single_url(&mock_server.uri());
332 let result = client
334 .retrieve_study_metadata("1.2.276.0.89.300.10035584652.20181014.93645")
335 .run()
336 .await;
337
338 assert!(result.is_ok());
339 }
340
341 #[tokio::test]
342 async fn retrieve_series_test() {
343 let mock_server = start_dicomweb_mock_server().await;
344 let client = DicomWebClient::with_single_url(&mock_server.uri());
345 let result = client
347 .retrieve_series(
348 "1.2.276.0.89.300.10035584652.20181014.93645",
349 "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
350 )
351 .run()
352 .await;
353
354 assert!(result.is_ok());
355 }
356
357 #[tokio::test]
358 async fn retrieve_series_metadata_test() {
359 let mock_server = start_dicomweb_mock_server().await;
360 let client = DicomWebClient::with_single_url(&mock_server.uri());
361 let result = client
363 .retrieve_series_metadata(
364 "1.2.276.0.89.300.10035584652.20181014.93645",
365 "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
366 )
367 .run()
368 .await;
369
370 assert!(result.is_ok());
371 }
372
373 #[tokio::test]
374 async fn retrieve_instance_test() {
375 let mock_server = start_dicomweb_mock_server().await;
376 let client = DicomWebClient::with_single_url(&mock_server.uri());
377 let result = client
379 .retrieve_instance(
380 "1.2.276.0.89.300.10035584652.20181014.93645",
381 "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
382 "1.2.392.200036.9125.9.0.454007928.521494544.1883970570",
383 )
384 .run()
385 .await;
386 assert!(result.is_err_and(|e| e.to_string().contains("Empty")));
387 }
388
389 #[tokio::test]
390 async fn retrieve_instance_metadata_test() {
391 let mock_server = start_dicomweb_mock_server().await;
392 let client = DicomWebClient::with_single_url(&mock_server.uri());
393 let result = client
395 .retrieve_instance_metadata(
396 "1.2.276.0.89.300.10035584652.20181014.93645",
397 "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
398 "1.2.392.200036.9125.9.0.454007928.521494544.1883970570",
399 )
400 .run()
401 .await;
402 assert!(result.is_ok());
403 }
404
405 #[tokio::test]
406 async fn retrieve_frames_test() {
407 let mock_server = start_dicomweb_mock_server().await;
408 let mut client = DicomWebClient::with_single_url(&mock_server.uri());
409 client.set_basic_auth("orthanc", "orthanc");
410 let result = client
412 .retrieve_frames(
413 "1.2.276.0.89.300.10035584652.20181014.93645",
414 "1.2.392.200036.9125.3.1696751121028.64888163108.42362053",
415 "1.2.392.200036.9125.9.0.454007928.521494544.1883970570",
416 &[1],
417 )
418 .run()
419 .await;
420 assert!(result.is_ok());
421 }
422}