diff --git a/.github/workflows/rust-checks.yml b/.github/workflows/rust-checks.yml index 59f6b27..ce41ce5 100644 --- a/.github/workflows/rust-checks.yml +++ b/.github/workflows/rust-checks.yml @@ -33,6 +33,11 @@ jobs: - name: cargo clippy run: cargo clippy --all-targets --all-features --message-format=json | clippy-sarif | tee results.sarif | sarif-fmt + - name: Deduplicate SARIF relatedLocations + run: | + jq '(.runs[].results[] | select(.relatedLocations) | .relatedLocations) |= unique' results.sarif > results-dedup.sarif + mv results-dedup.sarif results.sarif + - name: Upload SARIF file uses: github/codeql-action/upload-sarif@v4 with: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e78a56a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,177 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +DICOM-RST is a DICOMweb-compatible gateway server written in Rust. It translates DICOMweb HTTP requests (QIDO-RS, WADO-RS, STOW-RS) to backend storage systems: + +- **DIMSE backend**: Translates DICOMweb to DIMSE-C protocol (C-FIND, C-MOVE, C-STORE) for PACS communication +- **S3 backend** (feature-gated): Retrieves DICOM instances from S3-compatible storage +- **Plugin backend** (feature-gated): Dynamically loaded plugins for custom backend implementations + +## Build Commands + +```bash +# Build (default - DIMSE backend only) +cargo build + +# Build with S3 backend support +cargo build --features s3 + +# Build with plugin support +cargo build --features plugins + +# Build example plugin (produces .so/.dylib/.dll) +cargo build -p dicom-rst-example-plugin --release + +# Run with development config +cargo run + +# Run tests +cargo test + +# Format code (uses hard tabs per .rustfmt.toml) +cargo fmt + +# Lint (project uses pedantic, nursery, and cargo clippy lints) +cargo clippy --all-targets --all-features + +# Dependency license/security check +cargo deny check +``` + +## Architecture + +### Core Modules + +- `src/main.rs` - Application entry point, Axum HTTP server setup, and DIMSE server spawning +- `src/api/` - HTTP route handlers organized by DICOMweb service (qido/, wado/, stow/) +- `src/backend/` - Backend implementations behind service traits + - `backend/mod.rs` - `ServiceProvider` extractor that routes requests to the correct backend based on AET config + - `backend/dimse/` - DIMSE protocol implementation with association pooling and DICOM SCU/SCP + - `backend/s3/` - S3 backend (requires `s3` feature) + - `backend/plugin/` - Plugin loading and adapters (requires `plugins` feature) +- `dicom-rst-plugin-api/` - Plugin API crate for external plugin development +- `example-plugin/` - Example plugin demonstrating the plugin interface +- `src/config/` - Configuration loading from YAML files and environment variables +- `src/rendering/` - DICOM image rendering for `/rendered` endpoints + +### Key Design Patterns + +**Backend Abstraction**: Services are defined as traits (`QidoService`, `WadoService`, `StowService`) in `src/api/{qido,wado,stow}/service.rs`. The `ServiceProvider` in `src/backend/mod.rs` is an Axum extractor that instantiates the appropriate backend based on the AET path parameter. + +**Association Pooling**: DIMSE backends use `AssociationPools` for connection reuse to PACS systems. + +**Move Mediator**: For WADO-RS, the `MoveMediator` coordinates C-MOVE operations where DICOM-RST acts as both the SCU (requesting move) and SCP (receiving instances). + +### Configuration + +Configuration loads from (in order): +1. `src/config/defaults.yaml` - embedded defaults +2. `config.yaml` - optional file in working directory +3. Environment variables prefixed with `DICOM_RST_` + +Each AET entry defines which backend to use and service-specific timeouts. + +## Code Style + +- Uses hard tabs for indentation +- Clippy is run with `pedantic`, `nursery`, and `cargo` lint groups enabled +- `unsafe_code` is forbidden + +## Plugin Development + +### Overview + +Plugins allow external implementations of DICOMweb services (QIDO-RS, WADO-RS, STOW-RS). Plugins are shared libraries (`.so`, `.dylib`, `.dll`) loaded at runtime using `abi_stable` for C ABI compatibility across Rust versions. + +### Crate Structure + +``` +dicom-rst-plugin-api/ # Plugin API - depend on this to create plugins +├── src/ +│ ├── lib.rs # PluginModule, declare_plugin! macro +│ ├── types.rs # FFI-safe types (FfiSearchRequest, FfiError, etc.) +│ ├── streaming.rs # FFI-safe streaming traits +│ ├── qido.rs # QidoPlugin trait +│ ├── wado.rs # WadoPlugin trait +│ └── stow.rs # StowPlugin trait +``` + +### Creating a Plugin + +1. Create a new library crate with `crate-type = ["cdylib"]` +2. Depend on `dicom-rst-plugin-api` +3. Implement the plugin traits (`QidoPlugin`, `WadoPlugin`, `StowPlugin`) +4. Use `declare_plugin!` macro to export the module + +```rust +use dicom_rst_plugin_api::*; + +struct MyQidoPlugin; + +impl QidoPlugin for MyQidoPlugin { + fn search(&self, request: FfiSearchRequest) + -> FfiFuture> + { + FfiFuture::new(async { + // Query your backend, return stream of DICOM JSON objects + FfiResult::ROk(create_stream()) + }) + } + + fn health_check(&self) -> FfiFuture> { + FfiFuture::new(async { FfiResult::ROk(()) }) + } +} + +declare_plugin! { + plugin_id: "my-plugin", + version: env!("CARGO_PKG_VERSION"), + capabilities: PluginCapabilities::qido_only(), + initialize: |config| { /* parse config.config_json */ FfiResult::ROk(()) }, + create_qido: || ROption::RSome(QidoPlugin_TO::from_value(MyQidoPlugin, TD_Opaque)), + create_wado: || ROption::RNone, + create_stow: || ROption::RNone, +} +``` + +### Plugin Configuration + +Plugins are configured in `config.yaml`: + +```yaml +plugins: + - path: "/path/to/libmy_plugin.so" + aets: + - MY_PACS + - ANOTHER_AET + settings: + database_url: "postgres://localhost/dicom" + custom_option: true +``` + +- `path`: Path to the shared library +- `aets`: List of AETs this plugin handles (requests to these AETs go to the plugin) +- `settings`: Arbitrary JSON passed to the plugin's `initialize` function + +### Key Types + +| Type | Purpose | +|------|---------| +| `FfiSearchRequest` | QIDO search parameters (level, UIDs, match criteria) | +| `FfiRetrieveRequest` | WADO retrieve parameters (resource query) | +| `FfiStoreRequest` | STOW store parameters (list of DICOM files) | +| `FfiDicomObject` | DICOM object as JSON string | +| `FfiDicomFile` | Raw DICOM Part 10 bytes | +| `FfiError` | Error with code and message | +| `FfiDicomObjectStreamBox` | Async stream of DICOM objects | +| `FfiDicomFileStreamBox` | Async stream of DICOM files | + +### Plugin Loading + +1. Plugins are loaded at application startup from the `plugins` config +2. The `PluginRegistry` (`src/backend/plugin/registry.rs`) manages loaded plugins +3. `ServiceProvider` checks the plugin registry before falling back to built-in backends +4. Adapter classes (`PluginQidoAdapter`, etc.) bridge FFI types to internal service traits diff --git a/Cargo.lock b/Cargo.lock index 6e9508c..943583d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,54 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "abi_stable" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d6512d3eb05ffe5004c59c206de7f99c34951504056ce23fc953842f12c445" +dependencies = [ + "abi_stable_derive", + "abi_stable_shared", + "const_panic", + "core_extensions", + "crossbeam-channel", + "generational-arena", + "libloading 0.7.4", + "lock_api", + "parking_lot", + "paste", + "repr_offset", + "rustc_version", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "abi_stable_derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7178468b407a4ee10e881bc7a328a65e739f0863615cca4429d43916b05e898" +dependencies = [ + "abi_stable_shared", + "as_derive_utils", + "core_extensions", + "proc-macro2", + "quote", + "rustc_version", + "syn 1.0.109", + "typed-arena", +] + +[[package]] +name = "abi_stable_shared" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b5df7688c123e63f4d4d649cba63f2967ba7f7861b1664fca3f77d3dad2b63" +dependencies = [ + "core_extensions", +] + [[package]] name = "actix-codec" version = "0.5.2" @@ -222,7 +270,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -237,6 +285,27 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as_derive_utils" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff3c96645900a44cf11941c111bd08a6573b0e2f9f69bc9264b179d8fae753c4" +dependencies = [ + "core_extensions", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-ffi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4de21c0feef7e5a556e51af767c953f0501f7f300ba785cc99c47bdc8081a50" +dependencies = [ + "abi_stable", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -256,7 +325,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -267,7 +336,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -822,7 +891,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -907,7 +976,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn", + "syn 2.0.107", ] [[package]] @@ -1060,7 +1129,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.9", ] [[package]] @@ -1124,6 +1193,15 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "const_panic" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -1159,6 +1237,21 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_extensions" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bb5e5d0269fd4f739ea6cedaf29c16d81c27a7ce7582008e90eb50dcd57003" +dependencies = [ + "core_extensions_proc_macros", +] + +[[package]] +name = "core_extensions_proc_macros" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533d38ecd2709b7608fb8e18e4504deb99e9a72879e6aa66373a76d8dc4259ea" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1205,6 +1298,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1324,7 +1426,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", "unicode-xid", ] @@ -1473,6 +1575,7 @@ dependencies = [ name = "dicom-rst" version = "0.3.0-beta.2" dependencies = [ + "abi_stable", "anyhow", "async-stream", "async-trait", @@ -1487,6 +1590,7 @@ dependencies = [ "dicom", "dicom-json", "dicom-pixeldata", + "dicom-rst-plugin-api", "futures", "image", "mime", @@ -1505,6 +1609,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "dicom-rst-plugin-api" +version = "0.1.0" +dependencies = [ + "abi_stable", + "async-ffi", + "serde", + "serde_json", +] + [[package]] name = "dicom-transfer-syntax-registry" version = "0.9.0" @@ -1555,7 +1669,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -1701,7 +1815,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -1769,7 +1883,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -1917,7 +2031,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -1950,6 +2064,15 @@ dependencies = [ "slab", ] +[[package]] +name = "generational-arena" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877e94aff08e743b651baaea359664321055749b398adff8740a7399af7796e7" +dependencies = [ + "cfg-if", +] + [[package]] name = "generic-array" version = "0.14.9" @@ -2518,7 +2641,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -2833,6 +2956,16 @@ dependencies = [ "cc", ] +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "libloading" version = "0.8.9" @@ -3079,7 +3212,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -3149,7 +3282,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -3299,7 +3432,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -3329,7 +3462,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -3417,7 +3550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.107", ] [[package]] @@ -3445,7 +3578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -3669,6 +3802,15 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "repr_offset" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1070755bd29dffc19d0971cab794e607839ba2ef4b69a9e6fbc8733c1b72ea" +dependencies = [ + "tstr", +] + [[package]] name = "reqwest" version = "0.12.24" @@ -4152,7 +4294,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -4324,7 +4466,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -4394,6 +4536,17 @@ dependencies = [ "is_ci", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.107" @@ -4422,7 +4575,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -4493,7 +4646,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -4504,7 +4657,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -4605,7 +4758,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -4796,7 +4949,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -4844,6 +4997,27 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tstr" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8e0294f14baae476d0dd0a2d780b2e24d66e349a9de876f5126777a37bdba7" +dependencies = [ + "tstr_proc_macros", +] + +[[package]] +name = "tstr_proc_macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78122066b0cb818b8afd08f7ed22f7fdbc3e90815035726f0840d0d26c0747a" + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typeid" version = "1.0.3" @@ -4856,6 +5030,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "typewit" +version = "1.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" + [[package]] name = "ucd-trie" version = "0.1.7" @@ -5055,7 +5235,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.107", "wasm-bindgen-shared", ] @@ -5090,7 +5270,7 @@ checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -5172,7 +5352,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -5183,7 +5363,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -5439,7 +5619,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", "synstructure", ] @@ -5460,7 +5640,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] @@ -5480,7 +5660,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", "synstructure", ] @@ -5520,7 +5700,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.107", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 57331e7..bf41e1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ readme = "README.md" [features] default = [] s3 = ["dep:aws-config", "dep:aws-sdk-s3", "dep:aws-credential-types"] +plugins = ["dep:dicom-rst-plugin-api", "dep:abi_stable"] [dependencies] # DICOM processing @@ -56,6 +57,9 @@ image = { version = "0.25.8", features = ["png", "jpeg", "gif"] } aws-config = { version = "1.8.8", features = ["behavior-version-latest"], optional = true } aws-sdk-s3 = { version = "1.108.0", optional = true } aws-credential-types = { version = "1.2.8", optional = true } +# Plugin support +dicom-rst-plugin-api = { path = "dicom-rst-plugin-api", optional = true } +abi_stable = { version = "0.11", optional = true } [lints.rust] unsafe_code = "forbid" diff --git a/dicom-rst-plugin-api/Cargo.lock b/dicom-rst-plugin-api/Cargo.lock new file mode 100644 index 0000000..bc90043 --- /dev/null +++ b/dicom-rst-plugin-api/Cargo.lock @@ -0,0 +1,403 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "abi_stable" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d6512d3eb05ffe5004c59c206de7f99c34951504056ce23fc953842f12c445" +dependencies = [ + "abi_stable_derive", + "abi_stable_shared", + "const_panic", + "core_extensions", + "crossbeam-channel", + "generational-arena", + "libloading", + "lock_api", + "parking_lot", + "paste", + "repr_offset", + "rustc_version", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "abi_stable_derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7178468b407a4ee10e881bc7a328a65e739f0863615cca4429d43916b05e898" +dependencies = [ + "abi_stable_shared", + "as_derive_utils", + "core_extensions", + "proc-macro2", + "quote", + "rustc_version", + "syn 1.0.109", + "typed-arena", +] + +[[package]] +name = "abi_stable_shared" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b5df7688c123e63f4d4d649cba63f2967ba7f7861b1664fca3f77d3dad2b63" +dependencies = [ + "core_extensions", +] + +[[package]] +name = "as_derive_utils" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff3c96645900a44cf11941c111bd08a6573b0e2f9f69bc9264b179d8fae753c4" +dependencies = [ + "core_extensions", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-ffi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4de21c0feef7e5a556e51af767c953f0501f7f300ba785cc99c47bdc8081a50" +dependencies = [ + "abi_stable", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const_panic" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] + +[[package]] +name = "core_extensions" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bb5e5d0269fd4f739ea6cedaf29c16d81c27a7ce7582008e90eb50dcd57003" +dependencies = [ + "core_extensions_proc_macros", +] + +[[package]] +name = "core_extensions_proc_macros" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533d38ecd2709b7608fb8e18e4504deb99e9a72879e6aa66373a76d8dc4259ea" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dicom-rst-plugin-api" +version = "0.1.0" +dependencies = [ + "abi_stable", + "async-ffi", + "serde", + "serde_json", +] + +[[package]] +name = "generational-arena" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877e94aff08e743b651baaea359664321055749b398adff8740a7399af7796e7" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "repr_offset" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1070755bd29dffc19d0971cab794e607839ba2ef4b69a9e6fbc8733c1b72ea" +dependencies = [ + "tstr", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "serde_json" +version = "1.0.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tstr" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8e0294f14baae476d0dd0a2d780b2e24d66e349a9de876f5126777a37bdba7" +dependencies = [ + "tstr_proc_macros", +] + +[[package]] +name = "tstr_proc_macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78122066b0cb818b8afd08f7ed22f7fdbc3e90815035726f0840d0d26c0747a" + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typewit" +version = "1.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "zmij" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d6085d62852e35540689d1f97ad663e3971fc19cf5eceab364d62c646ea167" diff --git a/dicom-rst-plugin-api/Cargo.toml b/dicom-rst-plugin-api/Cargo.toml new file mode 100644 index 0000000..b897d97 --- /dev/null +++ b/dicom-rst-plugin-api/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "dicom-rst-plugin-api" +version = "0.1.0" +edition = "2021" +description = "Plugin API for DICOM-RST DICOMweb server" +license = "MIT" +repository = "https://github.com/UMEssen/DICOM-RST" + +[features] +default = [] + +[dependencies] +abi_stable = "0.11" +async-ffi = { version = "0.5", features = ["abi_stable"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[lints.rust] +# unsafe_code is required for FFI with abi_stable +unsafe_code = "allow" + +[lints.clippy] +pedantic = "warn" +nursery = "warn" +# Allow non-local definitions from abi_stable macros +non_local_definitions = "allow" diff --git a/dicom-rst-plugin-api/src/lib.rs b/dicom-rst-plugin-api/src/lib.rs new file mode 100644 index 0000000..1fb678f --- /dev/null +++ b/dicom-rst-plugin-api/src/lib.rs @@ -0,0 +1,222 @@ +//! DICOM-RST Plugin API +//! +//! This crate defines the FFI-safe plugin interface for DICOM-RST. +//! Plugin authors should depend on this crate and implement the plugin traits +//! for their backend. +//! +//! # Example +//! +//! ```ignore +//! use dicom_rst_plugin_api::prelude::*; +//! +//! struct MyQidoPlugin; +//! +//! impl QidoPlugin for MyQidoPlugin { +//! fn search(&self, request: FfiSearchRequest) +//! -> FfiFuture<'static, FfiResult> +//! { +//! // Implementation here +//! } +//! +//! fn health_check(&self) -> FfiFuture<'static, FfiResult<()>> { +//! FfiFuture::new(async { RResult::ROk(()) }) +//! } +//! } +//! +//! declare_plugin! { +//! plugin_id: "my-plugin", +//! version: env!("CARGO_PKG_VERSION"), +//! capabilities: PluginCapabilities::qido_only(), +//! initialize: |_config| RResult::ROk(()), +//! create_qido: || ROption::RSome(QidoPluginBox::from_value( +//! MyQidoPlugin, +//! abi_stable::sabi_trait::TD_Opaque, +//! )), +//! create_wado: || ROption::RNone, +//! create_stow: || ROption::RNone, +//! } +//! ``` + +// Allow non_local_definitions warning from abi_stable's #[sabi_trait] macro +// This is a known issue with the macro generating impl blocks in const items +#![allow(non_local_definitions)] +#![allow(clippy::module_name_repetitions)] + +use abi_stable::{ + library::RootModule, + package_version_strings, + sabi_types::VersionStrings, + std_types::{ROption, RString}, + StableAbi, +}; + +pub mod qido; +pub mod stow; +pub mod streaming; +pub mod types; +pub mod wado; + +pub use qido::{QidoPlugin, QidoPluginBox, QidoPlugin_TO}; +pub use stow::{StowPlugin, StowPluginBox, StowPlugin_TO}; +pub use streaming::{ + FfiDicomFileStream, FfiDicomFileStreamBox, FfiDicomFileStream_TO, FfiDicomObjectStream, + FfiDicomObjectStreamBox, FfiDicomObjectStream_TO, FfiStreamResult, +}; +pub use types::*; +pub use wado::{WadoPlugin, WadoPluginBox, WadoPlugin_TO}; + +/// Prelude module for convenient imports. +pub mod prelude { + pub use crate::qido::{QidoPlugin, QidoPluginBox}; + pub use crate::stow::{StowPlugin, StowPluginBox}; + pub use crate::streaming::{ + FfiDicomFileStream, FfiDicomFileStreamBox, FfiDicomObjectStream, FfiDicomObjectStreamBox, + FfiStreamResult, + }; + pub use crate::types::*; + pub use crate::wado::{WadoPlugin, WadoPluginBox}; + pub use crate::{PluginModule, PluginModuleRef}; + + pub use abi_stable::std_types::{RBox, ROption, RResult, RString, RVec}; + pub use async_ffi::FfiFuture; +} + +/// Root module that plugins must export. +/// +/// This struct defines the entry points that the host application uses +/// to interact with the plugin. +#[repr(C)] +#[derive(StableAbi)] +#[sabi(kind(Prefix(prefix_ref = PluginModuleRef)))] +#[sabi(missing_field(panic))] +pub struct PluginModule { + /// Returns the plugin identifier (unique name). + pub plugin_id: extern "C" fn() -> RString, + + /// Returns the plugin version. + pub plugin_version: extern "C" fn() -> RString, + + /// Returns the plugin capabilities. + pub capabilities: extern "C" fn() -> PluginCapabilities, + + /// Initialize the plugin with configuration. + /// + /// Called once when the plugin is loaded. + /// The configuration is passed as a JSON string. + pub initialize: extern "C" fn(config: PluginConfig) -> FfiResult<()>, + + /// Create a QIDO service instance. + /// + /// Returns `RNone` if QIDO is not supported. + pub create_qido_service: extern "C" fn() -> ROption, + + /// Create a WADO service instance. + /// + /// Returns `RNone` if WADO is not supported. + pub create_wado_service: extern "C" fn() -> ROption, + + /// Create a STOW service instance. + /// + /// Returns `RNone` if STOW is not supported. + #[sabi(last_prefix_field)] + pub create_stow_service: extern "C" fn() -> ROption, +} + +impl RootModule for PluginModuleRef { + abi_stable::declare_root_module_statics! {PluginModuleRef} + + const BASE_NAME: &'static str = "dicom_rst_plugin"; + const NAME: &'static str = "dicom_rst_plugin"; + const VERSION_STRINGS: VersionStrings = package_version_strings!(); +} + +/// Helper macro for declaring a plugin. +/// +/// This macro generates the required `get_root_module` function that +/// the host application uses to load the plugin. +/// +/// # Example +/// +/// ```ignore +/// declare_plugin! { +/// plugin_id: "my-plugin", +/// version: "0.1.0", +/// capabilities: PluginCapabilities::all(), +/// initialize: |config| { +/// // Parse config.config_json and initialize +/// RResult::ROk(()) +/// }, +/// create_qido: || ROption::RSome(/* QidoPluginBox */), +/// create_wado: || ROption::RSome(/* WadoPluginBox */), +/// create_stow: || ROption::RSome(/* StowPluginBox */), +/// } +/// ``` +#[macro_export] +macro_rules! declare_plugin { + ( + plugin_id: $id:expr, + version: $version:expr, + capabilities: $caps:expr, + initialize: $init:expr, + create_qido: $qido:expr, + create_wado: $wado:expr, + create_stow: $stow:expr $(,)? + ) => { + /// Plugin entry point. + /// + /// This function is called by the host application to get the plugin module. + #[::abi_stable::export_root_module] + pub fn get_root_module() -> $crate::PluginModuleRef { + use ::abi_stable::prefix_type::PrefixTypeTrait; + + extern "C" fn plugin_id() -> ::abi_stable::std_types::RString { + ::abi_stable::std_types::RString::from($id) + } + + extern "C" fn plugin_version() -> ::abi_stable::std_types::RString { + ::abi_stable::std_types::RString::from($version) + } + + extern "C" fn capabilities() -> $crate::PluginCapabilities { + $caps + } + + extern "C" fn initialize(config: $crate::PluginConfig) -> $crate::FfiResult<()> { + let init_fn: fn($crate::PluginConfig) -> $crate::FfiResult<()> = $init; + init_fn(config) + } + + extern "C" fn create_qido_service( + ) -> ::abi_stable::std_types::ROption<$crate::QidoPluginBox> { + let create_fn: fn() -> ::abi_stable::std_types::ROption<$crate::QidoPluginBox> = + $qido; + create_fn() + } + + extern "C" fn create_wado_service( + ) -> ::abi_stable::std_types::ROption<$crate::WadoPluginBox> { + let create_fn: fn() -> ::abi_stable::std_types::ROption<$crate::WadoPluginBox> = + $wado; + create_fn() + } + + extern "C" fn create_stow_service( + ) -> ::abi_stable::std_types::ROption<$crate::StowPluginBox> { + let create_fn: fn() -> ::abi_stable::std_types::ROption<$crate::StowPluginBox> = + $stow; + create_fn() + } + + $crate::PluginModule { + plugin_id, + plugin_version, + capabilities, + initialize, + create_qido_service, + create_wado_service, + create_stow_service, + } + .leak_into_prefix() + } + }; +} diff --git a/dicom-rst-plugin-api/src/qido.rs b/dicom-rst-plugin-api/src/qido.rs new file mode 100644 index 0000000..139db2c --- /dev/null +++ b/dicom-rst-plugin-api/src/qido.rs @@ -0,0 +1,35 @@ +//! QIDO-RS plugin trait definition. +//! +//! QIDO-RS (Query based on ID for DICOM Objects by RESTful Services) provides +//! search functionality for DICOM objects. + +use abi_stable::{sabi_trait, std_types::RBox}; +use async_ffi::FfiFuture; + +use crate::streaming::FfiDicomObjectStreamBox; +use crate::types::{FfiResult, FfiSearchRequest}; + +/// FFI-safe QIDO service trait. +/// +/// Plugins implementing this trait provide QIDO-RS search functionality. +/// The search results are returned as a stream of DICOM objects in DICOM JSON format. +#[sabi_trait] +pub trait QidoPlugin: Send + Sync { + /// Execute a QIDO-RS search. + /// + /// # Arguments + /// * `request` - The search request containing query parameters + /// + /// # Returns + /// A stream of DICOM objects matching the search criteria, or an error. + fn search(&self, request: FfiSearchRequest) -> FfiFuture>; + + /// Check if the plugin is healthy and ready to serve requests. + /// + /// This is called periodically by the host to verify the plugin's status. + #[sabi(last_prefix_field)] + fn health_check(&self) -> FfiFuture>; +} + +/// Boxed QIDO plugin for use in the plugin registry. +pub type QidoPluginBox = QidoPlugin_TO<'static, RBox<()>>; diff --git a/dicom-rst-plugin-api/src/stow.rs b/dicom-rst-plugin-api/src/stow.rs new file mode 100644 index 0000000..1d8690e --- /dev/null +++ b/dicom-rst-plugin-api/src/stow.rs @@ -0,0 +1,32 @@ +//! STOW-RS plugin trait definition. +//! +//! STOW-RS (Store over the Web by RESTful Services) provides +//! storage functionality for DICOM objects. + +use abi_stable::{sabi_trait, std_types::RBox}; +use async_ffi::FfiFuture; + +use crate::types::{FfiResult, FfiStoreRequest, FfiStoreResponse}; + +/// FFI-safe STOW service trait. +/// +/// Plugins implementing this trait provide STOW-RS storage functionality. +#[sabi_trait] +pub trait StowPlugin: Send + Sync { + /// Store DICOM instances. + /// + /// # Arguments + /// * `request` - The store request containing DICOM instances as raw bytes + /// + /// # Returns + /// A response indicating which instances were stored successfully + /// and which failed, or an error. + fn store(&self, request: FfiStoreRequest) -> FfiFuture>; + + /// Check if the plugin is healthy and ready to serve requests. + #[sabi(last_prefix_field)] + fn health_check(&self) -> FfiFuture>; +} + +/// Boxed STOW plugin for use in the plugin registry. +pub type StowPluginBox = StowPlugin_TO<'static, RBox<()>>; diff --git a/dicom-rst-plugin-api/src/streaming.rs b/dicom-rst-plugin-api/src/streaming.rs new file mode 100644 index 0000000..0b642fd --- /dev/null +++ b/dicom-rst-plugin-api/src/streaming.rs @@ -0,0 +1,62 @@ +//! FFI-safe streaming abstractions for plugin API. +//! +//! Streaming is handled via callback-based iterators that can be polled +//! asynchronously across the FFI boundary. + +use abi_stable::{ + sabi_trait, + std_types::{RBox, ROption, RResult}, +}; +use async_ffi::FfiFuture; + +use crate::types::{FfiDicomFile, FfiDicomObject, FfiError}; + +/// FFI-safe result for stream items. +pub type FfiStreamResult = RResult; + +/// FFI-safe stream of DICOM objects (for QIDO results). +/// +/// This trait provides an async iterator interface for streaming DICOM objects +/// across the FFI boundary. Implementations should return `ROption::RNone` when +/// the stream is exhausted. +#[sabi_trait] +pub trait FfiDicomObjectStream: Send + Sync { + /// Poll for the next item in the stream. + /// + /// Returns: + /// - `RSome(ROk(object))` - Next DICOM object + /// - `RSome(RErr(error))` - Error occurred + /// - `RNone` - Stream exhausted + fn poll_next(&self) -> FfiFuture>>; + + /// Close the stream and release resources. + /// + /// This should be called when the stream is no longer needed. + /// After calling close, `poll_next` should return `RNone`. + #[sabi(last_prefix_field)] + fn close(&self); +} + +/// Boxed DICOM object stream for use in plugin APIs. +pub type FfiDicomObjectStreamBox = FfiDicomObjectStream_TO<'static, RBox<()>>; + +/// FFI-safe stream of DICOM files (for WADO results). +/// +/// Similar to `FfiDicomObjectStream` but yields raw DICOM file bytes. +#[sabi_trait] +pub trait FfiDicomFileStream: Send + Sync { + /// Poll for the next item in the stream. + /// + /// Returns: + /// - `RSome(ROk(file))` - Next DICOM file + /// - `RSome(RErr(error))` - Error occurred + /// - `RNone` - Stream exhausted + fn poll_next(&self) -> FfiFuture>>; + + /// Close the stream and release resources. + #[sabi(last_prefix_field)] + fn close(&self); +} + +/// Boxed DICOM file stream for use in plugin APIs. +pub type FfiDicomFileStreamBox = FfiDicomFileStream_TO<'static, RBox<()>>; diff --git a/dicom-rst-plugin-api/src/types.rs b/dicom-rst-plugin-api/src/types.rs new file mode 100644 index 0000000..ae2ac59 --- /dev/null +++ b/dicom-rst-plugin-api/src/types.rs @@ -0,0 +1,311 @@ +//! FFI-safe type definitions for the plugin API. +//! +//! All types crossing the FFI boundary must be `#[repr(C)]` and derive `StableAbi`. + +use abi_stable::{ + std_types::{ROption, RResult, RString, RVec}, + StableAbi, +}; + +// ============================================================================ +// Common Types +// ============================================================================ + +/// FFI-safe error type for plugin operations. +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct FfiError { + pub code: FfiErrorCode, + pub message: RString, +} + +impl FfiError { + pub fn new(code: FfiErrorCode, message: impl Into) -> Self { + Self { + code, + message: RString::from(message.into()), + } + } + + pub fn not_found(message: impl Into) -> Self { + Self::new(FfiErrorCode::NotFound, message) + } + + pub fn backend(message: impl Into) -> Self { + Self::new(FfiErrorCode::Backend, message) + } + + pub fn internal(message: impl Into) -> Self { + Self::new(FfiErrorCode::Internal, message) + } +} + +impl std::fmt::Display for FfiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}: {}", self.code, self.message) + } +} + +impl std::error::Error for FfiError {} + +/// Error codes for plugin operations. +#[repr(C)] +#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq)] +pub enum FfiErrorCode { + /// Resource not found + NotFound, + /// Invalid request parameters + InvalidRequest, + /// Backend error (database, network, etc.) + Backend, + /// Operation timed out + Timeout, + /// Internal plugin error + Internal, + /// Service not implemented + NotImplemented, +} + +/// FFI-safe result type. +pub type FfiResult = RResult; + +// ============================================================================ +// QIDO Types +// ============================================================================ + +/// FFI-safe query retrieve level. +#[repr(C)] +#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq)] +pub enum FfiQueryRetrieveLevel { + Patient, + Study, + Series, + Image, + Frame, +} + +/// FFI-safe DICOM tag (group, element). +#[repr(C)] +#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq)] +pub struct FfiTag { + pub group: u16, + pub element: u16, +} + +impl FfiTag { + pub const fn new(group: u16, element: u16) -> Self { + Self { group, element } + } +} + +/// FFI-safe match criterion: tag + value as string. +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct FfiMatchCriterion { + pub tag: FfiTag, + /// The match value as a string (same format as in HTTP query parameters) + pub value: RString, +} + +/// FFI-safe include field specification. +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub enum FfiIncludeField { + /// Include all available fields + All, + /// Include only the specified fields + List(RVec), +} + +/// FFI-safe QIDO search request. +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct FfiSearchRequest { + pub query_retrieve_level: FfiQueryRetrieveLevel, + pub study_instance_uid: ROption, + pub series_instance_uid: ROption, + pub match_criteria: RVec, + pub include_field: FfiIncludeField, + pub fuzzy_matching: bool, + pub limit: usize, + pub offset: usize, +} + +/// A single DICOM object serialized as DICOM JSON. +/// +/// DICOM JSON is defined in DICOM PS3.18 Annex F. +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct FfiDicomObject { + /// DICOM JSON representation of the object + pub dicom_json: RString, +} + +// ============================================================================ +// WADO Types +// ============================================================================ + +/// FFI-safe resource query for WADO operations. +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct FfiResourceQuery { + pub aet: RString, + pub study_instance_uid: RString, + pub series_instance_uid: ROption, + pub sop_instance_uid: ROption, +} + +/// FFI-safe retrieve instance request. +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct FfiRetrieveRequest { + pub query: FfiResourceQuery, + pub accept_header: ROption, +} + +/// FFI-safe metadata request. +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct FfiMetadataRequest { + pub query: FfiResourceQuery, +} + +/// FFI-safe rendering request. +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct FfiRenderingRequest { + pub query: FfiResourceQuery, + pub media_type: RString, + pub quality: ROption, + pub viewport: ROption, + pub window: ROption, +} + +/// FFI-safe viewport specification. +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct FfiViewport { + pub viewport_width: u32, + pub viewport_height: u32, + pub source_xpos: ROption, + pub source_ypos: ROption, + pub source_width: ROption, + pub source_height: ROption, +} + +/// FFI-safe window specification for VOI LUT. +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct FfiWindow { + pub center: f64, + pub width: f64, + pub function: FfiVoiLutFunction, +} + +/// FFI-safe VOI LUT function. +#[repr(C)] +#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq)] +pub enum FfiVoiLutFunction { + Linear, + LinearExact, + Sigmoid, +} + +/// Raw DICOM file bytes (Part 10 format). +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct FfiDicomFile { + pub data: RVec, +} + +/// Rendered image bytes with media type. +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct FfiRenderedResponse { + pub data: RVec, + pub media_type: RString, +} + +// ============================================================================ +// STOW Types +// ============================================================================ + +/// FFI-safe store request. +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct FfiStoreRequest { + /// List of DICOM files as raw bytes + pub instances: RVec, + pub study_instance_uid: ROption, +} + +/// FFI-safe instance reference (SOP Class UID + SOP Instance UID). +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct FfiInstanceReference { + pub sop_class_uid: RString, + pub sop_instance_uid: RString, +} + +/// FFI-safe store response. +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct FfiStoreResponse { + pub referenced_sequence: RVec, + pub failed_sequence: RVec, +} + +// ============================================================================ +// Plugin Configuration +// ============================================================================ + +/// Plugin configuration passed during initialization. +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct PluginConfig { + /// Plugin-specific configuration as JSON string + pub config_json: RString, +} + +/// Plugin capabilities flags. +#[repr(C)] +#[derive(StableAbi, Clone, Copy, Debug)] +pub struct PluginCapabilities { + pub supports_qido: bool, + pub supports_wado: bool, + pub supports_stow: bool, +} + +impl PluginCapabilities { + pub const fn all() -> Self { + Self { + supports_qido: true, + supports_wado: true, + supports_stow: true, + } + } + + pub const fn qido_only() -> Self { + Self { + supports_qido: true, + supports_wado: false, + supports_stow: false, + } + } + + pub const fn wado_only() -> Self { + Self { + supports_qido: false, + supports_wado: true, + supports_stow: false, + } + } + + pub const fn none() -> Self { + Self { + supports_qido: false, + supports_wado: false, + supports_stow: false, + } + } +} diff --git a/dicom-rst-plugin-api/src/wado.rs b/dicom-rst-plugin-api/src/wado.rs new file mode 100644 index 0000000..878e57c --- /dev/null +++ b/dicom-rst-plugin-api/src/wado.rs @@ -0,0 +1,60 @@ +//! WADO-RS plugin trait definition. +//! +//! WADO-RS (Web Access to DICOM Objects by RESTful Services) provides +//! retrieval functionality for DICOM objects. + +use abi_stable::{sabi_trait, std_types::RBox}; +use async_ffi::FfiFuture; + +use crate::streaming::FfiDicomFileStreamBox; +use crate::types::{ + FfiMetadataRequest, FfiRenderedResponse, FfiRenderingRequest, FfiResult, FfiRetrieveRequest, +}; + +/// FFI-safe WADO service trait. +/// +/// Plugins implementing this trait provide WADO-RS retrieval functionality +/// including instance retrieval, rendering, and metadata access. +#[sabi_trait] +pub trait WadoPlugin: Send + Sync { + /// Retrieve DICOM instances. + /// + /// Returns a stream of raw DICOM files (Part 10 format). + /// + /// # Arguments + /// * `request` - The retrieve request containing resource identifiers + /// + /// # Returns + /// A stream of DICOM files, or an error. + fn retrieve(&self, request: FfiRetrieveRequest) -> FfiFuture>; + + /// Render a DICOM instance to an image. + /// + /// Returns the rendered image in the requested format (JPEG, PNG, etc.). + /// + /// # Arguments + /// * `request` - The rendering request with viewport and window settings + /// + /// # Returns + /// The rendered image bytes with media type, or an error. + fn render(&self, request: FfiRenderingRequest) -> FfiFuture>; + + /// Retrieve metadata for DICOM instances. + /// + /// Returns a stream of DICOM files. The host will extract metadata + /// and strip bulk data before returning to the client. + /// + /// # Arguments + /// * `request` - The metadata request containing resource identifiers + /// + /// # Returns + /// A stream of DICOM files (metadata will be extracted by host), or an error. + fn metadata(&self, request: FfiMetadataRequest) -> FfiFuture>; + + /// Check if the plugin is healthy and ready to serve requests. + #[sabi(last_prefix_field)] + fn health_check(&self) -> FfiFuture>; +} + +/// Boxed WADO plugin for use in the plugin registry. +pub type WadoPluginBox = WadoPlugin_TO<'static, RBox<()>>; diff --git a/example-plugin/Cargo.lock b/example-plugin/Cargo.lock new file mode 100644 index 0000000..f11beff --- /dev/null +++ b/example-plugin/Cargo.lock @@ -0,0 +1,430 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "abi_stable" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d6512d3eb05ffe5004c59c206de7f99c34951504056ce23fc953842f12c445" +dependencies = [ + "abi_stable_derive", + "abi_stable_shared", + "const_panic", + "core_extensions", + "crossbeam-channel", + "generational-arena", + "libloading", + "lock_api", + "parking_lot", + "paste", + "repr_offset", + "rustc_version", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "abi_stable_derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7178468b407a4ee10e881bc7a328a65e739f0863615cca4429d43916b05e898" +dependencies = [ + "abi_stable_shared", + "as_derive_utils", + "core_extensions", + "proc-macro2", + "quote", + "rustc_version", + "syn 1.0.109", + "typed-arena", +] + +[[package]] +name = "abi_stable_shared" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b5df7688c123e63f4d4d649cba63f2967ba7f7861b1664fca3f77d3dad2b63" +dependencies = [ + "core_extensions", +] + +[[package]] +name = "as_derive_utils" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff3c96645900a44cf11941c111bd08a6573b0e2f9f69bc9264b179d8fae753c4" +dependencies = [ + "core_extensions", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-ffi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4de21c0feef7e5a556e51af767c953f0501f7f300ba785cc99c47bdc8081a50" +dependencies = [ + "abi_stable", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const_panic" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] + +[[package]] +name = "core_extensions" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bb5e5d0269fd4f739ea6cedaf29c16d81c27a7ce7582008e90eb50dcd57003" +dependencies = [ + "core_extensions_proc_macros", +] + +[[package]] +name = "core_extensions_proc_macros" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533d38ecd2709b7608fb8e18e4504deb99e9a72879e6aa66373a76d8dc4259ea" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dicom-rst-example-plugin" +version = "0.1.0" +dependencies = [ + "abi_stable", + "async-ffi", + "dicom-rst-plugin-api", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "dicom-rst-plugin-api" +version = "0.1.0" +dependencies = [ + "abi_stable", + "async-ffi", + "serde", + "serde_json", +] + +[[package]] +name = "generational-arena" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877e94aff08e743b651baaea359664321055749b398adff8740a7399af7796e7" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "repr_offset" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1070755bd29dffc19d0971cab794e607839ba2ef4b69a9e6fbc8733c1b72ea" +dependencies = [ + "tstr", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "serde_json" +version = "1.0.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "tstr" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8e0294f14baae476d0dd0a2d780b2e24d66e349a9de876f5126777a37bdba7" +dependencies = [ + "tstr_proc_macros", +] + +[[package]] +name = "tstr_proc_macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78122066b0cb818b8afd08f7ed22f7fdbc3e90815035726f0840d0d26c0747a" + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typewit" +version = "1.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "zmij" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d6085d62852e35540689d1f97ad663e3971fc19cf5eceab364d62c646ea167" diff --git a/example-plugin/Cargo.toml b/example-plugin/Cargo.toml new file mode 100644 index 0000000..774b700 --- /dev/null +++ b/example-plugin/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "dicom-rst-example-plugin" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "Example plugin for DICOM-RST demonstrating the plugin API" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +dicom-rst-plugin-api = { path = "../dicom-rst-plugin-api" } +abi_stable = "0.11" +async-ffi = { version = "0.5", features = ["abi_stable"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1", features = ["sync"] } diff --git a/example-plugin/src/lib.rs b/example-plugin/src/lib.rs new file mode 100644 index 0000000..3e1b930 --- /dev/null +++ b/example-plugin/src/lib.rs @@ -0,0 +1,292 @@ +//! Example plugin demonstrating how to implement a DICOM-RST plugin. +//! +//! This plugin provides stub implementations of QIDO-RS, WADO-RS, and STOW-RS +//! services for demonstration purposes. + +use abi_stable::std_types::{ROption, RString, RVec}; +use async_ffi::FfiFuture; +use dicom_rst_plugin_api::{ + declare_plugin, FfiDicomFile, FfiDicomFileStream, FfiDicomFileStreamBox, + FfiDicomFileStream_TO, FfiDicomObject, FfiDicomObjectStream, FfiDicomObjectStreamBox, + FfiDicomObjectStream_TO, FfiError, FfiErrorCode, FfiInstanceReference, FfiMetadataRequest, + FfiRenderedResponse, FfiRenderingRequest, FfiResult, FfiRetrieveRequest, FfiSearchRequest, + FfiStoreRequest, FfiStoreResponse, FfiStreamResult, PluginCapabilities, PluginConfig, + QidoPlugin, QidoPluginBox, QidoPlugin_TO, StowPlugin, StowPluginBox, StowPlugin_TO, WadoPlugin, + WadoPluginBox, WadoPlugin_TO, +}; +use serde::Deserialize; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Plugin configuration loaded from JSON. +#[derive(Debug, Deserialize)] +struct ExamplePluginConfig { + /// Optional message to log on initialization. + #[serde(default)] + init_message: Option, +} + +/// Shared plugin state. +struct PluginState { + initialized: AtomicBool, + config: Mutex>, +} + +static PLUGIN_STATE: std::sync::OnceLock> = std::sync::OnceLock::new(); + +fn get_state() -> &'static Arc { + PLUGIN_STATE.get_or_init(|| { + Arc::new(PluginState { + initialized: AtomicBool::new(false), + config: Mutex::new(None), + }) + }) +} + +// ============================================================================ +// QIDO Plugin Implementation +// ============================================================================ + +/// Example QIDO plugin that returns an empty stream for all queries. +struct ExampleQidoPlugin; + +impl ExampleQidoPlugin { + fn new() -> Self { + Self + } +} + +/// An empty stream implementation that immediately returns None. +struct EmptyObjectStream; + +impl FfiDicomObjectStream for EmptyObjectStream { + fn poll_next(&self) -> FfiFuture>> { + FfiFuture::new(async { ROption::RNone }) + } + + fn close(&self) { + // Nothing to clean up + } +} + +impl QidoPlugin for ExampleQidoPlugin { + fn search(&self, _request: FfiSearchRequest) -> FfiFuture> { + FfiFuture::new(async { + // Return an empty stream - a real plugin would query a database here + let stream = EmptyObjectStream; + let boxed: FfiDicomObjectStreamBox = + FfiDicomObjectStream_TO::from_value(stream, abi_stable::sabi_trait::TD_Opaque); + FfiResult::ROk(boxed) + }) + } + + fn health_check(&self) -> FfiFuture> { + FfiFuture::new(async { + if get_state().initialized.load(Ordering::SeqCst) { + FfiResult::ROk(()) + } else { + FfiResult::RErr(FfiError { + code: FfiErrorCode::Internal, + message: RString::from("Plugin not initialized"), + }) + } + }) + } +} + +// ============================================================================ +// WADO Plugin Implementation +// ============================================================================ + +/// Example WADO plugin that returns empty streams for all retrieve requests. +struct ExampleWadoPlugin; + +impl ExampleWadoPlugin { + fn new() -> Self { + Self + } +} + +/// An empty file stream implementation. +struct EmptyFileStream; + +impl FfiDicomFileStream for EmptyFileStream { + fn poll_next(&self) -> FfiFuture>> { + FfiFuture::new(async { ROption::RNone }) + } + + fn close(&self) { + // Nothing to clean up + } +} + +impl WadoPlugin for ExampleWadoPlugin { + fn retrieve(&self, _request: FfiRetrieveRequest) -> FfiFuture> { + FfiFuture::new(async { + // Return an empty stream - a real plugin would retrieve DICOM files here + let stream = EmptyFileStream; + let boxed: FfiDicomFileStreamBox = + FfiDicomFileStream_TO::from_value(stream, abi_stable::sabi_trait::TD_Opaque); + FfiResult::ROk(boxed) + }) + } + + fn render(&self, _request: FfiRenderingRequest) -> FfiFuture> { + FfiFuture::new(async { + // Return a 1x1 transparent PNG as a placeholder + let transparent_png: &[u8] = &[ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, + 0x15, 0xC4, 0x89, // IHDR data + 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, // IDAT chunk + 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, // IDAT data + 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, // IEND chunk + 0xAE, 0x42, 0x60, 0x82, // IEND CRC + ]; + + FfiResult::ROk(FfiRenderedResponse { + data: RVec::from(transparent_png.to_vec()), + media_type: RString::from("image/png"), + }) + }) + } + + fn metadata(&self, _request: FfiMetadataRequest) -> FfiFuture> { + FfiFuture::new(async { + // Return an empty stream - a real plugin would return DICOM metadata here + let stream = EmptyFileStream; + let boxed: FfiDicomFileStreamBox = + FfiDicomFileStream_TO::from_value(stream, abi_stable::sabi_trait::TD_Opaque); + FfiResult::ROk(boxed) + }) + } + + fn health_check(&self) -> FfiFuture> { + FfiFuture::new(async { + if get_state().initialized.load(Ordering::SeqCst) { + FfiResult::ROk(()) + } else { + FfiResult::RErr(FfiError { + code: FfiErrorCode::Internal, + message: RString::from("Plugin not initialized"), + }) + } + }) + } +} + +// ============================================================================ +// STOW Plugin Implementation +// ============================================================================ + +/// Example STOW plugin that accepts but ignores all stored instances. +struct ExampleStowPlugin; + +impl ExampleStowPlugin { + fn new() -> Self { + Self + } +} + +impl StowPlugin for ExampleStowPlugin { + fn store(&self, request: FfiStoreRequest) -> FfiFuture> { + FfiFuture::new(async move { + // Accept all instances but don't actually store them + let referenced_sequence: Vec = request + .instances + .iter() + .enumerate() + .map(|(i, _)| FfiInstanceReference { + sop_class_uid: RString::from("1.2.840.10008.5.1.4.1.1.2"), + sop_instance_uid: RString::from(format!("1.2.3.4.5.{}", i)), + }) + .collect(); + + FfiResult::ROk(FfiStoreResponse { + referenced_sequence: RVec::from(referenced_sequence), + failed_sequence: RVec::new(), + }) + }) + } + + fn health_check(&self) -> FfiFuture> { + FfiFuture::new(async { + if get_state().initialized.load(Ordering::SeqCst) { + FfiResult::ROk(()) + } else { + FfiResult::RErr(FfiError { + code: FfiErrorCode::Internal, + message: RString::from("Plugin not initialized"), + }) + } + }) + } +} + +// ============================================================================ +// Plugin Module Declaration +// ============================================================================ + +fn do_initialize(config: PluginConfig) -> FfiResult<()> { + let config_str = config.config_json.to_string(); + + // Parse configuration (allow empty config) + let parsed_config: ExamplePluginConfig = if config_str.is_empty() || config_str == "{}" { + ExamplePluginConfig { init_message: None } + } else { + match serde_json::from_str(&config_str) { + Ok(c) => c, + Err(e) => { + return FfiResult::RErr(FfiError { + code: FfiErrorCode::InvalidRequest, + message: RString::from(format!("Failed to parse config: {}", e)), + }); + } + } + }; + + // Log initialization message if provided + if let Some(msg) = &parsed_config.init_message { + eprintln!("[example-plugin] {}", msg); + } + + // Store config and mark as initialized + let state = get_state(); + if let Ok(mut guard) = state.config.try_lock() { + *guard = Some(parsed_config); + } + state.initialized.store(true, Ordering::SeqCst); + + FfiResult::ROk(()) +} + +fn do_create_qido_service() -> ROption { + let plugin = ExampleQidoPlugin::new(); + let boxed: QidoPluginBox = QidoPlugin_TO::from_value(plugin, abi_stable::sabi_trait::TD_Opaque); + ROption::RSome(boxed) +} + +fn do_create_wado_service() -> ROption { + let plugin = ExampleWadoPlugin::new(); + let boxed: WadoPluginBox = WadoPlugin_TO::from_value(plugin, abi_stable::sabi_trait::TD_Opaque); + ROption::RSome(boxed) +} + +fn do_create_stow_service() -> ROption { + let plugin = ExampleStowPlugin::new(); + let boxed: StowPluginBox = StowPlugin_TO::from_value(plugin, abi_stable::sabi_trait::TD_Opaque); + ROption::RSome(boxed) +} + +// Use the declare_plugin! macro to export the plugin module +declare_plugin! { + plugin_id: "example-plugin", + version: env!("CARGO_PKG_VERSION"), + capabilities: PluginCapabilities::all(), + initialize: do_initialize, + create_qido: do_create_qido_service, + create_wado: do_create_wado_service, + create_stow: do_create_stow_service, +} diff --git a/src/api/qido/service.rs b/src/api/qido/service.rs index c02b21b..4db9ad4 100644 --- a/src/api/qido/service.rs +++ b/src/api/qido/service.rs @@ -270,7 +270,9 @@ pub struct ResourceQuery { #[derive(Debug, Error)] pub enum SearchError { #[error(transparent)] - Backend { source: Box }, + Backend { + source: Box, + }, } #[cfg(test)] diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 8c907c9..5f01f7b 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -11,6 +11,9 @@ use std::time::Duration; pub mod dimse; +#[cfg(feature = "plugins")] +pub mod plugin; + #[cfg(feature = "s3")] pub mod s3; @@ -39,6 +42,16 @@ where let state = AppState::from_ref(state); + // First, check if this AET is served by a plugin + #[cfg(feature = "plugins")] + { + let registry = state.plugin_registry.read().await; + if let Some(plugin) = registry.get_for_aet(&aet) { + return Ok(Self::from_plugin(&plugin)); + } + } + + // Fall back to built-in backends let ae_config = state .config .aets @@ -87,3 +100,26 @@ where Ok(provider) } } + +#[cfg(feature = "plugins")] +impl ServiceProvider { + /// Create a `ServiceProvider` from a loaded plugin. + fn from_plugin(plugin: &plugin::LoadedPlugin) -> Self { + use plugin::{PluginQidoAdapter, PluginStowAdapter, PluginWadoAdapter}; + + Self { + qido: plugin + .qido + .clone() + .map(|p| Box::new(PluginQidoAdapter::new(p)) as Box), + wado: plugin + .wado + .clone() + .map(|p| Box::new(PluginWadoAdapter::new(p)) as Box), + stow: plugin + .stow + .clone() + .map(|p| Box::new(PluginStowAdapter::new(p)) as Box), + } + } +} diff --git a/src/backend/plugin/adapters.rs b/src/backend/plugin/adapters.rs new file mode 100644 index 0000000..b470f18 --- /dev/null +++ b/src/backend/plugin/adapters.rs @@ -0,0 +1,412 @@ +//! Adapters that wrap plugin boxes to implement internal service traits. + +use crate::api::qido::{ + IncludeField, QueryParameters, RequestHeaderFields as QidoRequestHeaderFields, + ResourceQuery as QidoResourceQuery, SearchError, SearchRequest, SearchResponse, +}; +use crate::api::stow::{InstanceReference, StoreError, StoreRequest, StoreResponse}; +use crate::api::wado::{ + InstanceResponse, MetadataRequest, RenderingRequest, RetrieveError, RetrieveInstanceRequest, + WadoService, +}; +use crate::api::wado::{RenderedResponse, ResourceQuery as WadoResourceQuery}; +use crate::backend::dimse::cmove::movescu::MoveError; +use crate::types::QueryRetrieveLevel; +use async_trait::async_trait; +use dicom::core::{PrimitiveValue, Tag}; +use dicom::object::{FileDicomObject, InMemDicomObject}; +use dicom_rst_plugin_api::{ + FfiIncludeField, FfiMatchCriterion, FfiMetadataRequest, FfiQueryRetrieveLevel, + FfiRenderingRequest, FfiResourceQuery, FfiRetrieveRequest, FfiSearchRequest, FfiTag, + FfiViewport, FfiVoiLutFunction, FfiWindow, QidoPluginBox, StowPluginBox, WadoPluginBox, +}; +use futures::stream::BoxStream; +use std::io::Cursor; +use std::sync::Arc; +use tracing::error; + +// ============================================================================ +// QIDO Adapter +// ============================================================================ + +/// Adapter that wraps a `QidoPluginBox` to implement `QidoService`. +pub struct PluginQidoAdapter { + plugin: Arc, +} + +impl PluginQidoAdapter { + pub fn new(plugin: Arc) -> Self { + Self { plugin } + } + + fn convert_request(request: &SearchRequest) -> FfiSearchRequest { + FfiSearchRequest { + query_retrieve_level: match request.query.query_retrieve_level { + QueryRetrieveLevel::Patient => FfiQueryRetrieveLevel::Patient, + QueryRetrieveLevel::Study => FfiQueryRetrieveLevel::Study, + QueryRetrieveLevel::Series => FfiQueryRetrieveLevel::Series, + QueryRetrieveLevel::Image => FfiQueryRetrieveLevel::Image, + QueryRetrieveLevel::Frame => FfiQueryRetrieveLevel::Frame, + }, + study_instance_uid: request + .query + .study_instance_uid + .as_ref() + .map(|s| s.as_str().into()) + .into(), + series_instance_uid: request + .query + .series_instance_uid + .as_ref() + .map(|s| s.as_str().into()) + .into(), + match_criteria: Vec::new().into(), // Match criteria conversion simplified + include_field: match &request.parameters.include_field { + IncludeField::All => FfiIncludeField::All, + IncludeField::List(tags) => FfiIncludeField::List( + tags.iter() + .map(|t| FfiTag::new(t.group(), t.element())) + .collect::>() + .into(), + ), + }, + fuzzy_matching: request.parameters.fuzzy_matching, + limit: request.parameters.limit, + offset: request.parameters.offset, + } + } +} + +fn primitive_value_to_string(value: &PrimitiveValue) -> String { + match value { + PrimitiveValue::Empty => String::new(), + PrimitiveValue::Str(s) => s.to_string(), + PrimitiveValue::Strs(strs) => strs.join("\\"), + _ => value.to_str().to_string(), + } +} + +#[async_trait] +impl crate::api::qido::QidoService for PluginQidoAdapter { + async fn search(&self, request: SearchRequest) -> SearchResponse { + let ffi_request = Self::convert_request(&request); + let plugin = Arc::clone(&self.plugin); + + let result = plugin.search(ffi_request).await; + + match result.into_result() { + Ok(stream) => { + // Convert FFI stream to BoxStream + let converted_stream = async_stream::stream! { + loop { + let item = stream.poll_next().await; + match item.into_option() { + Some(result) => { + match result.into_result() { + Ok(ffi_obj) => { + // Parse DICOM JSON back to InMemDicomObject + match dicom_json::from_str(&ffi_obj.dicom_json.to_string()) { + Ok(obj) => yield Ok(obj), + Err(e) => { + error!("Failed to parse DICOM JSON from plugin: {e}"); + break; + } + } + } + Err(e) => { + error!("Plugin search error: {}", e.message); + break; + } + } + } + None => break, + } + } + }; + + SearchResponse { + stream: Box::pin(converted_stream), + } + } + Err(e) => { + // Return empty stream on error + error!("Plugin search failed: {}", e.message); + SearchResponse { + stream: Box::pin(futures::stream::empty()), + } + } + } + } +} + +// ============================================================================ +// WADO Adapter +// ============================================================================ + +/// Adapter that wraps a `WadoPluginBox` to implement `WadoService`. +pub struct PluginWadoAdapter { + plugin: Arc, +} + +impl PluginWadoAdapter { + pub fn new(plugin: Arc) -> Self { + Self { plugin } + } + + fn convert_resource_query(query: &WadoResourceQuery) -> FfiResourceQuery { + FfiResourceQuery { + aet: query.aet.as_str().into(), + study_instance_uid: query.study_instance_uid.as_str().into(), + series_instance_uid: query + .series_instance_uid + .as_ref() + .map(|s| s.as_str().into()) + .into(), + sop_instance_uid: query + .sop_instance_uid + .as_ref() + .map(|s| s.as_str().into()) + .into(), + } + } + + fn convert_rendering_request(request: &RenderingRequest) -> FfiRenderingRequest { + FfiRenderingRequest { + query: Self::convert_resource_query(&request.query), + media_type: request.options.media_type.as_str().into(), + quality: request.options.quality.map(|q| q.as_u8()).into(), + viewport: request + .options + .viewport + .as_ref() + .map(|v| FfiViewport { + viewport_width: v.viewport_width, + viewport_height: v.viewport_height, + source_xpos: v.source_xpos.into(), + source_ypos: v.source_ypos.into(), + source_width: v.source_width.into(), + source_height: v.source_height.into(), + }) + .into(), + window: request + .options + .window + .as_ref() + .map(|w| FfiWindow { + center: w.center, + width: w.width, + function: match w.function { + crate::api::wado::VoiLutFunction::Linear => FfiVoiLutFunction::Linear, + crate::api::wado::VoiLutFunction::LinearExact => { + FfiVoiLutFunction::LinearExact + } + crate::api::wado::VoiLutFunction::Sigmoid => FfiVoiLutFunction::Sigmoid, + }, + }) + .into(), + } + } +} + +#[async_trait] +impl WadoService for PluginWadoAdapter { + async fn retrieve( + &self, + request: RetrieveInstanceRequest, + ) -> Result { + let ffi_request = FfiRetrieveRequest { + query: Self::convert_resource_query(&request.query), + accept_header: request + .headers + .accept + .as_ref() + .map(|s| s.as_str().into()) + .into(), + }; + + let plugin = Arc::clone(&self.plugin); + let result = plugin.retrieve(ffi_request).await; + + match result.into_result() { + Ok(stream) => { + let converted_stream: BoxStream< + 'static, + Result>, MoveError>, + > = Box::pin(async_stream::stream! { + loop { + let item = stream.poll_next().await; + match item.into_option() { + Some(result) => { + match result.into_result() { + Ok(ffi_file) => { + // Parse DICOM file from raw bytes + let cursor = Cursor::new(ffi_file.data.to_vec()); + match dicom::object::from_reader(cursor) { + Ok(obj) => yield Ok(Arc::new(obj)), + Err(e) => { + error!("Failed to parse DICOM file from plugin: {e}"); + yield Err(MoveError::OperationFailed); + } + } + } + Err(e) => { + error!("Plugin retrieve error: {}", e.message); + yield Err(MoveError::OperationFailed); + } + } + } + None => break, + } + } + }); + + Ok(InstanceResponse { + stream: converted_stream, + }) + } + Err(e) => Err(RetrieveError::Backend { + source: anyhow::anyhow!("Plugin error: {}", e.message), + }), + } + } + + async fn render(&self, request: RenderingRequest) -> Result { + let ffi_request = Self::convert_rendering_request(&request); + + let plugin = Arc::clone(&self.plugin); + let result = plugin.render(ffi_request).await; + + match result.into_result() { + Ok(rendered) => Ok(RenderedResponse(rendered.data.to_vec())), + Err(e) => Err(RetrieveError::Backend { + source: anyhow::anyhow!("Plugin render error: {}", e.message), + }), + } + } + + async fn metadata(&self, request: MetadataRequest) -> Result { + let ffi_request = FfiMetadataRequest { + query: Self::convert_resource_query(&request.query), + }; + + let plugin = Arc::clone(&self.plugin); + let result = plugin.metadata(ffi_request).await; + + match result.into_result() { + Ok(stream) => { + let converted_stream: BoxStream< + 'static, + Result>, MoveError>, + > = Box::pin(async_stream::stream! { + loop { + let item = stream.poll_next().await; + match item.into_option() { + Some(result) => { + match result.into_result() { + Ok(ffi_file) => { + let cursor = Cursor::new(ffi_file.data.to_vec()); + match dicom::object::from_reader(cursor) { + Ok(obj) => yield Ok(Arc::new(obj)), + Err(e) => { + error!("Failed to parse DICOM file from plugin: {e}"); + yield Err(MoveError::OperationFailed); + } + } + } + Err(e) => { + error!("Plugin metadata error: {}", e.message); + yield Err(MoveError::OperationFailed); + } + } + } + None => break, + } + } + }); + + Ok(InstanceResponse { + stream: converted_stream, + }) + } + Err(e) => Err(RetrieveError::Backend { + source: anyhow::anyhow!("Plugin metadata error: {}", e.message), + }), + } + } +} + +// ============================================================================ +// STOW Adapter +// ============================================================================ + +/// Adapter that wraps a `StowPluginBox` to implement `StowService`. +pub struct PluginStowAdapter { + plugin: Arc, +} + +impl PluginStowAdapter { + pub fn new(plugin: Arc) -> Self { + Self { plugin } + } +} + +#[async_trait] +impl crate::api::stow::StowService for PluginStowAdapter { + async fn store(&self, request: StoreRequest) -> Result { + // Convert DICOM objects to raw bytes + let instances: Vec<_> = request + .instances + .into_iter() + .filter_map(|obj| { + let mut buffer = Vec::new(); + match obj.write_all(&mut buffer) { + Ok(()) => Some(dicom_rst_plugin_api::FfiDicomFile { + data: buffer.into(), + }), + Err(e) => { + error!("Failed to serialize DICOM object for plugin: {e}"); + None + } + } + }) + .collect(); + + let ffi_request = dicom_rst_plugin_api::FfiStoreRequest { + instances: instances.into(), + study_instance_uid: request + .study_instance_uid + .as_ref() + .map(|s| s.as_str().into()) + .into(), + }; + + let plugin = Arc::clone(&self.plugin); + let result = plugin.store(ffi_request).await; + + match result.into_result() { + Ok(response) => Ok(StoreResponse { + referenced_sequence: response + .referenced_sequence + .iter() + .map(|r| InstanceReference { + sop_class_uid: r.sop_class_uid.to_string(), + sop_instance_uid: r.sop_instance_uid.to_string(), + }) + .collect(), + failed_sequence: response + .failed_sequence + .iter() + .map(|r| InstanceReference { + sop_class_uid: r.sop_class_uid.to_string(), + sop_instance_uid: r.sop_instance_uid.to_string(), + }) + .collect(), + }), + Err(e) => { + error!("Plugin store error: {}", e.message); + // Return empty response with all instances failed + Ok(StoreResponse::default()) + } + } + } +} diff --git a/src/backend/plugin/mod.rs b/src/backend/plugin/mod.rs new file mode 100644 index 0000000..906d217 --- /dev/null +++ b/src/backend/plugin/mod.rs @@ -0,0 +1,10 @@ +//! Plugin loading and registry infrastructure. +//! +//! This module provides functionality to load external plugins that implement +//! the QIDO-RS, WADO-RS, and/or STOW-RS services. + +mod adapters; +mod registry; + +pub use adapters::{PluginQidoAdapter, PluginStowAdapter, PluginWadoAdapter}; +pub use registry::{LoadedPlugin, PluginLoadError, PluginRegistry}; diff --git a/src/backend/plugin/registry.rs b/src/backend/plugin/registry.rs new file mode 100644 index 0000000..8bdbeb1 --- /dev/null +++ b/src/backend/plugin/registry.rs @@ -0,0 +1,181 @@ +//! Plugin registry for loading and managing plugins. + +use abi_stable::library::{lib_header_from_path, LibraryError, RootModule}; +use dicom_rst_plugin_api::{ + PluginCapabilities, PluginConfig, PluginModuleRef, QidoPluginBox, StowPluginBox, WadoPluginBox, +}; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use thiserror::Error; +use tracing::info; + +/// A loaded plugin with its services. +/// +/// Services are wrapped in Arc to allow sharing across multiple requests. +pub struct LoadedPlugin { + pub id: String, + pub version: String, + pub capabilities: PluginCapabilities, + pub qido: Option>, + pub wado: Option>, + pub stow: Option>, +} + +/// Registry for managing loaded plugins and AET bindings. +pub struct PluginRegistry { + plugins: HashMap>, + /// Maps AET to plugin ID + aet_bindings: HashMap, +} + +impl PluginRegistry { + /// Create a new empty plugin registry. + pub fn new() -> Self { + Self { + plugins: HashMap::new(), + aet_bindings: HashMap::new(), + } + } + + /// Load a plugin from a shared library path. + /// + /// # Arguments + /// * `path` - Path to the shared library (.so, .dylib, .dll) + /// * `config` - Plugin-specific configuration as JSON + /// + /// # Returns + /// The plugin ID if successful. + pub fn load_plugin( + &mut self, + path: &Path, + config_json: &str, + ) -> Result { + // Load the library + let header = lib_header_from_path(path).map_err(|e| PluginLoadError::LoadFailed { + path: path.display().to_string(), + source: e, + })?; + + // Get the root module + let module = header.init_root_module::().map_err(|e| { + PluginLoadError::InitFailed { + path: path.display().to_string(), + source: e, + } + })?; + + // Get plugin info + let id = (module.plugin_id())().to_string(); + let version = (module.plugin_version())().to_string(); + let capabilities = (module.capabilities())(); + + // Initialize the plugin + let ffi_config = PluginConfig { + config_json: config_json.into(), + }; + + (module.initialize())(ffi_config) + .into_result() + .map_err(|e| PluginLoadError::InitializationFailed { + plugin_id: id.clone(), + message: e.message.to_string(), + })?; + + // Create service instances (wrapped in Arc for sharing) + let qido = if capabilities.supports_qido { + (module.create_qido_service())().into_option().map(Arc::new) + } else { + None + }; + + let wado = if capabilities.supports_wado { + (module.create_wado_service())().into_option().map(Arc::new) + } else { + None + }; + + let stow = if capabilities.supports_stow { + (module.create_stow_service())().into_option().map(Arc::new) + } else { + None + }; + + info!( + plugin.id = %id, + plugin.version = %version, + plugin.qido = capabilities.supports_qido, + plugin.wado = capabilities.supports_wado, + plugin.stow = capabilities.supports_stow, + "Loaded plugin" + ); + + let plugin = Arc::new(LoadedPlugin { + id: id.clone(), + version, + capabilities, + qido, + wado, + stow, + }); + + self.plugins.insert(id.clone(), plugin); + Ok(id) + } + + /// Bind an AET to a plugin. + /// + /// Requests for this AET will be handled by the specified plugin. + pub fn bind_aet(&mut self, aet: &str, plugin_id: &str) -> Result<(), PluginLoadError> { + if !self.plugins.contains_key(plugin_id) { + return Err(PluginLoadError::PluginNotFound { + plugin_id: plugin_id.to_string(), + }); + } + + info!(aet = %aet, plugin.id = %plugin_id, "Bound AET to plugin"); + self.aet_bindings + .insert(aet.to_string(), plugin_id.to_string()); + Ok(()) + } + + /// Get the plugin for an AET. + pub fn get_for_aet(&self, aet: &str) -> Option> { + self.aet_bindings + .get(aet) + .and_then(|id| self.plugins.get(id)) + .cloned() + } + + /// Check if an AET is handled by a plugin. + pub fn has_aet(&self, aet: &str) -> bool { + self.aet_bindings.contains_key(aet) + } + + /// List all loaded plugins. + pub fn list_plugins(&self) -> impl Iterator { + self.plugins.values().map(Arc::as_ref) + } +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self::new() + } +} + +/// Errors that can occur when loading plugins. +#[derive(Debug, Error)] +pub enum PluginLoadError { + #[error("Failed to load plugin library at {path}: {source}")] + LoadFailed { path: String, source: LibraryError }, + + #[error("Failed to initialize plugin module at {path}: {source}")] + InitFailed { path: String, source: LibraryError }, + + #[error("Plugin {plugin_id} initialization failed: {message}")] + InitializationFailed { plugin_id: String, message: String }, + + #[error("Plugin not found: {plugin_id}")] + PluginNotFound { plugin_id: String }, +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 2a59781..6d6244e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -15,6 +15,24 @@ pub struct AppConfig { pub server: ServerConfig, #[serde(default)] pub aets: Vec, + #[cfg(feature = "plugins")] + #[serde(default)] + pub plugins: Vec, +} + +/// Configuration for an external plugin. +#[cfg(feature = "plugins")] +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct PluginConfiguration { + /// Path to the shared library (.so, .dylib, .dll) + pub path: String, + /// AETs served by this plugin + #[serde(default)] + pub aets: Vec, + /// Plugin-specific settings (passed as JSON to plugin) + #[serde(default)] + pub settings: serde_json::Value, } #[derive(Debug, Clone, Deserialize)] diff --git a/src/main.rs b/src/main.rs index 74413bb..3425dc9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,15 +8,23 @@ pub(crate) mod utils; use crate::backend::dimse::association; use crate::backend::dimse::cmove::MoveMediator; use crate::backend::dimse::StoreServiceClassProvider; +#[cfg(feature = "plugins")] +use crate::backend::plugin::PluginRegistry; use crate::config::{AppConfig, HttpServerConfig}; use crate::types::AE; use association::pool::AssociationPools; use axum::extract::{DefaultBodyLimit, Request}; use axum::response::Response; use std::net::SocketAddr; +#[cfg(feature = "plugins")] +use std::path::PathBuf; +#[cfg(feature = "plugins")] +use std::sync::Arc; use std::time::Duration; use tokio::net::TcpListener; use tokio::signal; +#[cfg(feature = "plugins")] +use tokio::sync::RwLock; use tower_http::cors::CorsLayer; use tower_http::timeout::TimeoutLayer; use tower_http::trace; @@ -59,6 +67,8 @@ pub struct AppState { pub config: AppConfig, pub pools: AssociationPools, pub mediator: MoveMediator, + #[cfg(feature = "plugins")] + pub plugin_registry: Arc>, } fn init_sentry(config: &AppConfig) -> sentry::ClientInitGuard { @@ -103,10 +113,40 @@ async fn run(config: AppConfig) -> anyhow::Result<()> { let mediator = MoveMediator::new(&config); let pools = AssociationPools::new(&config); + // Initialize plugin registry (when plugins feature is enabled) + #[cfg(feature = "plugins")] + let plugin_registry = { + let mut registry = PluginRegistry::new(); + + // Load plugins from configuration + for plugin_config in &config.plugins { + let plugin_path = PathBuf::from(&plugin_config.path); + let config_json = serde_json::to_string(&plugin_config.settings)?; + + match registry.load_plugin(&plugin_path, &config_json) { + Ok(plugin_id) => { + // Bind AETs to this plugin + for aet in &plugin_config.aets { + if let Err(e) = registry.bind_aet(aet, &plugin_id) { + error!("Failed to bind AET {aet} to plugin {plugin_id}: {e}"); + } + } + } + Err(e) => { + error!("Failed to load plugin from {}: {e}", plugin_config.path); + } + } + } + + Arc::new(RwLock::new(registry)) + }; + let app_state = AppState { config: config.clone(), mediator: mediator.clone(), pools, + #[cfg(feature = "plugins")] + plugin_registry, }; for dimse_config in config.server.dimse {