Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- MPPS-RS (Modality Performed Procedure Step) support for the DIMSE backend: the Create, Update and
Retrieve transactions are translated into N-CREATE, N-SET and N-GET requests. The timeout can be
configured with the new `mpps-rs.timeout` option.

### Changed

### Fixed
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ aws-credential-types = { version = "1.2.13", optional = true }
testcontainers = "0.27.3"
dicom-test-files = "0.4.0"
dicom-web = "0.6.0"
reqwest = { version = "0.13.2", default-features = false, features = ["json"] }

[lints.rust]
unsafe_code = "forbid"
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,25 @@ https://www.dicomstandard.org/news-dir/current/docs/sups/sup246.pdf
| limit | Return only {n} results | ✅ |
| offset | Skip {n} results | ✅ |

### Report Modality Performed Procedure Steps (MPPS-RS)

https://dicom.nema.org/medical/dicom/current/output/chtml/part18/chapter_15.html

The DIMSE backend translates the transactions into N-CREATE, N-SET and N-GET requests of the
Modality Performed Procedure Step SOP Classes.

#### Transactions

| Transaction | Method | URI Template | Support Status |
| ----------- | ------ | -------------------------------------------------------------- | :------------: |
| Create | POST | `/modality-performed-procedure-steps/{mppsUID}` | ✅ |
| Update | POST | `/modality-performed-procedure-steps/{mppsUID}?update` | ✅ |
| Retrieve | GET | `/modality-performed-procedure-steps/{mppsUID}{?includefield}` | ✅¹ |

¹ Requires a PACS that supports the Modality Performed Procedure Step Retrieve SOP Class.

Only the `application/dicom+json` media type is supported.

### Manage worklist items (UPS-RS)

https://www.dicomstandard.org/using/dicomweb/workflow-ups-rs
Expand Down
7 changes: 7 additions & 0 deletions docs/topics/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ aets:
timeout: 3000
stow-rs:
timeout: 3000
mpps-rs:
timeout: 3000
```

Each AET (regardless of the backend) has additional settings specific to the DICOMweb endpoints.
Expand Down Expand Up @@ -236,6 +238,11 @@ Each AET (regardless of the backend) has additional settings specific to the DIC
This is the timeout for a single operation (e.g. receiving a DIMSE-C response primitive).
If you want to set a timeout for the total execution time, use the <code>server.http.request-timeout</code> option instead.
</def>
<def title="mpps-rs.timeout" id="dicomweb.mpps-rs.timeout">
How many milliseconds to wait until a MPPS-RS request should time out.
This is the timeout for a single operation (e.g. receiving a N-CREATE-RSP, N-SET-RSP or N-GET-RSP).
Defaults to <code>30000</code>.
</def>
</deflist>

## S3 Backend Config
Expand Down
4 changes: 3 additions & 1 deletion src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use serde::{Deserialize, Deserializer};

mod aets;
mod home;
pub mod mpps;
pub mod mwl;
pub mod qido;
pub mod stow;
Expand All @@ -27,7 +28,8 @@ pub fn routes(base_path: &str) -> Router<AppState> {
.merge(qido::routes())
.merge(wado::routes())
.merge(stow::routes())
.merge(mwl::routes()),
.merge(mwl::routes())
.merge(mpps::routes()),
);

// axum no longer supports nesting at the root
Expand Down
5 changes: 5 additions & 0 deletions src/api/mpps/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
mod routes;
mod service;

pub use routes::routes;
pub use service::*;
120 changes: 120 additions & 0 deletions src/api/mpps/routes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
use crate::api::mpps::{
MppsCreateRequest, MppsError, MppsRetrieveQueryParameters, MppsRetrieveRequest,
MppsUpdateRequest,
};
use crate::backend::ServiceProvider;
use crate::utils::dicom_json::DicomJsonBody;
use crate::AppState;
use axum::body::Body;
use axum::extract::{Path, RawQuery};
use axum::http::header::CONTENT_TYPE;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::Router;
use axum_extra::extract::Query;
use dicom_json::DicomJson;
use tracing::instrument;

const APPLICATION_DICOM_JSON: &str = "application/dicom+json";

/// HTTP Router for the Modality Performed Procedure Step Service.
/// <https://dicom.nema.org/medical/dicom/current/output/chtml/part18/chapter_15.html>
pub fn routes() -> Router<AppState> {
Router::new().route(
"/modality-performed-procedure-steps/{mpps_uid}",
post(create_or_update).get(retrieve),
)
}

/// The Create and Update Transaction share the same method and resource, but the Update
/// Transaction requires the `update` query parameter, which has no value.
/// This cannot be deserialized into an `Option`, as an empty value deserializes to `None`.
fn is_update(query: Option<&str>) -> bool {
query.is_some_and(|query| {
query
.split('&')
.any(|parameter| parameter.split('=').next() == Some("update"))
})
}

fn disabled() -> Response {
(
StatusCode::SERVICE_UNAVAILABLE,
"MPPS-RS endpoint is disabled",
)
.into_response()
}

/// Create Transaction and Update Transaction.
/// <https://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_15.4.html>
/// <https://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_15.5.html>
#[instrument(skip_all)]
async fn create_or_update(
provider: ServiceProvider,
Path((_aet, mpps_uid)): Path<(String, String)>,
RawQuery(query): RawQuery,
DicomJsonBody(data_set): DicomJsonBody,
) -> Response {
let Some(mpps) = provider.mpps else {
return disabled();
};

let result = if is_update(query.as_deref()) {
match MppsUpdateRequest::new(mpps_uid, data_set) {
Ok(request) => mpps.update(request).await.map(|()| StatusCode::OK),
Err(err) => Err(err),
}
} else {
match MppsCreateRequest::new(mpps_uid, data_set) {
Ok(request) => mpps.create(request).await.map(|()| StatusCode::CREATED),
Err(err) => Err(err),
}
};

result.map_or_else(MppsError::into_response, IntoResponse::into_response)
}

/// Retrieve Transaction.
/// <https://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_15.6.html>
#[instrument(skip_all)]
async fn retrieve(
provider: ServiceProvider,
Path((_aet, mpps_uid)): Path<(String, String)>,
Query(parameters): Query<MppsRetrieveQueryParameters>,
) -> Response {
let Some(mpps) = provider.mpps else {
return disabled();
};

let request = match MppsRetrieveRequest::new(mpps_uid, parameters) {
Ok(request) => request,
Err(err) => return err.into_response(),
};

match mpps.retrieve(request).await {
Ok(data_set) => Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, APPLICATION_DICOM_JSON)
.body(Body::from(
serde_json::to_string(&DicomJson::from(data_set)).unwrap(),
))
.unwrap(),
Err(err) => err.into_response(),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn detects_update_parameter() {
assert!(is_update(Some("update")));
assert!(is_update(Some("update=")));
assert!(is_update(Some("foo=bar&update")));
assert!(!is_update(None));
assert!(!is_update(Some("")));
assert!(!is_update(Some("updated")));
}
}
Loading
Loading