diff --git a/docs/dcm4che5-migration-guide.md b/docs/dcm4che5-migration-guide.md new file mode 100644 index 0000000000..fadcdbb376 --- /dev/null +++ b/docs/dcm4che5-migration-guide.md @@ -0,0 +1,342 @@ +# dcm4che5 Migration Guide + +This guide covers migrating the OIE DICOM connector from the dcm4che2 backend to dcm4che5. + +## Overview + +The DICOM connector supports two backends selectable at server startup: + +- **dcm4che2** (default) — The legacy backend wrapping `MirthDcmSnd`/`MirthDcmRcv` +- **dcm4che5** — The modern backend using dcm4che3 APIs (`Device`/`Connection`/`ApplicationEntity`) + +Both backends expose identical behavior through the version-neutral `OieDicomSender`, `OieDicomReceiver`, and `OieDicomConverter` interfaces. Existing channel configurations (DICOM Listener and DICOM Sender properties) work with either backend without modification — with one exception noted in the [TLS section](#tls-configuration). + +## Prerequisites + +- Java 17 or later +- OIE build that includes `dicom-server-dcm5.jar` (the standard build produces it alongside `dicom-server-dcm2.jar`) + +## Enabling dcm4che5 + +Edit `server/conf/mirth.properties` and uncomment or add: + +```properties +dicom.library = dcm4che5 +``` + +**Restart the server.** The property is read once at startup and cached. + +To revert, change the value back to `dcm4che2` (or remove the line) and restart. + +## What Changes + +### Transparent (no action needed) + +| Area | Behavior | +|------|----------| +| Channel properties | Listener and Sender UI properties work identically (see [Settings with no effect on dcm4che5](#settings-with-no-effect-on-dcm4che5) for two pure-tuning flags that are ignored) | +| Source map keys | Same keys populated: `localApplicationEntityTitle`, `remoteApplicationEntityTitle`, `localAddress`, `localPort`, `remoteAddress`, `remotePort` | +| DICOM object serialization | Byte-level output differs (different FMI implementation version UIDs), but all tag values are semantically equivalent | +| C-STORE, C-ECHO | Both work through the standard channel lifecycle | +| Storage commitment | `N-ACTION` → `N-EVENT-REPORT` flow works through `commit()` / `waitForStgCmtResult()` | +| Transfer syntaxes | All standard transfer syntaxes supported | + +### Behavioral Differences + +| Area | dcm4che2 | dcm4che5 | +|------|----------|----------| +| **Architecture** | Thin wrapper around monolithic `MirthDcmSnd`/`MirthDcmRcv` | Composed from `Device` + `Connection` + `ApplicationEntity` + service handlers | +| **Element names** | `"Patient's Name"` (DICOM PS3.6 style) | `"PatientName"` (keyword style) | +| **C-STORE dispatch** | Synchronous — `send()` blocks until receiver processes | Asynchronous — `send()` returns immediately, receiver processes on worker thread | +| **XML serialization** | Standard `TransformerFactory` | Hardened `TransformerFactory` with XXE protections (`ACCESS_EXTERNAL_DTD` and `ACCESS_EXTERNAL_STYLESHEET` disabled) | +| **Keystore type** | Inferred automatically from URL | Inferred from file extension (`.p12`/`.pfx` → PKCS12, otherwise JKS); can be set explicitly via `setKeyStoreType()` | + +### Element Name Difference + +If your channel logic inspects DICOM element names (not tag numbers), be aware that dcm4che5 uses keyword-style names: + +```java +// dcm4che2: "Patient's Name" +// dcm4che5: "PatientName" + +// Safe approach: use tag numbers, which are identical across backends +dicomObject.getString(Tag.PatientName) // works on both +``` + +### Settings with no effect + +A few Listener and Sender UI fields have no corresponding implementation on the dcm4che5 backend. The dcm4che2 backend behavior is preserved unchanged. A `WARN`-level log is emitted on channel start when any of these are set to a non-default value on dcm4che5: + +| UI setting | Connector | Note | +|---|---|---| +| `bufSize` | Listener | File buffer size. dcm4che3 manages buffers internally; no equivalent API | +| `bufSize` | Sender | Transcoder buffer size. dcm4che3 manages buffers internally via `DataWriterAdapter` | +| `dest` (Store Received Objects in Directory) | Listener | Silently ignored on **both backends** — a long-standing upstream behavior. `MirthDcmRcv` streams DIMSE data directly to the channel and never consults this setting on either backend | + +None of these affect data integrity. Messages still arrive and dispatch through the channel correctly. The `bufSize` flags are pure performance tuning; revert `dicom.library` to `dcm4che2` if the throughput difference matters for your deployment. + +All other UI settings — TLS options, AE titles, timeouts, PDU lengths, transfer syntax selection, storage commitment, user identity, and priority — work identically on both backends. + +### DICOMUtil API (user transformer scripts) + +`DICOMUtil.byteArrayToDicomObject()` and `dicomObjectToByteArray()` now return / accept the version-neutral `OieDicomObject` type. For the vast majority of transformer scripts this change is invisible — Rhino's duck typing plus Object-type overloads on `OieDicomObject` mean existing calls keep working unchanged: + +```javascript +// Existing scripts continue to work on default (dcm4che2) without changes: +var dcm = DICOMUtil.byteArrayToDicomObject(bytes, false); +dcm.getString(Tag.PatientName); // same method exists on OieDicomObject +dcm.putString(Tag.PatientName, VR.PN, "SMITH"); // Object-overload routes via VR.toString() +``` + +Only these specific patterns require a one-line change: + +| Pattern | Change | +|---|---| +| `(DicomObject) DICOMUtil.byteArrayToDicomObject(...)` explicit cast | `(DicomObject) DICOMUtil.byteArrayToDicomObject(...).unwrap()` | +| `dcm instanceof DicomObject` | `dcm.unwrap() instanceof DicomObject` | +| Passing the result to a Java API that expects `org.dcm4che2.data.DicomObject` | Pass `dcm.unwrap()` instead | + +The recommended version-neutral pattern for new scripts is to use string VR codes instead of library-specific constants: + +```javascript +// Works identically on both backends — no dependency on VR class: +dcm.putString(Tag.PatientName, "PN", "SMITH"); +``` + +## TLS Configuration + +### Standard UI TLS Options + +The DICOM connector UI offers three TLS cipher presets: + +| UI Setting | Cipher Suite | Status | +|------------|-------------|--------| +| `aes` | `TLS_RSA_WITH_AES_128_CBC_SHA` | Disabled by default in current JDK security policies | +| `3des` | `SSL_RSA_WITH_3DES_EDE_CBC_SHA` | Disabled by default in current JDK security policies | +| `without` | `SSL_RSA_WITH_NULL_SHA` | Disabled by default in current JDK security policies | + +These legacy cipher suites are disabled by current JDK security policies (Java 17 and later). This affects both backends equally. If your channels use TLS with any of these presets, they will fail with an `SSLHandshakeException` until you supply a custom cipher suite via a `DICOMConfiguration` (see below). + +### Recommended: Custom Cipher Suites via DICOMConfiguration + +For TLS on Java 21, implement a custom `DICOMConfiguration` that uses modern cipher suites: + +**For dcm4che5:** + +```java +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DICOMConfiguration; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomSender; + +public class MyDcm5DICOMConfiguration implements Dcm5DICOMConfiguration { + + private static final String[] MODERN_CIPHERS = { + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" + }; + + @Override + public void configureDcm5Sender(Dcm5DicomSender sender, DICOMDispatcher connector, + DICOMDispatcherProperties props) throws Exception { + // Apply standard config first + DICOMConfigurationUtil.configureSender(sender, connector, props, protocols); + + // Override cipher suites with modern ones + if (!"notls".equals(props.getTls())) { + sender.setTlsCipherSuites(MODERN_CIPHERS); + } + } + + @Override + public void configureDcm5Receiver(Dcm5DicomReceiver receiver, DICOMReceiver connector, + DICOMReceiverProperties props) throws Exception { + DICOMConfigurationUtil.configureReceiver(receiver, connector, props, protocols); + + if (!"notls".equals(props.getTls())) { + receiver.setTlsCipherSuites(MODERN_CIPHERS); + } + } + + // ... remaining methods same as DefaultDcm5DICOMConfiguration +} +``` + +Then register your custom class. The `dicomConfigurationClass` is a Mirth **server configuration property** (stored in the database, not `mirth.properties`). Set it via the Mirth Administrator or REST API: + +``` +PUT /api/server/configuration/DICOM/dicomConfigurationClass +Body: com.example.MyDcm5DICOMConfiguration +``` + +And ensure dcm4che5 is enabled in `mirth.properties`: + +```properties +dicom.library = dcm4che5 +``` + +### Keystore Type + +dcm4che5 requires an explicit keystore/truststore type. The default behavior infers this from the file extension: + +| Extension | Inferred Type | +|-----------|--------------| +| `.p12`, `.pfx` | `PKCS12` | +| `.jks`, or anything else | `JKS` | + +If your keystore URL does not have a standard extension (e.g., loaded from a classpath resource or HTTP URL), set the type explicitly in your custom `DICOMConfiguration`: + +```java +sender.setKeyStoreType("PKCS12"); +sender.setTrustStoreType("PKCS12"); +``` + +## Custom DICOMConfiguration Migration + +If you have a custom `DICOMConfiguration` implementation: + +### If staying on dcm4che2 + +Change your class declaration from: + +```java +// Before (no longer compiles — DICOMConfiguration is now version-neutral) +public class MyConfig implements DICOMConfiguration { + void configureDcmSnd(MirthDcmSnd dcmsnd, ...) { ... } + void configureDcmRcv(MirthDcmRcv dcmrcv, ...) { ... } +} +``` + +To: + +```java +// After (one-line change — same method signatures) +public class MyConfig implements Dcm2DICOMConfiguration { + void configureDcmSnd(MirthDcmSnd dcmsnd, ...) { ... } + void configureDcmRcv(MirthDcmRcv dcmrcv, ...) { ... } +} +``` + +The `Dcm2DICOMConfiguration` interface has the same method signatures as the original pre-abstraction `DICOMConfiguration`. The bridge defaults handle the version-neutral interface methods automatically. + +### If migrating to dcm4che5 + +Implement `Dcm5DICOMConfiguration` instead: + +```java +public class MyConfig implements Dcm5DICOMConfiguration { + + @Override + public void configureDcm5Sender(Dcm5DicomSender sender, DICOMDispatcher connector, + DICOMDispatcherProperties props) throws Exception { + // Use DICOMConfigurationUtil for standard property wiring + DICOMConfigurationUtil.configureSender(sender, connector, props, protocols); + + // Add custom configuration + sender.setTlsCipherSuites(new String[]{"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"}); + } + + @Override + public void configureDcm5Receiver(Dcm5DicomReceiver receiver, DICOMReceiver connector, + DICOMReceiverProperties props) throws Exception { + DICOMConfigurationUtil.configureReceiver(receiver, connector, props, protocols); + } + + @Override + public Map getCStoreRequestInformation(Association association) { + // association is org.dcm4che3.net.Association (not org.dcm4che2) + Map map = new HashMap<>(); + map.put("calledAET", association.getCalledAET()); + return map; + } + + @Override + public Connection createDcm5Connection() { + return new Connection(); + } + + // ... +} +``` + +Key differences from `Dcm2DICOMConfiguration`: + +| | `Dcm2DICOMConfiguration` | `Dcm5DICOMConfiguration` | +|---|---|---| +| Receiver method | `configureDcmRcv(MirthDcmRcv, ...)` | `configureDcm5Receiver(Dcm5DicomReceiver, ...)` | +| Sender method | `configureDcmSnd(MirthDcmSnd, ...)` | `configureDcm5Sender(Dcm5DicomSender, ...)` | +| Association type | `org.dcm4che2.net.Association` | `org.dcm4che3.net.Association` | +| Network connection | `createLegacyNetworkConnection()` → `NetworkConnection` | `createDcm5Connection()` → `Connection` | + +## JAR Architecture + +The build produces three JARs: + +| JAR | Contents | When Loaded | +|-----|----------|-------------| +| `dicom-server.jar` | Version-neutral interfaces, factory, connector classes | Always | +| `dicom-server-dcm2.jar` | `Dcm2DicomSender`, `Dcm2DicomReceiver`, `Dcm2DicomConverter`, MirthDcmSnd/Rcv | `dicom.library=dcm4che2` | +| `dicom-server-dcm5.jar` | `Dcm5DicomSender`, `Dcm5DicomReceiver`, `Dcm5DicomConverter` | `dicom.library=dcm4che5` | + +The variant-based loading is controlled by the connector extension XML: + +```xml + + +``` + +Only the JAR matching the configured library is loaded at runtime. The factory uses `Class.forName()` to avoid compile-time dependencies between the version-neutral code and either backend. + +## Verification + +After switching to dcm4che5: + +1. **Check server logs for library detection:** + ``` + DICOM library backend: DCM4CHE5 + ``` + +2. **Test a non-TLS DICOM channel:** Deploy a DICOM Listener on a test port. Use any DICOM SCU (e.g., dcm4che's `storescu` CLI tool, or a DICOM Sender channel) to send a C-STORE. Verify the message arrives and is processed. + +3. **Test TLS channels (if applicable):** If using TLS, verify with a custom `DICOMConfiguration` that uses modern cipher suites (see [TLS section](#recommended-custom-cipher-suites-via-dicomconfiguration)). + +4. **Verify source map:** Check that the source map in received messages contains the expected keys: `localApplicationEntityTitle`, `remoteApplicationEntityTitle`, `localAddress`, `localPort`, `remoteAddress`, `remotePort`. + +5. **Test storage commitment (if applicable):** If any channels use storage commitment, verify the `N-ACTION` / `N-EVENT-REPORT` flow completes. + +## Rollback + +To revert to dcm4che2: + +1. Edit `server/conf/mirth.properties`: + ```properties + dicom.library = dcm4che2 + ``` + (or remove the line entirely — dcm4che2 is the default) + +2. If you changed `dicomConfigurationClass` (Mirth server configuration property) to a dcm5-specific implementation, revert it to your dcm2 implementation or remove the property via the Mirth Administrator or REST API. + +3. Restart the server. + +No channel configuration changes are needed — the version-neutral property model is shared. + +## Troubleshooting + +### `SSLHandshakeException: No appropriate protocol` + +The configured cipher suite is disabled in your JDK's security policy. This affects both backends on current JDKs. Use a custom `DICOMConfiguration` with modern cipher suites (see [TLS section](#tls-configuration)). + +### `IllegalStateException: keyStoreURL requires keyStoreType` + +dcm4che5 requires an explicit keystore type. The default inference handles `.jks`, `.p12`, and `.pfx` extensions. If your keystore URL has a non-standard extension, call `setKeyStoreType()` explicitly in your custom `DICOMConfiguration`. + +### `IncompatibleConnectionException` during sender `open()` + +dcm4che5 requires TLS cipher suites to match on both the local and remote `Connection` objects. This is handled automatically by the `Dcm5DicomSender` implementation — if you see this error, ensure you're calling `setTlsCipherSuites()` (not the individual preset methods) and that `initTLS()` is called after all TLS setters. + +### `NoPresentationContextException` + +The receiver and sender must agree on at least one transfer syntax. Verify that both sides are configured with compatible transfer syntaxes (e.g., `1.2.840.10008.1.2` for Implicit VR Little Endian, `1.2.840.10008.1.2.1` for Explicit VR Little Endian). + +### Element name mismatch in channel scripts + +If your JavaScript/channel scripts compare element names as strings, they may break because dcm4che5 uses keyword-style names (`"PatientName"`) rather than dcm4che2's display names (`"Patient's Name"`). Use tag numbers (`Tag.PatientName` / `0x00100010`) instead. diff --git a/server/build.xml b/server/build.xml index 011b3264e1..7569a28a49 100644 --- a/server/build.xml +++ b/server/build.xml @@ -160,13 +160,39 @@ - + + + + + + + + + + + + + + - + + - - + + + + + + + + + + + + diff --git a/server/conf/mirth.properties b/server/conf/mirth.properties index e3c9719f1f..4367133c60 100644 --- a/server/conf/mirth.properties +++ b/server/conf/mirth.properties @@ -112,3 +112,7 @@ database.connection.retrywaitinmilliseconds = 10000 # database-readonly.url = jdbc:... # database.enable-read-write-split = true + +# DICOM library backend. Supported values: dcm4che2 (default), dcm4che5. +# Changing this value requires a server restart. +# dicom.library = dcm4che2 diff --git a/server/docs/thirdparty/THIRD-PARTY-README.txt b/server/docs/thirdparty/THIRD-PARTY-README.txt index 39227b9af6..54dc4e6541 100644 --- a/server/docs/thirdparty/THIRD-PARTY-README.txt +++ b/server/docs/thirdparty/THIRD-PARTY-README.txt @@ -70,7 +70,9 @@ terms. DcmRcv and DcmSnd were modified to allow overriding of the network connections. - HAPI 2.3 (source code can be downloaded at: + dcm4che 5.34.3 (source code can be downloaded at: http://www.dcm4che.org/) + + HAPI 2.3 (source code can be downloaded at: https://github.com/hapifhir/hapi-hl7v2) iText, a free Java-PDF library version 2.1.7 (source code can be downloaded diff --git a/server/lib/extensions/dimse/dcm4che-core-5.34.3.jar b/server/lib/extensions/dimse/dcm4che-core-5.34.3.jar new file mode 100644 index 0000000000..696ee7e109 Binary files /dev/null and b/server/lib/extensions/dimse/dcm4che-core-5.34.3.jar differ diff --git a/server/lib/extensions/dimse/dcm4che-net-5.34.3.jar b/server/lib/extensions/dimse/dcm4che-net-5.34.3.jar new file mode 100644 index 0000000000..ca38f5775b Binary files /dev/null and b/server/lib/extensions/dimse/dcm4che-net-5.34.3.jar differ diff --git a/server/src/com/mirth/connect/connectors/dimse/DICOMConfiguration.java b/server/src/com/mirth/connect/connectors/dimse/DICOMConfiguration.java index 635ecc6726..d595f8cd7e 100644 --- a/server/src/com/mirth/connect/connectors/dimse/DICOMConfiguration.java +++ b/server/src/com/mirth/connect/connectors/dimse/DICOMConfiguration.java @@ -1,8 +1,8 @@ /* * Copyright (c) Mirth Corporation. All rights reserved. - * + * * http://www.mirthcorp.com - * + * * The software in this package is published under the terms of the MPL license a copy of which has * been included with this distribution in the LICENSE.txt file. */ @@ -11,22 +11,51 @@ import java.util.Map; -import org.dcm4che2.net.Association; -import org.dcm4che2.net.NetworkConnection; -import org.dcm4che2.tool.dcmrcv.MirthDcmRcv; -import org.dcm4che2.tool.dcmsnd.MirthDcmSnd; - +import com.mirth.connect.connectors.dimse.dicom.OieDicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.OieDicomSender; import com.mirth.connect.donkey.server.channel.Connector; +/** + * Version-neutral interface for DICOM connector configuration. Implementations + * configure the DICOM sender and receiver without direct dcm4che library dependencies. + * + *

Custom implementations can cast the sender/receiver to the dcm4che-specific + * type (e.g., Dcm2DicomSender) and call {@code unwrap()} to access the underlying + * MirthDcmSnd/MirthDcmRcv if needed. + */ public interface DICOMConfiguration { - public void configureConnectorDeploy(Connector connector) throws Exception; - - public NetworkConnection createNetworkConnection(); - - public void configureDcmRcv(MirthDcmRcv dcmrcv, DICOMReceiver connector, DICOMReceiverProperties connectorProperties) throws Exception; - - public void configureDcmSnd(MirthDcmSnd dcmsnd, DICOMDispatcher connector, DICOMDispatcherProperties connectorProperties) throws Exception; - - public Map getCStoreRequestInformation(Association as); -} \ No newline at end of file + void configureConnectorDeploy(Connector connector) throws Exception; + + void configureReceiver(OieDicomReceiver receiver, DICOMReceiver connector, DICOMReceiverProperties connectorProperties) throws Exception; + + void configureSender(OieDicomSender sender, DICOMDispatcher connector, DICOMDispatcherProperties connectorProperties) throws Exception; + + /** + * Extracts additional information from a DICOM C-STORE association request. + * The association parameter is the library-specific association object. + * + *

For the dcm4che2 backend the runtime type is + * {@code org.dcm4che2.net.Association}. Custom implementations should cast + * accordingly: + *

{@code
+     * Association as = (Association) association;
+     * map.put("calledAET", as.getCalledAET());
+     * }
+ * + * @param association The library-specific association object + * @return Additional key-value pairs to add to the source map + */ + Map getCStoreRequestInformation(Object association); + + /** + * Optional factory method for creating a custom {@code NetworkConnection}. + * The returned object must be an instance of the library-specific + * NetworkConnection class (e.g., {@code org.dcm4che2.net.NetworkConnection} + * for the dcm4che2 backend). If {@code null} is returned, the default + * NetworkConnection is used. + * + * @return A library-specific NetworkConnection, or {@code null} for the default + */ + default Object createNetworkConnection() { return null; } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/DICOMConfigurationUtil.java b/server/src/com/mirth/connect/connectors/dimse/DICOMConfigurationUtil.java index d873d13244..ba10c3b241 100644 --- a/server/src/com/mirth/connect/connectors/dimse/DICOMConfigurationUtil.java +++ b/server/src/com/mirth/connect/connectors/dimse/DICOMConfigurationUtil.java @@ -1,8 +1,8 @@ /* * Copyright (c) Mirth Corporation. All rights reserved. - * + * * http://www.mirthcorp.com - * + * * The software in this package is published under the terms of the MPL license a copy of which has * been included with this distribution in the LICENSE.txt file. */ @@ -15,49 +15,58 @@ import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; -import org.dcm4che2.tool.dcmrcv.MirthDcmRcv; -import org.dcm4che2.tool.dcmsnd.MirthDcmSnd; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import com.mirth.connect.connectors.dimse.dicom.OieDicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.OieDicomSender; import com.mirth.connect.util.MirthSSLUtil; public class DICOMConfigurationUtil { - public static void configureDcmRcv(MirthDcmRcv dcmrcv, DICOMReceiver connector, DICOMReceiverProperties connectorProperties, String[] protocols) throws Exception { + private static final Logger logger = LogManager.getLogger(DICOMConfigurationUtil.class); + + public static void configureReceiver(OieDicomReceiver receiver, DICOMReceiver connector, DICOMReceiverProperties connectorProperties, String[] protocols) throws Exception { if (!StringUtils.equals(connectorProperties.getTls(), "notls")) { if (connectorProperties.getTls().equals("without")) { - dcmrcv.setTlsWithoutEncyrption(); + logger.warn("DICOM receiver configured with TLS NULL encryption (SSL_RSA_WITH_NULL_SHA). " + + "This provides authentication only — data is sent in cleartext. " + + "Consider using AES encryption instead."); + receiver.setTlsWithoutEncryption(); } else if (connectorProperties.getTls().equals("3des")) { - dcmrcv.setTls3DES_EDE_CBC(); + logger.warn("DICOM receiver configured with deprecated 3DES cipher suite. " + + "Consider using AES encryption instead."); + receiver.setTls3DES_EDE_CBC(); } else if (connectorProperties.getTls().equals("aes")) { - dcmrcv.setTlsAES_128_CBC(); + receiver.setTlsAES_128_CBC(); } String trustStore = connector.getReplacer().replaceValues(connectorProperties.getTrustStore(), connector.getChannelId(), connector.getChannel().getName()); if (StringUtils.isNotBlank(trustStore)) { - dcmrcv.setTrustStoreURL(trustStore); + receiver.setTrustStoreURL(trustStore); } String trustStorePW = connector.getReplacer().replaceValues(connectorProperties.getTrustStorePW(), connector.getChannelId(), connector.getChannel().getName()); if (StringUtils.isNotBlank(trustStorePW)) { - dcmrcv.setTrustStorePassword(trustStorePW); + receiver.setTrustStorePassword(trustStorePW); } String keyPW = connector.getReplacer().replaceValues(connectorProperties.getKeyPW(), connector.getChannelId(), connector.getChannel().getName()); if (StringUtils.isNotBlank(keyPW)) { - dcmrcv.setKeyPassword(keyPW); + receiver.setKeyPassword(keyPW); } String keyStore = connector.getReplacer().replaceValues(connectorProperties.getKeyStore(), connector.getChannelId(), connector.getChannel().getName()); if (StringUtils.isNotBlank(keyStore)) { - dcmrcv.setKeyStoreURL(keyStore); + receiver.setKeyStoreURL(keyStore); } String keyStorePW = connector.getReplacer().replaceValues(connectorProperties.getKeyStorePW(), connector.getChannelId(), connector.getChannel().getName()); if (StringUtils.isNotBlank(keyStorePW)) { - dcmrcv.setKeyStorePassword(keyStorePW); + receiver.setKeyStorePassword(keyStorePW); } - dcmrcv.setTlsNeedClientAuth(connectorProperties.isNoClientAuth()); + receiver.setTlsNeedClientAuth(connectorProperties.isNoClientAuth()); protocols = ArrayUtils.clone(protocols); @@ -73,31 +82,38 @@ public static void configureDcmRcv(MirthDcmRcv dcmrcv, DICOMReceiver connector, protocols = protocolsList.toArray(new String[protocolsList.size()]); } - dcmrcv.setTlsProtocol(MirthSSLUtil.getEnabledHttpsProtocols(protocols)); + receiver.setTlsProtocol(MirthSSLUtil.getEnabledHttpsProtocols(protocols)); - dcmrcv.initTLS(); + receiver.initTLS(); } } - public static void configureDcmSnd(MirthDcmSnd dcmsnd, DICOMDispatcher connector, DICOMDispatcherProperties connectorProperties, String[] protocols) throws Exception { + public static void configureSender(OieDicomSender sender, DICOMDispatcher connector, DICOMDispatcherProperties connectorProperties, String[] protocols) throws Exception { if (connectorProperties.getTls() != null && !connectorProperties.getTls().equals("notls")) { - if (connectorProperties.getTls().equals("without")) - dcmsnd.setTlsWithoutEncyrption(); - if (connectorProperties.getTls().equals("3des")) - dcmsnd.setTls3DES_EDE_CBC(); + if (connectorProperties.getTls().equals("without")) { + logger.warn("DICOM sender configured with TLS NULL encryption (SSL_RSA_WITH_NULL_SHA). " + + "This provides authentication only — data is sent in cleartext. " + + "Consider using AES encryption instead."); + sender.setTlsWithoutEncryption(); + } + if (connectorProperties.getTls().equals("3des")) { + logger.warn("DICOM sender configured with deprecated 3DES cipher suite. " + + "Consider using AES encryption instead."); + sender.setTls3DES_EDE_CBC(); + } if (connectorProperties.getTls().equals("aes")) - dcmsnd.setTlsAES_128_CBC(); - if (connectorProperties.getTrustStore() != null && !connectorProperties.getTrustStore().equals("")) - dcmsnd.setTrustStoreURL(connectorProperties.getTrustStore()); - if (connectorProperties.getTrustStorePW() != null && !connectorProperties.getTrustStorePW().equals("")) - dcmsnd.setTrustStorePassword(connectorProperties.getTrustStorePW()); - if (connectorProperties.getKeyPW() != null && !connectorProperties.getKeyPW().equals("")) - dcmsnd.setKeyPassword(connectorProperties.getKeyPW()); - if (connectorProperties.getKeyStore() != null && !connectorProperties.getKeyStore().equals("")) - dcmsnd.setKeyStoreURL(connectorProperties.getKeyStore()); - if (connectorProperties.getKeyStorePW() != null && !connectorProperties.getKeyStorePW().equals("")) - dcmsnd.setKeyStorePassword(connectorProperties.getKeyStorePW()); - dcmsnd.setTlsNeedClientAuth(connectorProperties.isNoClientAuth()); + sender.setTlsAES_128_CBC(); + if (StringUtils.isNotBlank(connectorProperties.getTrustStore())) + sender.setTrustStoreURL(connectorProperties.getTrustStore()); + if (StringUtils.isNotBlank(connectorProperties.getTrustStorePW())) + sender.setTrustStorePassword(connectorProperties.getTrustStorePW()); + if (StringUtils.isNotBlank(connectorProperties.getKeyPW())) + sender.setKeyPassword(connectorProperties.getKeyPW()); + if (StringUtils.isNotBlank(connectorProperties.getKeyStore())) + sender.setKeyStoreURL(connectorProperties.getKeyStore()); + if (StringUtils.isNotBlank(connectorProperties.getKeyStorePW())) + sender.setKeyStorePassword(connectorProperties.getKeyStorePW()); + sender.setTlsNeedClientAuth(connectorProperties.isNoClientAuth()); protocols = ArrayUtils.clone(protocols); @@ -115,9 +131,9 @@ public static void configureDcmSnd(MirthDcmSnd dcmsnd, DICOMDispatcher connector protocols = MirthSSLUtil.getEnabledHttpsProtocols(protocols); - dcmsnd.setTlsProtocol(protocols); + sender.setTlsProtocol(protocols); - dcmsnd.initTLS(); + sender.initTLS(); } } -} \ No newline at end of file +} diff --git a/server/src/com/mirth/connect/connectors/dimse/DICOMDispatcher.java b/server/src/com/mirth/connect/connectors/dimse/DICOMDispatcher.java index 656a3e8a49..1fffc973d3 100644 --- a/server/src/com/mirth/connect/connectors/dimse/DICOMDispatcher.java +++ b/server/src/com/mirth/connect/connectors/dimse/DICOMDispatcher.java @@ -1,8 +1,8 @@ /* * Copyright (c) Mirth Corporation. All rights reserved. - * + * * http://www.mirthcorp.com - * + * * The software in this package is published under the terms of the MPL license a copy of which has * been included with this distribution in the LICENSE.txt file. */ @@ -16,16 +16,13 @@ import org.apache.commons.lang3.math.NumberUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.dcm4che2.data.BasicDicomObject; -import org.dcm4che2.data.DicomElement; -import org.dcm4che2.data.DicomObject; -import org.dcm4che2.data.Tag; -import org.dcm4che2.net.Association; -import org.dcm4che2.net.UserIdentity; -import org.dcm4che2.tool.dcmsnd.CustomDimseRSPHandler; -import org.dcm4che2.tool.dcmsnd.MirthDcmSnd; -import org.dcm4che2.util.StringUtils; +import com.mirth.connect.connectors.dimse.dicom.DicomConstants; +import com.mirth.connect.connectors.dimse.dicom.DicomLibraryFactory; +import com.mirth.connect.connectors.dimse.dicom.OieDicomElement; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; +import com.mirth.connect.connectors.dimse.dicom.OieDicomSender; +import com.mirth.connect.connectors.dimse.dicom.OieDimseRspHandler; import com.mirth.connect.donkey.model.channel.ConnectorProperties; import com.mirth.connect.donkey.model.event.ConnectionStatusEventType; import com.mirth.connect.donkey.model.event.ErrorEventType; @@ -59,12 +56,7 @@ public void onDeploy() throws ConnectorTaskException { // load the default configuration String configurationClass = configurationController.getProperty(connectorProperties.getProtocol(), "dicomConfigurationClass"); - try { - configuration = (DICOMConfiguration) Class.forName(configurationClass).newInstance(); - } catch (Throwable t) { - logger.trace("could not find custom configuration class, using default"); - configuration = new DefaultDICOMConfiguration(); - } + configuration = DicomLibraryFactory.loadConfiguration(configurationClass); try { configuration.configureConnectorDeploy(this); @@ -125,7 +117,7 @@ public Response send(ConnectorProperties connectorProperties, ConnectorMessage c Status responseStatus = Status.QUEUED; File tempFile = null; - MirthDcmSnd dcmSnd = getDcmSnd(configuration); + OieDicomSender dcmSnd = createDicomSender(configuration); try { tempFile = File.createTempFile("temp", "tmp"); @@ -175,15 +167,8 @@ else if (dicomDispatcherProperties.getPriority().equals("high")) dcmSnd.setPriority(2); if (dicomDispatcherProperties.getUsername() != null && !dicomDispatcherProperties.getUsername().equals("")) { String username = dicomDispatcherProperties.getUsername(); - UserIdentity userId; - if (dicomDispatcherProperties.getPasscode() != null && !dicomDispatcherProperties.getPasscode().equals("")) { - String passcode = dicomDispatcherProperties.getPasscode(); - userId = new UserIdentity.UsernamePasscode(username, passcode.toCharArray()); - } else { - userId = new UserIdentity.Username(username); - } - userId.setPositiveResponseRequested(dicomDispatcherProperties.isUidnegrsp()); - dcmSnd.setUserIdentity(userId); + String passcode = dicomDispatcherProperties.getPasscode(); + dcmSnd.setUserIdentity(username, passcode, dicomDispatcherProperties.isUidnegrsp()); } dcmSnd.setPackPDV(dicomDispatcherProperties.isPdv1()); @@ -226,7 +211,7 @@ else if (dicomDispatcherProperties.getPriority().equals("high")) dcmSnd.setStorageCommitment(dicomDispatcherProperties.isStgcmt()); dcmSnd.setTcpNoDelay(!dicomDispatcherProperties.isTcpDelay()); - configuration.configureDcmSnd(dcmSnd, this, dicomDispatcherProperties); + configuration.configureSender(dcmSnd, this, dicomDispatcherProperties); dcmSnd.setOfferDefaultTransferSyntaxInSeparatePresentationContext(dicomDispatcherProperties.isTs1()); dcmSnd.configureTransferCapability(); @@ -240,15 +225,22 @@ else if (dicomDispatcherProperties.getPriority().equals("high")) String storageCommitmentFailureReason = "Unknown"; if (dcmSnd.isStorageCommitment()) { if (dcmSnd.commit()) { - DicomObject cmtrslt = dcmSnd.waitForStgCmtResult(); - DicomElement failedSOPSq = cmtrslt.get(Tag.FailedSOPSequence); - if (failedSOPSq != null && failedSOPSq.countItems() > 0) { - storageCommitmentFailed = true; - DicomObject failedSOPItem = failedSOPSq.getDicomObject(); - int failureReason = failedSOPItem.getInt(Tag.FailureReason); - if (failureReason != 0) { - storageCommitmentFailureReason = String.valueOf(failureReason); + OieDicomObject cmtrslt = dcmSnd.waitForStgCmtResult(); + if (cmtrslt != null) { + OieDicomElement failedSOPSq = cmtrslt.get(DicomConstants.TAG_FAILED_SOP_SEQUENCE); + if (failedSOPSq != null && failedSOPSq.countItems() > 0) { + storageCommitmentFailed = true; + OieDicomObject failedSOPItem = failedSOPSq.getDicomObject(); + if (failedSOPItem != null) { + int failureReason = failedSOPItem.getInt(DicomConstants.TAG_FAILURE_REASON); + if (failureReason != 0) { + storageCommitmentFailureReason = String.valueOf(failureReason); + } + } } + } else { + logger.warn("Storage commitment result was null — remote SCP may not have responded"); + storageCommitmentFailed = true; } } else { storageCommitmentFailed = true; @@ -259,16 +251,16 @@ else if (dicomDispatcherProperties.getPriority().equals("high")) int status = rspHandler.getStatus(); - if (status == 0) { + if (status == DicomConstants.STATUS_SUCCESS) { responseStatusMessage = "DICOM message successfully sent"; responseStatus = Status.SENT; - } else if (status == 0xB000 || status == 0xB006 || status == 0xB007) { + } else if (status == DicomConstants.STATUS_WARNING_COERCION || status == DicomConstants.STATUS_WARNING_ELEMENTS_DISCARDED || status == DicomConstants.STATUS_WARNING_DATA_SET_MISMATCH) { // These status codes are used in DcmSnd.onDimseRSP to flag warnings - responseStatusMessage = "DICOM message successfully sent with warning status code: 0x" + StringUtils.shortToHex(status); + responseStatusMessage = "DICOM message successfully sent with warning status code: 0x" + DicomConstants.shortToHex(status); responseStatus = Status.SENT; } else { // Any other status is considered unsuccessful - responseStatusMessage = "Error status code received from DICOM server: 0x" + StringUtils.shortToHex(status); + responseStatusMessage = "Error status code received from DICOM server: 0x" + DicomConstants.shortToHex(status); responseStatus = Status.QUEUED; } @@ -283,6 +275,11 @@ else if (dicomDispatcherProperties.getPriority().equals("high")) responseError = ErrorMessageBuilder.buildErrorMessage(connectorProperties.getName(), e.getMessage(), null); eventController.dispatchEvent(new ErrorEvent(getChannelId(), getMetaDataId(), connectorMessage.getMessageId(), ErrorEventType.DESTINATION_CONNECTOR, getDestinationName(), connectorProperties.getName(), e.getMessage(), null)); } finally { + try { + dcmSnd.close(); + } catch (Exception e) { + logger.debug("Error closing DICOM sender association", e); + } dcmSnd.stop(); if (tempFile != null) { @@ -295,37 +292,37 @@ else if (dicomDispatcherProperties.getPriority().equals("high")) return new Response(responseStatus, responseData, responseStatusMessage, responseError); } - protected MirthDcmSnd getDcmSnd(DICOMConfiguration configuration) { - return new MirthDcmSnd(configuration); + protected OieDicomSender createDicomSender(DICOMConfiguration configuration) { + return DicomLibraryFactory.createSender(configuration); } - protected class CommandDataDimseRSPHandler extends CustomDimseRSPHandler { + protected class CommandDataDimseRSPHandler implements OieDimseRspHandler { - private DicomObject cmd; + private OieDicomObject cmd; @Override - public void onDimseRSP(Association as, DicomObject cmd, DicomObject data) { + public void onDimseRSP(OieDicomObject cmd, OieDicomObject data) { this.cmd = cmd; } public int getStatus() { if (cmd != null) { - return cmd.getInt(Tag.Status); + return cmd.getInt(DicomConstants.TAG_STATUS); } else { return 0; } } public String getCommandData() { - if (cmd instanceof BasicDicomObject) { + if (cmd != null) { try { DonkeyElement dicom = new DonkeyElement(""); - for (Iterator it = ((BasicDicomObject) cmd).commandIterator(); it.hasNext();) { - DicomElement element = it.next(); - String tag = StringUtils.shortToHex(element.tag() >> 16) + StringUtils.shortToHex(element.tag()); + for (Iterator it = cmd.commandIterator(); it.hasNext();) { + OieDicomElement element = it.next(); + String tag = DicomConstants.shortToHex(element.tag() >> 16) + DicomConstants.shortToHex(element.tag()); - DonkeyElement child = dicom.addChildElement("tag" + tag, element.getValueAsString(null, 0)); + DonkeyElement child = dicom.addChildElement("tag" + tag, element.getValueAsString(0)); child.setAttribute("len", String.valueOf(element.length())); child.setAttribute("tag", tag); child.setAttribute("vr", String.valueOf(element.vr())); diff --git a/server/src/com/mirth/connect/connectors/dimse/DICOMReceiver.java b/server/src/com/mirth/connect/connectors/dimse/DICOMReceiver.java index 7a1a9d462b..60e13bc8f8 100644 --- a/server/src/com/mirth/connect/connectors/dimse/DICOMReceiver.java +++ b/server/src/com/mirth/connect/connectors/dimse/DICOMReceiver.java @@ -1,8 +1,8 @@ /* * Copyright (c) Mirth Corporation. All rights reserved. - * + * * http://www.mirthcorp.com - * + * * The software in this package is published under the terms of the MPL license a copy of which has * been included with this distribution in the LICENSE.txt file. */ @@ -13,9 +13,10 @@ import org.apache.commons.lang3.math.NumberUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.dcm4che2.data.UID; -import org.dcm4che2.tool.dcmrcv.MirthDcmRcv; +import com.mirth.connect.connectors.dimse.dicom.DicomConstants; +import com.mirth.connect.connectors.dimse.dicom.DicomLibraryFactory; +import com.mirth.connect.connectors.dimse.dicom.OieDicomReceiver; import com.mirth.connect.donkey.model.event.ConnectionStatusEventType; import com.mirth.connect.donkey.server.ConnectorTaskException; import com.mirth.connect.donkey.server.channel.DispatchResult; @@ -28,12 +29,12 @@ public class DICOMReceiver extends SourceConnector { private Logger logger = LogManager.getLogger(this.getClass()); - private DICOMReceiverProperties connectorProperties; - private EventController eventController = ControllerFactory.getFactory().createEventController(); + protected DICOMReceiverProperties connectorProperties; + protected EventController eventController = ControllerFactory.getFactory().createEventController(); private ConfigurationController configurationController = ControllerFactory.getFactory().createConfigurationController(); private TemplateValueReplacer replacer = new TemplateValueReplacer(); - private DICOMConfiguration configuration = null; - private MirthDcmRcv dcmrcv; + protected DICOMConfiguration configuration = null; + protected OieDicomReceiver dicomReceiver; @Override public void onDeploy() throws ConnectorTaskException { @@ -42,12 +43,7 @@ public void onDeploy() throws ConnectorTaskException { // load the default configuration String configurationClass = configurationController.getProperty(connectorProperties.getProtocol(), "dicomConfigurationClass"); - try { - configuration = (DICOMConfiguration) Class.forName(configurationClass).newInstance(); - } catch (Throwable t) { - logger.trace("could not find custom configuration class, using default"); - configuration = new DefaultDICOMConfiguration(); - } + configuration = DicomLibraryFactory.loadConfiguration(configurationClass); try { configuration.configureConnectorDeploy(this); @@ -55,7 +51,7 @@ public void onDeploy() throws ConnectorTaskException { throw new ConnectorTaskException(e); } - dcmrcv = new MirthDcmRcv(this, configuration); + dicomReceiver = createDicomReceiver(configuration); } @Override @@ -64,105 +60,123 @@ public void onUndeploy() throws ConnectorTaskException {} @Override public void onStart() throws ConnectorTaskException { try { - dcmrcv.setPort(NumberUtils.toInt(replacer.replaceValues(connectorProperties.getListenerConnectorProperties().getPort(), getChannelId(), getChannel().getName()))); - dcmrcv.setHostname(replacer.replaceValues(connectorProperties.getListenerConnectorProperties().getHost(), getChannelId(), getChannel().getName())); - - String[] only_def_ts = { UID.ImplicitVRLittleEndian }; - String[] native_le_ts = { UID.ImplicitVRLittleEndian }; - String[] native_ts = { UID.ImplicitVRLittleEndian }; - String[] non_retired_ts = { UID.ImplicitVRLittleEndian }; + dicomReceiver.setPort(NumberUtils.toInt(replacer.replaceValues(connectorProperties.getListenerConnectorProperties().getPort(), getChannelId(), getChannel().getName()))); + dicomReceiver.setHostname(replacer.replaceValues(connectorProperties.getListenerConnectorProperties().getHost(), getChannelId(), getChannel().getName())); + + String[] only_def_ts = { DicomConstants.IMPLICIT_VR_LITTLE_ENDIAN }; + String[] native_le_ts = { DicomConstants.EXPLICIT_VR_LITTLE_ENDIAN, + DicomConstants.IMPLICIT_VR_LITTLE_ENDIAN }; + String[] native_ts = { DicomConstants.EXPLICIT_VR_LITTLE_ENDIAN, + DicomConstants.EXPLICIT_VR_BIG_ENDIAN, + DicomConstants.IMPLICIT_VR_LITTLE_ENDIAN }; + String[] non_retired_ts = { + DicomConstants.JPEG_LS_LOSSLESS, + DicomConstants.JPEG_LOSSLESS_SV1, + DicomConstants.JPEG_LOSSLESS_NH14, + DicomConstants.JPEG_2000_LOSSLESS, + DicomConstants.DEFLATED_EXPLICIT_VR_LITTLE_ENDIAN, + DicomConstants.RLE_LOSSLESS, + DicomConstants.EXPLICIT_VR_LITTLE_ENDIAN, + DicomConstants.EXPLICIT_VR_BIG_ENDIAN, + DicomConstants.IMPLICIT_VR_LITTLE_ENDIAN, + DicomConstants.JPEG_BASELINE, + DicomConstants.JPEG_EXTENDED, + DicomConstants.JPEG_LS_NEAR_LOSSLESS, + DicomConstants.JPEG_2000, + DicomConstants.MPEG2, + }; String destination = replacer.replaceValues(connectorProperties.getDest(), getChannelId(), getChannel().getName()); if (StringUtils.isNotBlank(destination)) { - dcmrcv.setDestination(destination); + dicomReceiver.setDestination(destination); } if (connectorProperties.isDefts()) { - dcmrcv.setTransferSyntax(only_def_ts); + dicomReceiver.setTransferSyntax(only_def_ts); } else if (connectorProperties.isNativeData()) { if (connectorProperties.isBigEndian()) { - dcmrcv.setTransferSyntax(native_ts); + dicomReceiver.setTransferSyntax(native_ts); } else { - dcmrcv.setTransferSyntax(native_le_ts); + dicomReceiver.setTransferSyntax(native_le_ts); } } else if (connectorProperties.isBigEndian()) { - dcmrcv.setTransferSyntax(non_retired_ts); + dicomReceiver.setTransferSyntax(non_retired_ts); } String aeTitle = replacer.replaceValues(connectorProperties.getApplicationEntity(), getChannelId(), getChannel().getName()); aeTitle = StringUtils.defaultIfBlank(aeTitle, null); - dcmrcv.setAEtitle(aeTitle); + dicomReceiver.setAEtitle(aeTitle); //TODO Allow variables int value = NumberUtils.toInt(connectorProperties.getReaper()); if (value != 10) { - dcmrcv.setAssociationReaperPeriod(value); + dicomReceiver.setAssociationReaperPeriod(value); } value = NumberUtils.toInt(connectorProperties.getIdleTo()); if (value != 60) { - dcmrcv.setIdleTimeout(value); + dicomReceiver.setIdleTimeout(value); } value = NumberUtils.toInt(connectorProperties.getRequestTo()); if (value != 5) { - dcmrcv.setRequestTimeout(value); + dicomReceiver.setRequestTimeout(value); } value = NumberUtils.toInt(connectorProperties.getReleaseTo()); if (value != 5) { - dcmrcv.setReleaseTimeout(value); + dicomReceiver.setReleaseTimeout(value); } value = NumberUtils.toInt(connectorProperties.getSoCloseDelay()); if (value != 50) { - dcmrcv.setSocketCloseDelay(value); + dicomReceiver.setSocketCloseDelay(value); } value = NumberUtils.toInt(connectorProperties.getRspDelay()); if (value > 0) { - dcmrcv.setDimseRspDelay(value); + dicomReceiver.setDimseRspDelay(value); } value = NumberUtils.toInt(connectorProperties.getRcvpdulen()); if (value != 16) { - dcmrcv.setMaxPDULengthReceive(value); + dicomReceiver.setMaxPDULengthReceive(value); } value = NumberUtils.toInt(connectorProperties.getSndpdulen()); if (value != 16) { - dcmrcv.setMaxPDULengthSend(value); + dicomReceiver.setMaxPDULengthSend(value); } value = NumberUtils.toInt(connectorProperties.getSosndbuf()); if (value > 0) { - dcmrcv.setSendBufferSize(value); + dicomReceiver.setSendBufferSize(value); } value = NumberUtils.toInt(connectorProperties.getSorcvbuf()); if (value > 0) { - dcmrcv.setReceiveBufferSize(value); + dicomReceiver.setReceiveBufferSize(value); } value = NumberUtils.toInt(connectorProperties.getBufSize()); if (value != 1) { - dcmrcv.setFileBufferSize(value); + dicomReceiver.setFileBufferSize(value); } - dcmrcv.setPackPDV(connectorProperties.isPdv1()); - dcmrcv.setTcpNoDelay(!connectorProperties.isTcpDelay()); + dicomReceiver.setPackPDV(connectorProperties.isPdv1()); + dicomReceiver.setTcpNoDelay(!connectorProperties.isTcpDelay()); value = NumberUtils.toInt(connectorProperties.getAsync()); if (value > 0) { - dcmrcv.setMaxOpsPerformed(value); + dicomReceiver.setMaxOpsPerformed(value); } - dcmrcv.initTransferCapability(); + dicomReceiver.initTransferCapability(); - configuration.configureDcmRcv(dcmrcv, this, connectorProperties); + configuration.configureReceiver(dicomReceiver, this, connectorProperties); // start the DICOM port - dcmrcv.start(); + dicomReceiver.start(); eventController.dispatchEvent(new ConnectionStatusEvent(getChannelId(), getMetaDataId(), getSourceName(), ConnectionStatusEventType.IDLE)); } catch (Exception e) { @@ -173,7 +187,7 @@ public void onStart() throws ConnectorTaskException { @Override public void onStop() throws ConnectorTaskException { try { - dcmrcv.stop(); + dicomReceiver.stop(); } catch (Exception e) { logger.error("Unable to close DICOM port.", e); } finally { @@ -196,4 +210,8 @@ public void handleRecoveredResponse(DispatchResult dispatchResult) { public TemplateValueReplacer getReplacer() { return replacer; } -} \ No newline at end of file + + protected OieDicomReceiver createDicomReceiver(DICOMConfiguration configuration) { + return DicomLibraryFactory.createReceiver(this, configuration); + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/DefaultDICOMConfiguration.java b/server/src/com/mirth/connect/connectors/dimse/DefaultDICOMConfiguration.java index cd2bdf370b..521a06fbaf 100644 --- a/server/src/com/mirth/connect/connectors/dimse/DefaultDICOMConfiguration.java +++ b/server/src/com/mirth/connect/connectors/dimse/DefaultDICOMConfiguration.java @@ -1,8 +1,8 @@ /* * Copyright (c) Mirth Corporation. All rights reserved. - * + * * http://www.mirthcorp.com - * + * * The software in this package is published under the terms of the MPL license a copy of which has * been included with this distribution in the LICENSE.txt file. */ @@ -17,12 +17,15 @@ import org.dcm4che2.tool.dcmrcv.MirthDcmRcv; import org.dcm4che2.tool.dcmsnd.MirthDcmSnd; +import com.mirth.connect.connectors.dimse.dicom.dcm2.Dcm2DICOMConfiguration; +import com.mirth.connect.connectors.dimse.dicom.dcm2.Dcm2DicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.dcm2.Dcm2DicomSender; import com.mirth.connect.donkey.server.channel.Connector; import com.mirth.connect.server.controllers.ConfigurationController; import com.mirth.connect.server.controllers.ControllerFactory; import com.mirth.connect.util.MirthSSLUtil; -public class DefaultDICOMConfiguration implements DICOMConfiguration { +public class DefaultDICOMConfiguration implements Dcm2DICOMConfiguration { private ConfigurationController configurationController = ControllerFactory.getFactory().createConfigurationController(); private String[] protocols; @@ -37,22 +40,24 @@ public void configureConnectorDeploy(Connector connector) throws Exception { } @Override - public NetworkConnection createNetworkConnection() { - return new NetworkConnection(); + public void configureDcmRcv(MirthDcmRcv dcmrcv, DICOMReceiver connector, + DICOMReceiverProperties connectorProperties) throws Exception { + DICOMConfigurationUtil.configureReceiver(new Dcm2DicomReceiver(dcmrcv), connector, connectorProperties, protocols); } @Override - public void configureDcmRcv(MirthDcmRcv dcmrcv, DICOMReceiver connector, DICOMReceiverProperties connectorProperties) throws Exception { - DICOMConfigurationUtil.configureDcmRcv(dcmrcv, connector, connectorProperties, protocols); + public void configureDcmSnd(MirthDcmSnd dcmsnd, DICOMDispatcher connector, + DICOMDispatcherProperties connectorProperties) throws Exception { + DICOMConfigurationUtil.configureSender(new Dcm2DicomSender(dcmsnd), connector, connectorProperties, protocols); } @Override - public void configureDcmSnd(MirthDcmSnd dcmsnd, DICOMDispatcher connector, DICOMDispatcherProperties connectorProperties) throws Exception { - DICOMConfigurationUtil.configureDcmSnd(dcmsnd, connector, connectorProperties, protocols); + public Map getCStoreRequestInformation(Association association) { + return new HashMap(); } @Override - public Map getCStoreRequestInformation(Association as) { - return new HashMap(); + public NetworkConnection createLegacyNetworkConnection() { + return new NetworkConnection(); } -} \ No newline at end of file +} diff --git a/server/src/com/mirth/connect/connectors/dimse/DefaultDcm5DICOMConfiguration.java b/server/src/com/mirth/connect/connectors/dimse/DefaultDcm5DICOMConfiguration.java new file mode 100644 index 0000000000..4a088e20e1 --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/DefaultDcm5DICOMConfiguration.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse; + +import java.util.HashMap; +import java.util.Map; + +import org.dcm4che3.net.Association; +import org.dcm4che3.net.Connection; + +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DICOMConfiguration; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomSender; +import com.mirth.connect.donkey.server.channel.Connector; +import com.mirth.connect.server.controllers.ConfigurationController; +import com.mirth.connect.server.controllers.ControllerFactory; +import com.mirth.connect.util.MirthSSLUtil; + +public class DefaultDcm5DICOMConfiguration implements Dcm5DICOMConfiguration { + + private ConfigurationController configurationController = ControllerFactory.getFactory().createConfigurationController(); + private String[] protocols; + + @Override + public void configureConnectorDeploy(Connector connector) throws Exception { + if (connector instanceof DICOMReceiver) { + protocols = MirthSSLUtil.getEnabledHttpsProtocols(configurationController.getHttpsServerProtocols()); + } else { + protocols = MirthSSLUtil.getEnabledHttpsProtocols(configurationController.getHttpsClientProtocols()); + } + } + + @Override + public void configureDcm5Receiver(Dcm5DicomReceiver receiver, DICOMReceiver connector, + DICOMReceiverProperties connectorProperties) throws Exception { + DICOMConfigurationUtil.configureReceiver(receiver, connector, connectorProperties, protocols); + } + + @Override + public void configureDcm5Sender(Dcm5DicomSender sender, DICOMDispatcher connector, + DICOMDispatcherProperties connectorProperties) throws Exception { + DICOMConfigurationUtil.configureSender(sender, connector, connectorProperties, protocols); + } + + @Override + public Map getCStoreRequestInformation(Association association) { + return new HashMap(); + } + + @Override + public Connection createDcm5Connection() { + return new Connection(); + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/destination.xml b/server/src/com/mirth/connect/connectors/dimse/destination.xml index 0c960d726f..9b53c66af5 100644 --- a/server/src/com/mirth/connect/connectors/dimse/destination.xml +++ b/server/src/com/mirth/connect/connectors/dimse/destination.xml @@ -10,13 +10,15 @@ com.mirth.connect.connectors.dimse.DICOMDispatcherProperties - - - - - + + + + + + + dicom DESTINATION diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/DicomConstants.java b/server/src/com/mirth/connect/connectors/dimse/dicom/DicomConstants.java new file mode 100644 index 0000000000..9e373ec59f --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/DicomConstants.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom; + +/** + * Version-neutral DICOM constants. These values are defined by the DICOM standard + * and are identical across all dcm4che library versions. + */ +public final class DicomConstants { + + private DicomConstants() {} + + // DICOM Tags (from the DICOM standard, version-independent) + public static final int TAG_PIXEL_DATA = 0x7FE00010; + public static final int TAG_STATUS = 0x00000900; + public static final int TAG_FAILED_SOP_SEQUENCE = 0x00081198; + public static final int TAG_FAILURE_REASON = 0x00081197; + public static final int TAG_AFFECTED_SOP_CLASS_UID = 0x00000002; + public static final int TAG_AFFECTED_SOP_INSTANCE_UID = 0x00001000; + public static final int TAG_REQUESTED_SOP_CLASS_UID = 0x00000003; + public static final int TAG_REQUESTED_SOP_INSTANCE_UID = 0x00001001; + + // DICOM Value Representations + public static final String VR_OB = "OB"; + public static final String VR_UI = "UI"; + public static final String VR_IS = "IS"; + + // Transfer Syntax UIDs + public static final String IMPLICIT_VR_LITTLE_ENDIAN = "1.2.840.10008.1.2"; + public static final String EXPLICIT_VR_LITTLE_ENDIAN = "1.2.840.10008.1.2.1"; + public static final String EXPLICIT_VR_BIG_ENDIAN = "1.2.840.10008.1.2.2"; + public static final String DEFLATED_EXPLICIT_VR_LITTLE_ENDIAN = "1.2.840.10008.1.2.1.99"; + public static final String JPEG_BASELINE = "1.2.840.10008.1.2.4.50"; + public static final String JPEG_EXTENDED = "1.2.840.10008.1.2.4.51"; + public static final String JPEG_LOSSLESS_NH14 = "1.2.840.10008.1.2.4.57"; + public static final String JPEG_LOSSLESS_SV1 = "1.2.840.10008.1.2.4.70"; + public static final String JPEG_LS_LOSSLESS = "1.2.840.10008.1.2.4.80"; + public static final String JPEG_LS_NEAR_LOSSLESS = "1.2.840.10008.1.2.4.81"; + public static final String JPEG_2000_LOSSLESS = "1.2.840.10008.1.2.4.90"; + public static final String JPEG_2000 = "1.2.840.10008.1.2.4.91"; + public static final String MPEG2 = "1.2.840.10008.1.2.4.100"; + public static final String RLE_LOSSLESS = "1.2.840.10008.1.2.5"; + + // DICOM Status Codes + public static final int STATUS_SUCCESS = 0x0000; + public static final int STATUS_WARNING_COERCION = 0xB000; + public static final int STATUS_WARNING_ELEMENTS_DISCARDED = 0xB006; + public static final int STATUS_WARNING_DATA_SET_MISMATCH = 0xB007; + public static final int STATUS_PROCESSING_FAILURE = 0x0110; + + /** + * Formats a 16-bit status code as a 4-character hex string (e.g., 0xB000 → "B000"). + * Equivalent to dcm4che2's StringUtils.shortToHex(). + */ + public static String shortToHex(int val) { + return String.format("%04X", val & 0xFFFF); + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/DicomLibraryFactory.java b/server/src/com/mirth/connect/connectors/dimse/dicom/DicomLibraryFactory.java new file mode 100644 index 0000000000..e6f43f8906 --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/DicomLibraryFactory.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom; + +import java.io.File; +import java.io.FileInputStream; +import java.lang.reflect.Constructor; +import java.util.Properties; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.mirth.connect.connectors.dimse.DICOMConfiguration; +import com.mirth.connect.donkey.server.channel.SourceConnector; + +/** + * Factory for creating version-neutral DICOM library instances. Reads the + * {@code dicom.library} property from mirth.properties to determine which + * backend to use. Defaults to {@code dcm4che2}. + * + *

All backend classes are loaded via {@code Class.forName} to avoid + * compile-time dependencies on variant-specific code. + */ +public final class DicomLibraryFactory { + + private static final Logger logger = LogManager.getLogger(DicomLibraryFactory.class); + + private static final String MIRTH_PROPERTIES_FILE = "./conf/mirth.properties"; + private static final String PROPERTY_DICOM_LIBRARY = "dicom.library"; + + // dcm4che2 backend class names + private static final String DCM2_CONVERTER = "com.mirth.connect.connectors.dimse.dicom.dcm2.Dcm2DicomConverter"; + private static final String DCM2_SENDER = "com.mirth.connect.connectors.dimse.dicom.dcm2.Dcm2DicomSender"; + private static final String DCM2_RECEIVER = "com.mirth.connect.connectors.dimse.dicom.dcm2.Dcm2DicomReceiver"; + private static final String DCM2_DEFAULT_CONFIG = "com.mirth.connect.connectors.dimse.DefaultDICOMConfiguration"; + + // dcm4che5 backend class names + private static final String DCM5_CONVERTER = "com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomConverter"; + private static final String DCM5_SENDER = "com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomSender"; + private static final String DCM5_RECEIVER = "com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomReceiver"; + private static final String DCM5_DEFAULT_CONFIG = "com.mirth.connect.connectors.dimse.DefaultDcm5DICOMConfiguration"; + + public enum DicomLibrary { + DCM4CHE2, DCM4CHE5 + } + + private static volatile DicomLibrary activeLibrary; + private static volatile OieDicomConverter converterInstance; + + private DicomLibraryFactory() {} + + /** + * Returns the active DICOM library backend, reading from mirth.properties on first access. + */ + public static DicomLibrary getActiveLibrary() { + if (activeLibrary == null) { + synchronized (DicomLibraryFactory.class) { + if (activeLibrary == null) { + activeLibrary = detectLibrary(); + } + } + } + return activeLibrary; + } + + /** + * Returns a singleton converter instance for the configured DICOM library version. + */ + public static OieDicomConverter getConverter() { + if (converterInstance == null) { + synchronized (DicomLibraryFactory.class) { + if (converterInstance == null) { + converterInstance = createConverterInstance(); + } + } + } + return converterInstance; + } + + /** + * Creates a new DICOM sender for the configured library version. + */ + public static OieDicomSender createSender(DICOMConfiguration configuration) { + try { + String className = getSenderClassName(); + Class clazz = Class.forName(className); + Constructor ctor = clazz.getConstructor(DICOMConfiguration.class); + return (OieDicomSender) ctor.newInstance(configuration); + } catch (Exception e) { + throw new RuntimeException("Failed to create DICOM sender for library: " + getActiveLibrary(), e); + } + } + + /** + * Creates a new DICOM receiver for the configured library version. + */ + public static OieDicomReceiver createReceiver(SourceConnector connector, DICOMConfiguration configuration) { + try { + String className = getReceiverClassName(); + Class clazz = Class.forName(className); + Constructor ctor = clazz.getConstructor(SourceConnector.class, DICOMConfiguration.class); + return (OieDicomReceiver) ctor.newInstance(connector, configuration); + } catch (Exception e) { + throw new RuntimeException("Failed to create DICOM receiver for library: " + getActiveLibrary(), e); + } + } + + /** + * Creates the default DICOMConfiguration for the configured library version. + */ + public static DICOMConfiguration createDefaultConfiguration() { + try { + String className = getDefaultConfigClassName(); + return (DICOMConfiguration) Class.forName(className).getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new RuntimeException("Failed to create default DICOMConfiguration for library: " + getActiveLibrary(), e); + } + } + + /** + * Loads a DICOMConfiguration by class name. Falls back to the default configuration + * if the class cannot be loaded or does not implement DICOMConfiguration. + * + * @param className the fully qualified class name, or null/empty for the default + * @return a DICOMConfiguration instance + */ + public static DICOMConfiguration loadConfiguration(String className) { + if (className == null || className.trim().isEmpty()) { + return createDefaultConfiguration(); + } + try { + Object instance = Class.forName(className.trim()).getDeclaredConstructor().newInstance(); + if (instance instanceof DICOMConfiguration) { + return (DICOMConfiguration) instance; + } + logger.warn("Custom DICOMConfiguration class does not implement current interface: " + + className + ". Using default. If this is a legacy class, recompile against Dcm2DICOMConfiguration."); + return createDefaultConfiguration(); + } catch (Exception e) { + logger.warn("Could not load custom DICOMConfiguration class, using default: " + className, e); + return createDefaultConfiguration(); + } + } + + private static OieDicomConverter createConverterInstance() { + try { + String className = getConverterClassName(); + return (OieDicomConverter) Class.forName(className).getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new RuntimeException("Failed to create DICOM converter for library: " + getActiveLibrary(), e); + } + } + + private static String getConverterClassName() { + return getActiveLibrary() == DicomLibrary.DCM4CHE5 ? DCM5_CONVERTER : DCM2_CONVERTER; + } + + private static String getSenderClassName() { + return getActiveLibrary() == DicomLibrary.DCM4CHE5 ? DCM5_SENDER : DCM2_SENDER; + } + + private static String getReceiverClassName() { + return getActiveLibrary() == DicomLibrary.DCM4CHE5 ? DCM5_RECEIVER : DCM2_RECEIVER; + } + + private static String getDefaultConfigClassName() { + return getActiveLibrary() == DicomLibrary.DCM4CHE5 ? DCM5_DEFAULT_CONFIG : DCM2_DEFAULT_CONFIG; + } + + private static DicomLibrary detectLibrary() { + DicomLibrary library = DicomLibrary.DCM4CHE2; + try (FileInputStream is = new FileInputStream(new File(MIRTH_PROPERTIES_FILE))) { + Properties props = new Properties(); + props.load(is); + String value = props.getProperty(PROPERTY_DICOM_LIBRARY, "dcm4che2").trim(); + if ("dcm4che5".equalsIgnoreCase(value)) { + library = DicomLibrary.DCM4CHE5; + } else if (!value.isEmpty() && !"dcm4che2".equalsIgnoreCase(value)) { + logger.warn("Unrecognized value for {}: '{}'. Supported values are 'dcm4che2' and 'dcm4che5'. Defaulting to dcm4che2.", + PROPERTY_DICOM_LIBRARY, value); + } + } catch (Exception e) { + // Default to dcm4che2 if properties cannot be read (e.g., in tests) + } + logger.info("DICOM library backend: {}", library); + return library; + } + + /** + * Resets factory state for testing. Not for production use. + */ + public static void resetForTesting(DicomLibrary override) { + synchronized (DicomLibraryFactory.class) { + activeLibrary = override; + converterInstance = null; + } + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/OieDicomConverter.java b/server/src/com/mirth/connect/connectors/dimse/dicom/OieDicomConverter.java new file mode 100644 index 0000000000..f17c6b566c --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/OieDicomConverter.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom; + +import java.io.IOException; + +/** + * Version-neutral interface for DICOM conversion operations. Abstracts the byte-array, + * XML, and object conversion logic across dcm4che library versions. + */ +public interface OieDicomConverter { + + /** + * Parses a byte array into a DICOM object. + * + * @param bytes The binary DICOM data + * @param decodeBase64 If true, the input is Base64-decoded before parsing + * @return The parsed DICOM object + */ + OieDicomObject byteArrayToDicomObject(byte[] bytes, boolean decodeBase64) throws IOException; + + /** + * Serializes a DICOM object to a byte array. Note: the DICOM object is cleared + * after serialization as a memory optimization. + * + * @param dicomObject The DICOM object to serialize + * @return The serialized byte array + */ + byte[] dicomObjectToByteArray(OieDicomObject dicomObject) throws IOException; + + /** + * Creates a new empty DICOM object. + */ + OieDicomObject createDicomObject(); + + /** + * Converts Base64-encoded DICOM data to its XML representation. + * The XML uses dcm4che's native format with <attr> elements. + * + * @param encodedDicomBytes ASCII bytes of Base64-encoded DICOM data + * @return The XML string representation + */ + String dicomBytesToXml(byte[] encodedDicomBytes) throws Exception; + + /** + * Parses XML (in dcm4che attr format) into a DICOM object. + * + * @param xml The XML string in dcm4che format + * @param charset The character set for the XML bytes + * @return The parsed DICOM object + */ + OieDicomObject xmlToDicomObject(String xml, String charset) throws Exception; + + /** + * Returns the human-readable name for a DICOM tag. + * + * @param tag The DICOM tag number + * @return The element name, or empty string if unknown + */ + String getElementName(int tag); +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/OieDicomElement.java b/server/src/com/mirth/connect/connectors/dimse/dicom/OieDicomElement.java new file mode 100644 index 0000000000..5b7c728ad1 --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/OieDicomElement.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom; + +/** + * Version-neutral interface for DICOM data elements. Wraps dcm4che2 DicomElement + * or dcm4che5 element access on Attributes. + */ +public interface OieDicomElement { + + int tag(); + + int length(); + + OieVR vr(); + + String getValueAsString(int index); + + boolean hasItems(); + + int countItems(); + + boolean isEmpty(); + + boolean hasDicomObjects(); + + boolean hasFragments(); + + byte[] getFragment(int index); + + byte[] getBytes(); + + String[] getStrings(); + + int getInt(int defaultValue); + + int[] getInts(); + + float getFloat(float defaultValue); + + float[] getFloats(); + + double getDouble(double defaultValue); + + double[] getDoubles(); + + java.util.Date getDate(); + + java.util.Date[] getDates(); + + void addFragment(byte[] data); + + OieDicomObject getDicomObject(); + + /** + * Returns the sequence item at the given index, or null if the index is out of range + * or this element is not a sequence. + */ + default OieDicomObject getDicomObject(int index) { + return index == 0 ? getDicomObject() : null; + } + + void addDicomObject(OieDicomObject obj); + + /** + * Returns the underlying library-specific element. Use with caution. + */ + Object unwrap(); +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/OieDicomObject.java b/server/src/com/mirth/connect/connectors/dimse/dicom/OieDicomObject.java new file mode 100644 index 0000000000..f385671385 --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/OieDicomObject.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom; + +import java.util.Date; +import java.util.Iterator; + +/** + * Version-neutral interface for DICOM data objects. Wraps dcm4che2 DicomObject/BasicDicomObject + * or dcm4che5 Attributes, providing a consistent API across library versions. + */ +public interface OieDicomObject { + + String getString(int tag); + + /** + * Returns the string value for the given tag, or {@code defaultValue} + * if the tag is not present. + */ + default String getString(int tag, String defaultValue) { + String val = getString(tag); + return val != null ? val : defaultValue; + } + + int getInt(int tag); + + /** + * Returns the integer value for the given tag, or {@code defaultValue} + * if the tag is not present. + */ + default int getInt(int tag, int defaultValue) { + return contains(tag) ? getInt(tag) : defaultValue; + } + + OieDicomElement get(int tag); + + boolean contains(int tag); + + int size(); + + boolean isEmpty(); + + byte[] getBytes(int tag); + + int[] getInts(int tag); + + String[] getStrings(int tag); + + float getFloat(int tag, float defaultValue); + + float[] getFloats(int tag); + + double getDouble(int tag, double defaultValue); + + double[] getDoubles(int tag); + + Date getDate(int tag); + + Date[] getDates(int tag); + + OieDicomObject getNestedDicomObject(int tag); + + /** Value multiplicity for the element at {@code tag}. Returns 0 when absent. */ + int vm(int tag); + + /** Two-letter VR code for {@code tag}, derived from the DICOM dictionary. */ + String vrOf(int tag); + + /** Human-readable attribute name for {@code tag}, or {@code null} when unknown. */ + String nameOf(int tag); + + void putString(int tag, String vr, String value); + + /** + * Overload accepting any {@code Object} as the VR argument. Delegates to + * {@link #putString(int, String, String)} using {@code vr.toString()}. + * + *

Preserves backward compatibility for transformer scripts that pass a + * library-specific VR constant (e.g., dcm4che2's {@code VR.PN}), whose + * {@code toString()} returns the two-letter VR code. + */ + default void putString(int tag, Object vr, String value) { + putString(tag, vr != null ? vr.toString() : null, value); + } + + void putInt(int tag, String vr, int value); + + /** Object-VR overload of {@link #putInt(int, String, int)}; see {@link #putString(int, Object, String)}. */ + default void putInt(int tag, Object vr, int value) { + putInt(tag, vr != null ? vr.toString() : null, value); + } + + void putBytes(int tag, String vr, byte[] value); + + /** Object-VR overload of {@link #putBytes(int, String, byte[])}; see {@link #putString(int, Object, String)}. */ + default void putBytes(int tag, Object vr, byte[] value) { + putBytes(tag, vr != null ? vr.toString() : null, value); + } + + OieDicomElement putSequence(int tag); + + OieDicomElement putFragments(int tag, String vr, boolean bigEndian, int capacity); + + /** Object-VR overload of {@link #putFragments(int, String, boolean, int)}; see {@link #putString(int, Object, String)}. */ + default OieDicomElement putFragments(int tag, Object vr, boolean bigEndian, int capacity) { + return putFragments(tag, vr != null ? vr.toString() : null, bigEndian, capacity); + } + + void add(OieDicomElement element); + + OieDicomElement remove(int tag); + + void clear(); + + boolean hasFileMetaInfo(); + + void initFileMetaInformation(String cuid, String iuid, String tsuid); + + boolean bigEndian(); + + /** + * Returns a read-only iterator over the command elements (group 0x0000) of this DICOM object. + * Used for building response XML from DIMSE command responses. + * + *

The returned iterator does not support {@code remove()}. Callers must not + * attempt to modify the iteration — behavior varies by implementation. + */ + Iterator commandIterator(); + + /** + * Returns the underlying library-specific DICOM object (e.g., dcm4che2 DicomObject + * or dcm4che5 Attributes). Use with caution — this breaks version independence. + * + *

Example — accessing dcm4che2 APIs from a user script: + *

{@code
+     * OieDicomObject oie = DICOMUtil.byteArrayToDicomObject(bytes, false);
+     * DicomObject dcm = (DicomObject) oie.unwrap();
+     * dcm.getString(Tag.PatientName, "UNKNOWN");
+     * }
+ */ + Object unwrap(); +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/OieDicomReceiver.java b/server/src/com/mirth/connect/connectors/dimse/dicom/OieDicomReceiver.java new file mode 100644 index 0000000000..b8c55c9921 --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/OieDicomReceiver.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom; + +/** + * Version-neutral interface for DICOM C-STORE SCP (receiver). Abstracts dcm4che2's + * MirthDcmRcv/DcmRcv and dcm4che5's StoreSCP. + */ +public interface OieDicomReceiver { + + void setPort(int port); + + void setHostname(String hostname); + + void setDestination(String destination); + + void setTransferSyntax(String[] transferSyntax); + + void setAEtitle(String aeTitle); + + void setAssociationReaperPeriod(int period); + + void setIdleTimeout(int timeout); + + void setRequestTimeout(int timeout); + + void setReleaseTimeout(int timeout); + + void setSocketCloseDelay(int delay); + + void setDimseRspDelay(int delay); + + void setMaxPDULengthReceive(int length); + + void setMaxPDULengthSend(int length); + + void setSendBufferSize(int size); + + void setReceiveBufferSize(int size); + + void setFileBufferSize(int size); + + void setPackPDV(boolean packPDV); + + void setTcpNoDelay(boolean tcpNoDelay); + + void setMaxOpsPerformed(int maxOps); + + void setTlsWithoutEncryption(); + + void setTls3DES_EDE_CBC(); + + void setTlsAES_128_CBC(); + + /** + * Sets custom TLS cipher suites. Use this instead of the preset methods + * (setTlsWithoutEncryption, setTls3DES_EDE_CBC, setTlsAES_128_CBC) when + * those legacy suites are not suitable (e.g., disabled in modern JVMs). + */ + default void setTlsCipherSuites(String[] cipherSuites) {} + + void setTrustStoreURL(String url); + + void setTrustStorePassword(String password); + + void setKeyPassword(String password); + + void setKeyStoreURL(String url); + + void setKeyStorePassword(String password); + + /** + * Sets the keystore type (e.g., "JKS", "PKCS12", "JCEKS"). + * dcm4che2 infers this automatically; dcm4che5 requires it explicitly. + * If not set, dcm4che5 will infer from the keystore URL file extension. + */ + default void setKeyStoreType(String type) {} + + /** + * Sets the truststore type (e.g., "JKS", "PKCS12", "JCEKS"). + * dcm4che2 infers this automatically; dcm4che5 requires it explicitly. + * If not set, dcm4che5 will infer from the truststore URL file extension. + */ + default void setTrustStoreType(String type) {} + + void setTlsNeedClientAuth(boolean needClientAuth); + + void setTlsProtocol(String[] protocols); + + void initTLS() throws Exception; + + void initTransferCapability(); + + void start() throws Exception; + + void stop(); + + /** + * Returns the underlying library-specific receiver object. Use with caution. + */ + Object unwrap(); +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/OieDicomSender.java b/server/src/com/mirth/connect/connectors/dimse/dicom/OieDicomSender.java new file mode 100644 index 0000000000..5a785bed5f --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/OieDicomSender.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom; + +import java.io.File; +import java.io.IOException; + +/** + * Version-neutral interface for DICOM C-STORE SCU (sender). Abstracts dcm4che2's + * MirthDcmSnd/DcmSnd and dcm4che5's StoreSCU. + */ +public interface OieDicomSender { + + void setCalledAET(String aet); + + void setRemoteHost(String host); + + void setRemotePort(int port); + + void setCalling(String aet); + + void setLocalHost(String host); + + void setLocalPort(int port); + + void addFile(File file); + + void setAcceptTimeout(int timeout); + + void setMaxOpsInvoked(int maxOps); + + void setTranscoderBufferSize(int size); + + void setConnectTimeout(int timeout); + + void setPriority(int priority); + + void setPackPDV(boolean packPDV); + + void setMaxPDULengthReceive(int length); + + void setMaxPDULengthSend(int length); + + void setReceiveBufferSize(int size); + + void setSendBufferSize(int size); + + void setAssociationReaperPeriod(int period); + + void setReleaseTimeout(int timeout); + + void setDimseRspTimeout(int timeout); + + void setShutdownDelay(int delay); + + void setSocketCloseDelay(int delay); + + void setTcpNoDelay(boolean tcpNoDelay); + + void setOfferDefaultTransferSyntaxInSeparatePresentationContext(boolean ts1); + + void setStorageCommitment(boolean stgcmt); + + void setUserIdentity(String username, String passcode, boolean positiveResponseRequested); + + void setTlsWithoutEncryption(); + + void setTls3DES_EDE_CBC(); + + void setTlsAES_128_CBC(); + + /** + * Sets custom TLS cipher suites. Use this instead of the preset methods + * (setTlsWithoutEncryption, setTls3DES_EDE_CBC, setTlsAES_128_CBC) when + * those legacy suites are not suitable (e.g., disabled in modern JVMs). + */ + default void setTlsCipherSuites(String[] cipherSuites) {} + + void setTrustStoreURL(String url); + + void setTrustStorePassword(String password); + + void setKeyPassword(String password); + + void setKeyStoreURL(String url); + + void setKeyStorePassword(String password); + + /** + * Sets the keystore type (e.g., "JKS", "PKCS12", "JCEKS"). + * dcm4che2 infers this automatically; dcm4che5 requires it explicitly. + * If not set, dcm4che5 will infer from the keystore URL file extension. + */ + default void setKeyStoreType(String type) {} + + /** + * Sets the truststore type (e.g., "JKS", "PKCS12", "JCEKS"). + * dcm4che2 infers this automatically; dcm4che5 requires it explicitly. + * If not set, dcm4che5 will infer from the truststore URL file extension. + */ + default void setTrustStoreType(String type) {} + + void setTlsNeedClientAuth(boolean needClientAuth); + + void setTlsProtocol(String[] protocols); + + void initTLS() throws Exception; + + void configureTransferCapability(); + + void start() throws IOException; + + void open() throws Exception; + + void send(OieDimseRspHandler handler) throws Exception; + + boolean isStorageCommitment(); + + boolean commit() throws Exception; + + OieDicomObject waitForStgCmtResult() throws InterruptedException; + + void close(); + + void stop(); + + /** + * Returns the underlying library-specific sender object. Use with caution. + */ + Object unwrap(); +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/OieDimseRspHandler.java b/server/src/com/mirth/connect/connectors/dimse/dicom/OieDimseRspHandler.java new file mode 100644 index 0000000000..814923bdb2 --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/OieDimseRspHandler.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom; + +/** + * Version-neutral interface for handling DIMSE responses. Replaces dcm4che2's + * CustomDimseRSPHandler with a version-independent callback. + */ +public interface OieDimseRspHandler { + + /** + * Called when a DIMSE response is received. + * + * @param cmd The command DICOM object from the response + * @param data The data DICOM object from the response (may be null) + */ + void onDimseRSP(OieDicomObject cmd, OieDicomObject data); +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/OieVR.java b/server/src/com/mirth/connect/connectors/dimse/dicom/OieVR.java new file mode 100644 index 0000000000..6d7e874ee3 --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/OieVR.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom; + +/** + * Version-neutral wrapper for a DICOM Value Representation. Matches the runtime + * shape of dcm4che2's {@code VR} class so existing JavaScript transformer + * scripts calling {@code elem.vr().code()} or {@code String(elem.vr())} + * continue to work regardless of the configured DICOM backend. + */ +public interface OieVR { + + /** Two-character VR code (e.g. {@code "UI"}, {@code "SQ"}, {@code "OB"}). */ + String toString(); + + /** Packed 16-bit representation of the VR code, as stored on the wire. */ + int code(); + + /** Pad byte used when the VR's encoded length would otherwise be odd. */ + int padding(); + + /** Underlying library-specific VR object. Use with caution. */ + Object unwrap(); +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DICOMConfiguration.java b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DICOMConfiguration.java new file mode 100644 index 0000000000..0b8f922ea2 --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DICOMConfiguration.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom.dcm2; + +import java.util.Map; + +import org.dcm4che2.net.Association; +import org.dcm4che2.net.NetworkConnection; +import org.dcm4che2.tool.dcmrcv.MirthDcmRcv; +import org.dcm4che2.tool.dcmsnd.MirthDcmSnd; + +import com.mirth.connect.connectors.dimse.DICOMConfiguration; +import com.mirth.connect.connectors.dimse.DICOMDispatcher; +import com.mirth.connect.connectors.dimse.DICOMDispatcherProperties; +import com.mirth.connect.connectors.dimse.DICOMReceiver; +import com.mirth.connect.connectors.dimse.DICOMReceiverProperties; +import com.mirth.connect.connectors.dimse.dicom.OieDicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.OieDicomSender; + +/** + * dcm4che2-specific extension of {@link DICOMConfiguration}. Provides the + * legacy dcm4che2-typed method signatures and bridge defaults that delegate + * the version-neutral methods to the legacy ones. + * + *

Custom implementations that need dcm4che2 API access should implement + * this interface. The method signatures match the original pre-abstraction + * {@code DICOMConfiguration} interface, so migrating is a one-line change: + * replace {@code implements DICOMConfiguration} with + * {@code implements Dcm2DICOMConfiguration}. + */ +public interface Dcm2DICOMConfiguration extends DICOMConfiguration { + + // Legacy dcm4che2-typed methods (same signatures as original DICOMConfiguration) + + void configureDcmRcv(MirthDcmRcv dcmrcv, DICOMReceiver connector, + DICOMReceiverProperties connectorProperties) throws Exception; + + void configureDcmSnd(MirthDcmSnd dcmsnd, DICOMDispatcher connector, + DICOMDispatcherProperties connectorProperties) throws Exception; + + Map getCStoreRequestInformation(Association association); + + NetworkConnection createLegacyNetworkConnection(); + + // Bridge defaults: version-neutral methods delegate to legacy-typed methods + + @Override + default void configureReceiver(OieDicomReceiver receiver, DICOMReceiver connector, + DICOMReceiverProperties connectorProperties) throws Exception { + configureDcmRcv((MirthDcmRcv) receiver.unwrap(), connector, connectorProperties); + } + + @Override + default void configureSender(OieDicomSender sender, DICOMDispatcher connector, + DICOMDispatcherProperties connectorProperties) throws Exception { + configureDcmSnd((MirthDcmSnd) sender.unwrap(), connector, connectorProperties); + } + + @Override + default Map getCStoreRequestInformation(Object association) { + return getCStoreRequestInformation((Association) association); + } + + @Override + default Object createNetworkConnection() { + return createLegacyNetworkConnection(); + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DicomConverter.java b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DicomConverter.java new file mode 100644 index 0000000000..50257d8e51 --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DicomConverter.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom.dcm2; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringWriter; + +import javax.xml.XMLConstants; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.sax.SAXTransformerFactory; +import javax.xml.transform.sax.TransformerHandler; +import javax.xml.transform.stream.StreamResult; + +import org.apache.commons.codec.binary.Base64InputStream; +import org.apache.commons.io.IOUtils; +import org.dcm4che2.data.BasicDicomObject; +import org.dcm4che2.data.DicomObject; +import org.dcm4che2.data.ElementDictionary; +import org.dcm4che2.data.TransferSyntax; +import org.dcm4che2.io.ContentHandlerAdapter; +import org.dcm4che2.io.DicomInputStream; +import org.dcm4che2.io.DicomOutputStream; +import org.dcm4che2.io.SAXWriter; +import org.xml.sax.InputSource; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.mirth.connect.connectors.dimse.dicom.OieDicomConverter; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; +import com.mirth.connect.donkey.util.ByteCounterOutputStream; + +/** + * dcm4che2 implementation of OieDicomConverter. Handles all byte-array, XML, and + * DICOM object conversion using the dcm4che 2.0.29 library. + */ +public class Dcm2DicomConverter implements OieDicomConverter { + + private static final Logger logger = LogManager.getLogger(Dcm2DicomConverter.class); + + @Override + public OieDicomObject byteArrayToDicomObject(byte[] bytes, boolean decodeBase64) throws IOException { + DicomObject basicDicomObject = new BasicDicomObject(); + DicomInputStream dis = null; + + try { + ByteArrayInputStream bais = new ByteArrayInputStream(bytes); + InputStream inputStream; + if (decodeBase64) { + inputStream = new BufferedInputStream(new Base64InputStream(bais)); + } else { + inputStream = bais; + } + dis = new DicomInputStream(inputStream); + /* + * This parameter was added in dcm4che 2.0.28. We use it to retain the memory allocation + * behavior from 2.0.25. http://www.mirthcorp.com/community/issues/browse/MIRTH-2166 + * http://www.dcm4che.org/jira/browse/DCM-554 + */ + dis.setAllocateLimit(-1); + dis.readDicomObject(basicDicomObject, -1); + } catch (IOException e) { + throw e; + } finally { + IOUtils.closeQuietly(dis); + } + + return new Dcm2DicomObject(basicDicomObject); + } + + @Override + public byte[] dicomObjectToByteArray(OieDicomObject dicomObject) throws IOException { + BasicDicomObject basicDicomObject = (BasicDicomObject) dicomObject.unwrap(); + DicomOutputStream dos = null; + + try { + ByteCounterOutputStream bcos = new ByteCounterOutputStream(); + ByteArrayOutputStream baos; + + if (basicDicomObject.fileMetaInfo().isEmpty()) { + try { + dos = new DicomOutputStream(bcos); + dos.writeDataset(basicDicomObject, TransferSyntax.ImplicitVRLittleEndian); + } finally { + IOUtils.closeQuietly(dos); + } + + baos = new ByteArrayOutputStream(bcos.size()); + dos = new DicomOutputStream(baos); + dos.writeDataset(basicDicomObject, TransferSyntax.ImplicitVRLittleEndian); + } else { + try { + dos = new DicomOutputStream(bcos); + dos.writeDicomFile(basicDicomObject); + } finally { + IOUtils.closeQuietly(dos); + } + + baos = new ByteArrayOutputStream(bcos.size()); + dos = new DicomOutputStream(baos); + dos.writeDicomFile(basicDicomObject); + } + + // Memory Optimization since the dicom object is no longer needed at this point. + dicomObject.clear(); + + return baos.toByteArray(); + } catch (IOException e) { + throw e; + } catch (Throwable t) { + logger.error("Failed to serialize DICOM object to byte array", t); + throw new IOException("DICOM serialization failed", t); + } finally { + IOUtils.closeQuietly(dos); + } + } + + @Override + public OieDicomObject createDicomObject() { + return new Dcm2DicomObject(); + } + + @Override + public String dicomBytesToXml(byte[] encodedDicomBytes) throws Exception { + StringWriter output = new StringWriter(); + DicomInputStream dis = new DicomInputStream(new BufferedInputStream(new Base64InputStream(new ByteArrayInputStream(encodedDicomBytes)))); + dis.setAllocateLimit(-1); + + try { + TransformerFactory tf = TransformerFactory.newInstance(); + tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ""); + SAXTransformerFactory factory = (SAXTransformerFactory) tf; + TransformerHandler handler = factory.newTransformerHandler(); + handler.getTransformer().setOutputProperty(OutputKeys.INDENT, "no"); + handler.setResult(new StreamResult(output)); + + final SAXWriter writer = new SAXWriter(handler, null); + dis.setHandler(writer); + dis.readDicomObject(new BasicDicomObject(), -1); + + return output.toString(); + } finally { + IOUtils.closeQuietly(dis); + IOUtils.closeQuietly(output); + } + } + + @Override + public OieDicomObject xmlToDicomObject(String xml, String charset) throws Exception { + SAXParserFactory factory = SAXParserFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + SAXParser parser = factory.newSAXParser(); + DicomObject dicomObject = new BasicDicomObject(); + ContentHandlerAdapter contentHandler = new ContentHandlerAdapter(dicomObject); + byte[] documentBytes = xml.trim().getBytes(charset); + parser.parse(new InputSource(new ByteArrayInputStream(documentBytes)), contentHandler); + return new Dcm2DicomObject(dicomObject); + } + + @Override + public String getElementName(int tag) { + try { + return ElementDictionary.getDictionary().nameOf(tag); + } catch (Exception e) { + return ""; + } + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DicomElement.java b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DicomElement.java new file mode 100644 index 0000000000..b8a6b25d17 --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DicomElement.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom.dcm2; + +import org.dcm4che2.data.DicomElement; +import org.dcm4che2.data.DicomObject; + +import com.mirth.connect.connectors.dimse.dicom.OieDicomElement; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; +import com.mirth.connect.connectors.dimse.dicom.OieVR; + +/** + * dcm4che2 implementation of OieDicomElement, wrapping DicomElement. + */ +public class Dcm2DicomElement implements OieDicomElement { + + private final DicomElement delegate; + + public Dcm2DicomElement(DicomElement delegate) { + this.delegate = delegate; + } + + @Override + public int tag() { + return delegate.tag(); + } + + @Override + public int length() { + return delegate.length(); + } + + @Override + public OieVR vr() { + return new Dcm2VR(delegate.vr()); + } + + @Override + public String getValueAsString(int index) { + return delegate.getValueAsString(null, index); + } + + @Override + public boolean hasItems() { + return delegate.hasItems(); + } + + @Override + public int countItems() { + return delegate.countItems(); + } + + @Override + public boolean isEmpty() { + return delegate.isEmpty(); + } + + @Override + public boolean hasDicomObjects() { + return delegate.hasDicomObjects(); + } + + @Override + public boolean hasFragments() { + return delegate.hasFragments(); + } + + @Override + public byte[] getFragment(int index) { + return delegate.getFragment(index); + } + + @Override + public byte[] getBytes() { + return delegate.getBytes(); + } + + @Override + public String[] getStrings() { + return delegate.getStrings(null, false); + } + + @Override + public int getInt(int defaultValue) { + return delegate.isEmpty() ? defaultValue : delegate.getInt(false); + } + + @Override + public int[] getInts() { + return delegate.getInts(false); + } + + @Override + public float getFloat(float defaultValue) { + return delegate.isEmpty() ? defaultValue : delegate.getFloat(false); + } + + @Override + public float[] getFloats() { + return delegate.getFloats(false); + } + + @Override + public double getDouble(double defaultValue) { + return delegate.isEmpty() ? defaultValue : delegate.getDouble(false); + } + + @Override + public double[] getDoubles() { + return delegate.getDoubles(false); + } + + @Override + public java.util.Date getDate() { + return delegate.getDate(false); + } + + @Override + public java.util.Date[] getDates() { + return delegate.getDates(false); + } + + @Override + public void addFragment(byte[] data) { + delegate.addFragment(data); + } + + @Override + public OieDicomObject getDicomObject() { + DicomObject obj = delegate.getDicomObject(); + return obj != null ? new Dcm2DicomObject(obj) : null; + } + + @Override + public OieDicomObject getDicomObject(int index) { + if (index < 0 || index >= delegate.countItems()) { + return null; + } + DicomObject obj = delegate.getDicomObject(index); + return obj != null ? new Dcm2DicomObject(obj) : null; + } + + @Override + public void addDicomObject(OieDicomObject obj) { + delegate.addDicomObject((DicomObject) obj.unwrap()); + } + + @Override + public Object unwrap() { + return delegate; + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DicomObject.java b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DicomObject.java new file mode 100644 index 0000000000..02280b9bde --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DicomObject.java @@ -0,0 +1,265 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom.dcm2; + +import java.util.Iterator; +import java.util.NoSuchElementException; + +import org.dcm4che2.data.BasicDicomObject; +import org.dcm4che2.data.DicomElement; +import org.dcm4che2.data.DicomObject; +import org.dcm4che2.data.VR; + +import com.mirth.connect.connectors.dimse.dicom.OieDicomElement; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; +import com.mirth.connect.connectors.dimse.dicom.OieVR; + +/** + * dcm4che2 implementation of OieDicomObject, wrapping BasicDicomObject/DicomObject. + */ +public class Dcm2DicomObject implements OieDicomObject { + + static VR toVR(String vrName) { + if (vrName == null || vrName.length() != 2) { + throw new IllegalArgumentException("Invalid VR: " + vrName); + } + return VR.valueOf((vrName.charAt(0) << 8) | vrName.charAt(1)); + } + + private final DicomObject delegate; + + public Dcm2DicomObject(DicomObject delegate) { + this.delegate = delegate; + } + + public Dcm2DicomObject() { + this(new BasicDicomObject()); + } + + @Override + public String getString(int tag) { + return delegate.getString(tag); + } + + @Override + public int getInt(int tag) { + return delegate.getInt(tag); + } + + @Override + public OieDicomElement get(int tag) { + DicomElement element = delegate.get(tag); + return element != null ? new Dcm2DicomElement(element) : null; + } + + @Override + public boolean contains(int tag) { + return delegate.contains(tag); + } + + @Override + public int size() { + return delegate.size(); + } + + @Override + public boolean isEmpty() { + return delegate.isEmpty(); + } + + @Override + public byte[] getBytes(int tag) { + return delegate.getBytes(tag); + } + + @Override + public int[] getInts(int tag) { + return delegate.getInts(tag); + } + + @Override + public String[] getStrings(int tag) { + return delegate.getStrings(tag); + } + + @Override + public float getFloat(int tag, float defaultValue) { + return delegate.getFloat(tag, defaultValue); + } + + @Override + public float[] getFloats(int tag) { + return delegate.getFloats(tag); + } + + @Override + public double getDouble(int tag, double defaultValue) { + return delegate.getDouble(tag, defaultValue); + } + + @Override + public double[] getDoubles(int tag) { + return delegate.getDoubles(tag); + } + + @Override + public java.util.Date getDate(int tag) { + return delegate.getDate(tag); + } + + @Override + public java.util.Date[] getDates(int tag) { + return delegate.getDates(tag); + } + + @Override + public OieDicomObject getNestedDicomObject(int tag) { + DicomObject nested = delegate.getNestedDicomObject(tag); + return nested != null ? new Dcm2DicomObject(nested) : null; + } + + @Override + public int vm(int tag) { + // dcm4che2 returns -1 when the tag is absent; normalize to 0 + // so callers can treat the result as a simple cardinality. + int vm = delegate.vm(tag); + return vm < 0 ? 0 : vm; + } + + @Override + public String vrOf(int tag) { + VR vr = delegate.vrOf(tag); + return vr != null ? vr.toString() : null; + } + + @Override + public String nameOf(int tag) { + return delegate.nameOf(tag); + } + + @Override + public void putString(int tag, String vr, String value) { + delegate.putString(tag, toVR(vr), value); + } + + @Override + public void putInt(int tag, String vr, int value) { + delegate.putInt(tag, toVR(vr), value); + } + + @Override + public void putBytes(int tag, String vr, byte[] value) { + delegate.putBytes(tag, toVR(vr), value); + } + + @Override + public OieDicomElement putSequence(int tag) { + DicomElement element = delegate.putSequence(tag); + return new Dcm2DicomElement(element); + } + + @Override + public OieDicomElement putFragments(int tag, String vr, boolean bigEndian, int capacity) { + DicomElement element = delegate.putFragments(tag, toVR(vr), bigEndian, capacity); + return new Dcm2DicomElement(element); + } + + @Override + public void add(OieDicomElement element) { + if (element instanceof Dcm2DicomElement) { + delegate.add((DicomElement) element.unwrap()); + } else { + // Cross-library element: extract via interface methods. + // Sequences cannot be fully reconstructed across library boundaries without + // a converter round-trip; fragments and plain values transfer cleanly via bytes. + int tag = element.tag(); + String vrName = String.valueOf(element.vr()); + if (element.hasItems() && !"SQ".equals(vrName)) { + // Fragments — binary-compatible across libraries + VR vr = toVR(vrName); + DicomElement frags = delegate.putFragments(tag, vr, false, element.countItems()); + for (int i = 0; i < element.countItems(); i++) { + frags.addFragment(element.getFragment(i)); + } + } else if (!"SQ".equals(vrName)) { + // Plain value — copy raw bytes + byte[] bytes = element.getBytes(); + if (bytes != null) { + delegate.putBytes(tag, toVR(vrName), bytes); + } + } + // Sequences from another library are silently skipped — use the converter + // for full cross-library DICOM object conversion instead. + } + } + + @Override + public OieDicomElement remove(int tag) { + DicomElement element = delegate.remove(tag); + return element != null ? new Dcm2DicomElement(element) : null; + } + + @Override + public void clear() { + delegate.clear(); + } + + @Override + public boolean hasFileMetaInfo() { + if (delegate instanceof BasicDicomObject) { + return !((BasicDicomObject) delegate).fileMetaInfo().isEmpty(); + } + return false; + } + + @Override + public void initFileMetaInformation(String cuid, String iuid, String tsuid) { + if (delegate instanceof BasicDicomObject) { + ((BasicDicomObject) delegate).initFileMetaInformation(cuid, iuid, tsuid); + } + } + + @Override + public boolean bigEndian() { + return delegate.bigEndian(); + } + + @Override + public Iterator commandIterator() { + if (delegate instanceof BasicDicomObject) { + final Iterator dcmIt = ((BasicDicomObject) delegate).commandIterator(); + return new Iterator() { + @Override + public boolean hasNext() { + return dcmIt.hasNext(); + } + + @Override + public OieDicomElement next() { + if (!dcmIt.hasNext()) { + throw new NoSuchElementException(); + } + return new Dcm2DicomElement(dcmIt.next()); + } + + @Override + public void remove() { + dcmIt.remove(); + } + }; + } + return java.util.Collections.emptyIterator(); + } + + @Override + public Object unwrap() { + return delegate; + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DicomReceiver.java b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DicomReceiver.java new file mode 100644 index 0000000000..98788e7bb3 --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DicomReceiver.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom.dcm2; + +import org.dcm4che2.tool.dcmrcv.MirthDcmRcv; + +import com.mirth.connect.connectors.dimse.DICOMConfiguration; +import com.mirth.connect.connectors.dimse.dicom.OieDicomReceiver; +import com.mirth.connect.donkey.server.channel.SourceConnector; + +/** + * dcm4che2 implementation of OieDicomReceiver, wrapping MirthDcmRcv. + */ +public class Dcm2DicomReceiver implements OieDicomReceiver { + + private final MirthDcmRcv delegate; + + public Dcm2DicomReceiver(SourceConnector sourceConnector, DICOMConfiguration configuration) { + this.delegate = new MirthDcmRcv(sourceConnector, configuration); + } + + /** + * Wrapping constructor for an existing MirthDcmRcv instance. + */ + public Dcm2DicomReceiver(MirthDcmRcv delegate) { + this.delegate = delegate; + } + + @Override + public void setPort(int port) { + delegate.setPort(port); + } + + @Override + public void setHostname(String hostname) { + delegate.setHostname(hostname); + } + + @Override + public void setDestination(String destination) { + delegate.setDestination(destination); + } + + @Override + public void setTransferSyntax(String[] transferSyntax) { + delegate.setTransferSyntax(transferSyntax); + } + + @Override + public void setAEtitle(String aeTitle) { + delegate.setAEtitle(aeTitle); + } + + @Override + public void setAssociationReaperPeriod(int period) { + delegate.setAssociationReaperPeriod(period); + } + + @Override + public void setIdleTimeout(int timeout) { + delegate.setIdleTimeout(timeout); + } + + @Override + public void setRequestTimeout(int timeout) { + delegate.setRequestTimeout(timeout); + } + + @Override + public void setReleaseTimeout(int timeout) { + delegate.setReleaseTimeout(timeout); + } + + @Override + public void setSocketCloseDelay(int delay) { + delegate.setSocketCloseDelay(delay); + } + + @Override + public void setDimseRspDelay(int delay) { + delegate.setDimseRspDelay(delay); + } + + @Override + public void setMaxPDULengthReceive(int length) { + delegate.setMaxPDULengthReceive(length); + } + + @Override + public void setMaxPDULengthSend(int length) { + delegate.setMaxPDULengthSend(length); + } + + @Override + public void setSendBufferSize(int size) { + delegate.setSendBufferSize(size); + } + + @Override + public void setReceiveBufferSize(int size) { + delegate.setReceiveBufferSize(size); + } + + @Override + public void setFileBufferSize(int size) { + delegate.setFileBufferSize(size); + } + + @Override + public void setPackPDV(boolean packPDV) { + delegate.setPackPDV(packPDV); + } + + @Override + public void setTcpNoDelay(boolean tcpNoDelay) { + delegate.setTcpNoDelay(tcpNoDelay); + } + + @Override + public void setMaxOpsPerformed(int maxOps) { + delegate.setMaxOpsPerformed(maxOps); + } + + @Override + public void setTlsWithoutEncryption() { + delegate.setTlsWithoutEncyrption(); + } + + @Override + public void setTls3DES_EDE_CBC() { + delegate.setTls3DES_EDE_CBC(); + } + + @Override + public void setTlsAES_128_CBC() { + delegate.setTlsAES_128_CBC(); + } + + @Override + public void setTrustStoreURL(String url) { + delegate.setTrustStoreURL(url); + } + + @Override + public void setTrustStorePassword(String password) { + delegate.setTrustStorePassword(password); + } + + @Override + public void setKeyPassword(String password) { + delegate.setKeyPassword(password); + } + + @Override + public void setKeyStoreURL(String url) { + delegate.setKeyStoreURL(url); + } + + @Override + public void setKeyStorePassword(String password) { + delegate.setKeyStorePassword(password); + } + + @Override + public void setTlsNeedClientAuth(boolean needClientAuth) { + delegate.setTlsNeedClientAuth(needClientAuth); + } + + @Override + public void setTlsProtocol(String[] protocols) { + delegate.setTlsProtocol(protocols); + } + + @Override + public void initTLS() throws Exception { + delegate.initTLS(); + } + + @Override + public void initTransferCapability() { + delegate.initTransferCapability(); + } + + @Override + public void start() throws Exception { + delegate.start(); + } + + @Override + public void stop() { + delegate.stop(); + } + + @Override + public Object unwrap() { + return delegate; + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DicomSender.java b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DicomSender.java new file mode 100644 index 0000000000..f619029379 --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DicomSender.java @@ -0,0 +1,294 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom.dcm2; + +import java.io.File; +import java.io.IOException; + +import org.dcm4che2.data.DicomObject; +import org.dcm4che2.net.Association; +import org.dcm4che2.net.UserIdentity; +import org.dcm4che2.tool.dcmsnd.CustomDimseRSPHandler; +import org.dcm4che2.tool.dcmsnd.MirthDcmSnd; + +import com.mirth.connect.connectors.dimse.DICOMConfiguration; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; +import com.mirth.connect.connectors.dimse.dicom.OieDicomSender; +import com.mirth.connect.connectors.dimse.dicom.OieDimseRspHandler; + +/** + * dcm4che2 implementation of OieDicomSender, wrapping MirthDcmSnd. + */ +public class Dcm2DicomSender implements OieDicomSender { + + private final MirthDcmSnd delegate; + + public Dcm2DicomSender(DICOMConfiguration configuration) { + this.delegate = new MirthDcmSnd(configuration); + } + + /** + * Wrapping constructor for an existing MirthDcmSnd instance. + */ + public Dcm2DicomSender(MirthDcmSnd delegate) { + this.delegate = delegate; + } + + @Override + public void setCalledAET(String aet) { + delegate.setCalledAET(aet); + } + + @Override + public void setRemoteHost(String host) { + delegate.setRemoteHost(host); + } + + @Override + public void setRemotePort(int port) { + delegate.setRemotePort(port); + } + + @Override + public void setCalling(String aet) { + delegate.setCalling(aet); + } + + @Override + public void setLocalHost(String host) { + delegate.setLocalHost(host); + } + + @Override + public void setLocalPort(int port) { + delegate.setLocalPort(port); + } + + @Override + public void addFile(File file) { + delegate.addFile(file); + } + + @Override + public void setAcceptTimeout(int timeout) { + delegate.setAcceptTimeout(timeout); + } + + @Override + public void setMaxOpsInvoked(int maxOps) { + delegate.setMaxOpsInvoked(maxOps); + } + + @Override + public void setTranscoderBufferSize(int size) { + delegate.setTranscoderBufferSize(size); + } + + @Override + public void setConnectTimeout(int timeout) { + delegate.setConnectTimeout(timeout); + } + + @Override + public void setPriority(int priority) { + delegate.setPriority(priority); + } + + @Override + public void setPackPDV(boolean packPDV) { + delegate.setPackPDV(packPDV); + } + + @Override + public void setMaxPDULengthReceive(int length) { + delegate.setMaxPDULengthReceive(length); + } + + @Override + public void setMaxPDULengthSend(int length) { + delegate.setMaxPDULengthSend(length); + } + + @Override + public void setReceiveBufferSize(int size) { + delegate.setReceiveBufferSize(size); + } + + @Override + public void setSendBufferSize(int size) { + delegate.setSendBufferSize(size); + } + + @Override + public void setAssociationReaperPeriod(int period) { + delegate.setAssociationReaperPeriod(period); + } + + @Override + public void setReleaseTimeout(int timeout) { + delegate.setReleaseTimeout(timeout); + } + + @Override + public void setDimseRspTimeout(int timeout) { + delegate.setDimseRspTimeout(timeout); + } + + @Override + public void setShutdownDelay(int delay) { + delegate.setShutdownDelay(delay); + } + + @Override + public void setSocketCloseDelay(int delay) { + delegate.setSocketCloseDelay(delay); + } + + @Override + public void setTcpNoDelay(boolean tcpNoDelay) { + delegate.setTcpNoDelay(tcpNoDelay); + } + + @Override + public void setOfferDefaultTransferSyntaxInSeparatePresentationContext(boolean ts1) { + delegate.setOfferDefaultTransferSyntaxInSeparatePresentationContext(ts1); + } + + @Override + public void setStorageCommitment(boolean stgcmt) { + delegate.setStorageCommitment(stgcmt); + } + + @Override + public void setUserIdentity(String username, String passcode, boolean positiveResponseRequested) { + UserIdentity userId; + if (passcode != null && !passcode.isEmpty()) { + userId = new UserIdentity.UsernamePasscode(username, passcode.toCharArray()); + } else { + userId = new UserIdentity.Username(username); + } + userId.setPositiveResponseRequested(positiveResponseRequested); + delegate.setUserIdentity(userId); + } + + @Override + public void setTlsWithoutEncryption() { + delegate.setTlsWithoutEncyrption(); + } + + @Override + public void setTls3DES_EDE_CBC() { + delegate.setTls3DES_EDE_CBC(); + } + + @Override + public void setTlsAES_128_CBC() { + delegate.setTlsAES_128_CBC(); + } + + @Override + public void setTrustStoreURL(String url) { + delegate.setTrustStoreURL(url); + } + + @Override + public void setTrustStorePassword(String password) { + delegate.setTrustStorePassword(password); + } + + @Override + public void setKeyPassword(String password) { + delegate.setKeyPassword(password); + } + + @Override + public void setKeyStoreURL(String url) { + delegate.setKeyStoreURL(url); + } + + @Override + public void setKeyStorePassword(String password) { + delegate.setKeyStorePassword(password); + } + + @Override + public void setTlsNeedClientAuth(boolean needClientAuth) { + delegate.setTlsNeedClientAuth(needClientAuth); + } + + @Override + public void setTlsProtocol(String[] protocols) { + delegate.setTlsProtocol(protocols); + } + + @Override + public void initTLS() throws Exception { + delegate.initTLS(); + } + + @Override + public void configureTransferCapability() { + delegate.configureTransferCapability(); + } + + @Override + public void start() throws IOException { + delegate.start(); + } + + @Override + public void open() throws Exception { + delegate.open(); + } + + @Override + public void send(OieDimseRspHandler handler) throws Exception { + // Bridge from OieDimseRspHandler to dcm4che2's CustomDimseRSPHandler + delegate.send(new CustomDimseRSPHandler() { + @Override + public void onDimseRSP(Association as, DicomObject cmd, DicomObject data) { + OieDicomObject wrappedCmd = cmd != null ? new Dcm2DicomObject(cmd) : null; + OieDicomObject wrappedData = data != null ? new Dcm2DicomObject(data) : null; + handler.onDimseRSP(wrappedCmd, wrappedData); + } + }); + } + + @Override + public boolean isStorageCommitment() { + return delegate.isStorageCommitment(); + } + + @Override + public boolean commit() throws Exception { + return delegate.commit(); + } + + @Override + public OieDicomObject waitForStgCmtResult() throws InterruptedException { + DicomObject result = delegate.waitForStgCmtResult(); + return result != null ? new Dcm2DicomObject(result) : null; + } + + @Override + public void close() { + delegate.close(); + } + + @Override + public void stop() { + delegate.stop(); + } + + @Override + public Object unwrap() { + return delegate; + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2VR.java b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2VR.java new file mode 100644 index 0000000000..3f500e5d41 --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2VR.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom.dcm2; + +import org.dcm4che2.data.VR; + +import com.mirth.connect.connectors.dimse.dicom.OieVR; + +public class Dcm2VR implements OieVR { + + private final VR vr; + + public Dcm2VR(VR vr) { + this.vr = vr; + } + + @Override + public String toString() { + return vr.toString(); + } + + @Override + public int code() { + return vr.code(); + } + + @Override + public int padding() { + return vr.padding(); + } + + @Override + public Object unwrap() { + return vr; + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DICOMConfiguration.java b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DICOMConfiguration.java new file mode 100644 index 0000000000..1dbfec9ba7 --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DICOMConfiguration.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom.dcm5; + +import java.util.Map; + +import org.dcm4che3.net.Association; +import org.dcm4che3.net.Connection; + +import com.mirth.connect.connectors.dimse.DICOMConfiguration; +import com.mirth.connect.connectors.dimse.DICOMDispatcher; +import com.mirth.connect.connectors.dimse.DICOMDispatcherProperties; +import com.mirth.connect.connectors.dimse.DICOMReceiver; +import com.mirth.connect.connectors.dimse.DICOMReceiverProperties; +import com.mirth.connect.connectors.dimse.dicom.OieDicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.OieDicomSender; + +/** + * dcm4che5-specific extension of {@link DICOMConfiguration}. Provides dcm4che5-typed + * method signatures and bridge defaults that delegate the version-neutral methods. + * + *

Custom implementations that need dcm4che5 API access should implement this interface. + */ +public interface Dcm5DICOMConfiguration extends DICOMConfiguration { + + // dcm4che5-typed methods + + void configureDcm5Receiver(Dcm5DicomReceiver receiver, DICOMReceiver connector, + DICOMReceiverProperties connectorProperties) throws Exception; + + void configureDcm5Sender(Dcm5DicomSender sender, DICOMDispatcher connector, + DICOMDispatcherProperties connectorProperties) throws Exception; + + Map getCStoreRequestInformation(Association association); + + Connection createDcm5Connection(); + + // Bridge defaults: version-neutral methods delegate to dcm5-typed methods + + @Override + default void configureReceiver(OieDicomReceiver receiver, DICOMReceiver connector, + DICOMReceiverProperties connectorProperties) throws Exception { + // Cast receiver directly — Dcm5DicomReceiver IS the composition (no unwrap) + configureDcm5Receiver((Dcm5DicomReceiver) receiver, connector, connectorProperties); + } + + @Override + default void configureSender(OieDicomSender sender, DICOMDispatcher connector, + DICOMDispatcherProperties connectorProperties) throws Exception { + configureDcm5Sender((Dcm5DicomSender) sender, connector, connectorProperties); + } + + @Override + default Map getCStoreRequestInformation(Object association) { + return getCStoreRequestInformation((Association) association); + } + + @Override + default Object createNetworkConnection() { + return createDcm5Connection(); + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomConverter.java b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomConverter.java new file mode 100644 index 0000000000..fde390a345 --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomConverter.java @@ -0,0 +1,169 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom.dcm5; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringWriter; + +import javax.xml.XMLConstants; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.sax.SAXTransformerFactory; +import javax.xml.transform.sax.TransformerHandler; +import javax.xml.transform.stream.StreamResult; + +import org.apache.commons.codec.binary.Base64InputStream; +import org.apache.commons.io.IOUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.ElementDictionary; +import org.dcm4che3.data.UID; +import org.dcm4che3.io.ContentHandlerAdapter; +import org.dcm4che3.io.DicomInputStream; +import org.dcm4che3.io.DicomOutputStream; +import org.dcm4che3.io.SAXWriter; +import org.xml.sax.InputSource; + +import com.mirth.connect.connectors.dimse.dicom.OieDicomConverter; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; + +/** + * dcm4che5 implementation of OieDicomConverter. Handles all byte-array, XML, and + * DICOM object conversion using the dcm4che 5.34.3 library. + * + *

Key differences from dcm2: + *

    + *
  • No {@code setAllocateLimit(-1)} needed
  • + *
  • Single-pass write (no ByteCounterOutputStream)
  • + *
  • FMI is a separate Attributes object
  • + *
  • SAXWriter.write(dataset) instead of stream handler
  • + *
+ */ +public class Dcm5DicomConverter implements OieDicomConverter { + + private static final Logger logger = LogManager.getLogger(Dcm5DicomConverter.class); + + @Override + public OieDicomObject byteArrayToDicomObject(byte[] bytes, boolean decodeBase64) throws IOException { + DicomInputStream dis = null; + + try { + ByteArrayInputStream bais = new ByteArrayInputStream(bytes); + InputStream inputStream; + if (decodeBase64) { + inputStream = new BufferedInputStream(new Base64InputStream(bais)); + } else { + inputStream = bais; + } + dis = new DicomInputStream(inputStream); + Attributes fmi = dis.readFileMetaInformation(); + Attributes dataset = dis.readDataset(-1, -1); + return new Dcm5DicomObject(fmi, dataset); + } catch (IOException e) { + throw e; + } finally { + IOUtils.closeQuietly(dis); + } + } + + @Override + public byte[] dicomObjectToByteArray(OieDicomObject dicomObject) throws IOException { + Dcm5DicomObject dcm5obj = (Dcm5DicomObject) dicomObject; + Attributes dataset = (Attributes) dcm5obj.unwrap(); + Attributes fmi = dcm5obj.getFmi(); + + DicomOutputStream dos = null; + + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + if (fmi != null && !fmi.isEmpty()) { + dos = new DicomOutputStream(baos, UID.ExplicitVRLittleEndian); + dos.writeDataset(fmi, dataset); + } else { + dos = new DicomOutputStream(baos, UID.ImplicitVRLittleEndian); + dos.writeDataset(null, dataset); + } + + // Memory optimization since the dicom object is no longer needed at this point. + dicomObject.clear(); + + return baos.toByteArray(); + } catch (IOException e) { + throw e; + } catch (Throwable t) { + logger.error("Error serializing DICOM object to byte array", t); + return null; + } finally { + IOUtils.closeQuietly(dos); + } + } + + @Override + public OieDicomObject createDicomObject() { + return new Dcm5DicomObject(); + } + + @Override + public String dicomBytesToXml(byte[] encodedDicomBytes) throws Exception { + DicomInputStream dis = new DicomInputStream(new BufferedInputStream(new Base64InputStream(new ByteArrayInputStream(encodedDicomBytes)))); + + try { + Attributes fmi = dis.readFileMetaInformation(); + Attributes dataset = dis.readDataset(-1, -1); + + StringWriter output = new StringWriter(); + TransformerFactory tf = TransformerFactory.newInstance(); + tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ""); + SAXTransformerFactory factory = (SAXTransformerFactory) tf; + TransformerHandler handler = factory.newTransformerHandler(); + handler.getTransformer().setOutputProperty(OutputKeys.INDENT, "no"); + handler.setResult(new StreamResult(output)); + + SAXWriter writer = new SAXWriter(handler); + writer.setIncludeKeyword(true); + writer.write(dataset); + + return output.toString(); + } finally { + IOUtils.closeQuietly(dis); + } + } + + @Override + public OieDicomObject xmlToDicomObject(String xml, String charset) throws Exception { + SAXParserFactory factory = SAXParserFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + SAXParser parser = factory.newSAXParser(); + Attributes dataset = new Attributes(); + ContentHandlerAdapter contentHandler = new ContentHandlerAdapter(dataset); + byte[] documentBytes = xml.trim().getBytes(charset); + parser.parse(new InputSource(new ByteArrayInputStream(documentBytes)), contentHandler); + return new Dcm5DicomObject(dataset); + } + + @Override + public String getElementName(int tag) { + try { + String keyword = ElementDictionary.keywordOf(tag, null); + return keyword != null ? keyword : ""; + } catch (Exception e) { + return ""; + } + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomElement.java b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomElement.java new file mode 100644 index 0000000000..2c8d2be79f --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomElement.java @@ -0,0 +1,310 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom.dcm5; + +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.Fragments; +import org.dcm4che3.data.Sequence; +import org.dcm4che3.data.VR; + +import com.mirth.connect.connectors.dimse.dicom.OieDicomElement; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; +import com.mirth.connect.connectors.dimse.dicom.OieVR; + +/** + * Synthetic dcm4che5 implementation of OieDicomElement. dcm4che5 has no standalone + * DicomElement class — all element access is done through the parent Attributes object. + * + *

Three modes: + *

    + *
  • VALUE — references parent Attributes + tag for plain value access
  • + *
  • SEQUENCE — wraps a {@link Sequence} (list of Attributes)
  • + *
  • FRAGMENTS — wraps a {@link Fragments} collection
  • + *
+ */ +public class Dcm5DicomElement implements OieDicomElement { + + private enum Mode { VALUE, SEQUENCE, FRAGMENTS } + + private final int tag; + private final Attributes parent; + private final Mode mode; + private final Sequence sequence; + private final Fragments fragments; + private final String vrName; + + /** VALUE mode constructor. */ + Dcm5DicomElement(int tag, Attributes parent) { + this.tag = tag; + this.parent = parent; + this.mode = Mode.VALUE; + this.sequence = null; + this.fragments = null; + VR vr = parent.getVR(tag); + this.vrName = vr != null ? vr.name() : "UN"; + } + + /** SEQUENCE mode constructor. */ + Dcm5DicomElement(int tag, Attributes parent, Sequence sequence) { + this.tag = tag; + this.parent = parent; + this.mode = Mode.SEQUENCE; + this.sequence = sequence; + this.fragments = null; + this.vrName = "SQ"; + } + + /** FRAGMENTS mode constructor. */ + Dcm5DicomElement(int tag, Attributes parent, Fragments fragments, String vrName) { + this.tag = tag; + this.parent = parent; + this.mode = Mode.FRAGMENTS; + this.sequence = null; + this.fragments = fragments; + this.vrName = vrName; + } + + /** Copies this element's data into the target Attributes. Package-visible for Dcm5DicomObject.add(). */ + void copyTo(Attributes target) { + switch (mode) { + case SEQUENCE: + Sequence targetSeq = target.newSequence(tag, sequence.size()); + for (Attributes item : sequence) { + targetSeq.add(new Attributes(item)); + } + break; + case FRAGMENTS: + Fragments targetFrags = target.newFragments(tag, VR.valueOf(vrName), fragments.size()); + for (Object frag : fragments) { + targetFrags.add(frag); + } + break; + default: + try { + byte[] bytes = parent.getBytes(tag); + if (bytes != null) { + target.setBytes(tag, VR.valueOf(vrName), bytes); + } else { + String val = parent.getString(tag); + if (val != null) { + target.setString(tag, VR.valueOf(vrName), val); + } + } + } catch (java.io.IOException e) { + throw new RuntimeException("Failed to copy element " + Integer.toHexString(tag), e); + } + break; + } + } + + @Override + public int tag() { + return tag; + } + + @Override + public int length() { + switch (mode) { + case VALUE: + try { + byte[] bytes = parent.getBytes(tag); + return bytes != null ? bytes.length : -1; + } catch (Exception e) { + return -1; + } + default: + return -1; + } + } + + @Override + public OieVR vr() { + return new Dcm5VR(VR.valueOf(vrName)); + } + + @Override + public String getValueAsString(int index) { + if (mode != Mode.VALUE) { + return null; + } + try { + String[] values = parent.getStrings(tag); + if (values != null && index >= 0 && index < values.length) { + return values[index]; + } + } catch (Exception e) { + // Fall through + } + return null; + } + + @Override + public boolean hasItems() { + switch (mode) { + case SEQUENCE: + return !sequence.isEmpty(); + case FRAGMENTS: + return !fragments.isEmpty(); + default: + return false; + } + } + + @Override + public int countItems() { + switch (mode) { + case SEQUENCE: + return sequence.size(); + case FRAGMENTS: + return fragments.size(); + default: + return 0; + } + } + + @Override + public boolean isEmpty() { + switch (mode) { + case SEQUENCE: + return sequence.isEmpty(); + case FRAGMENTS: + return fragments.isEmpty(); + default: + Object v = parent.getValue(tag); + if (v == null) { + return true; + } + if (v instanceof byte[]) { + return ((byte[]) v).length == 0; + } + return false; + } + } + + @Override + public boolean hasDicomObjects() { + return mode == Mode.SEQUENCE; + } + + @Override + public boolean hasFragments() { + return mode == Mode.FRAGMENTS; + } + + @Override + public byte[] getFragment(int index) { + if (mode != Mode.FRAGMENTS) { + throw new UnsupportedOperationException("getFragment not supported in " + mode + " mode"); + } + Object frag = fragments.get(index); + return frag instanceof byte[] ? (byte[]) frag : null; + } + + @Override + public byte[] getBytes() { + if (mode != Mode.VALUE) { + return null; + } + try { + return parent.getBytes(tag); + } catch (java.io.IOException e) { + return null; + } + } + + @Override + public String[] getStrings() { + return mode == Mode.VALUE ? parent.getStrings(tag) : null; + } + + @Override + public int getInt(int defaultValue) { + return mode == Mode.VALUE ? parent.getInt(tag, defaultValue) : defaultValue; + } + + @Override + public int[] getInts() { + return mode == Mode.VALUE ? parent.getInts(tag) : null; + } + + @Override + public float getFloat(float defaultValue) { + return mode == Mode.VALUE ? parent.getFloat(tag, defaultValue) : defaultValue; + } + + @Override + public float[] getFloats() { + return mode == Mode.VALUE ? parent.getFloats(tag) : null; + } + + @Override + public double getDouble(double defaultValue) { + return mode == Mode.VALUE ? parent.getDouble(tag, defaultValue) : defaultValue; + } + + @Override + public double[] getDoubles() { + return mode == Mode.VALUE ? parent.getDoubles(tag) : null; + } + + @Override + public java.util.Date getDate() { + return mode == Mode.VALUE ? parent.getDate(tag) : null; + } + + @Override + public java.util.Date[] getDates() { + return mode == Mode.VALUE ? parent.getDates(tag) : null; + } + + @Override + public void addFragment(byte[] data) { + if (mode != Mode.FRAGMENTS) { + throw new UnsupportedOperationException("addFragment not supported in " + mode + " mode"); + } + fragments.add(data); + } + + @Override + public OieDicomObject getDicomObject() { + if (mode != Mode.SEQUENCE || sequence.isEmpty()) { + return null; + } + return new Dcm5DicomObject(sequence.get(0)); + } + + @Override + public OieDicomObject getDicomObject(int index) { + if (mode != Mode.SEQUENCE || index < 0 || index >= sequence.size()) { + return null; + } + return new Dcm5DicomObject(sequence.get(index)); + } + + @Override + public void addDicomObject(OieDicomObject obj) { + if (mode != Mode.SEQUENCE) { + throw new UnsupportedOperationException("addDicomObject not supported in " + mode + " mode"); + } + sequence.add((Attributes) obj.unwrap()); + } + + @Override + public Object unwrap() { + switch (mode) { + case SEQUENCE: + return sequence; + case FRAGMENTS: + return fragments; + default: + return parent; + } + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomObject.java b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomObject.java new file mode 100644 index 0000000000..f6c1a262db --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomObject.java @@ -0,0 +1,285 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom.dcm5; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.ElementDictionary; +import org.dcm4che3.data.Fragments; +import org.dcm4che3.data.Sequence; +import org.dcm4che3.data.VR; + +import com.mirth.connect.connectors.dimse.dicom.OieDicomElement; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; +import com.mirth.connect.connectors.dimse.dicom.OieVR; + +/** + * dcm4che5 implementation of OieDicomObject, wrapping Attributes. + * + *

Key difference from dcm2: File Meta Information is stored as a separate + * Attributes object (not embedded in the dataset). + */ +public class Dcm5DicomObject implements OieDicomObject { + + private final Attributes dataset; + private Attributes fmi; + + public Dcm5DicomObject() { + this(new Attributes()); + } + + public Dcm5DicomObject(Attributes dataset) { + this(null, dataset); + } + + public Dcm5DicomObject(Attributes fmi, Attributes dataset) { + this.fmi = fmi; + this.dataset = dataset != null ? dataset : new Attributes(); + } + + /** Package-visible accessor for Dcm5DicomConverter. */ + Attributes getFmi() { + return fmi; + } + + @Override + public String getString(int tag) { + return dataset.getString(tag); + } + + @Override + public int getInt(int tag) { + return dataset.getInt(tag, 0); + } + + @Override + public OieDicomElement get(int tag) { + if (!dataset.contains(tag)) { + return null; + } + VR vr = dataset.getVR(tag); + if (vr == VR.SQ) { + Sequence seq = dataset.getSequence(tag); + return seq != null ? new Dcm5DicomElement(tag, dataset, seq) : null; + } + Object value = dataset.getValue(tag); + if (value instanceof Fragments) { + return new Dcm5DicomElement(tag, dataset, (Fragments) value, vr.name()); + } + return new Dcm5DicomElement(tag, dataset); + } + + @Override + public boolean contains(int tag) { + return dataset.contains(tag); + } + + @Override + public int size() { + return dataset.size(); + } + + @Override + public boolean isEmpty() { + return dataset.isEmpty(); + } + + @Override + public byte[] getBytes(int tag) { + try { + return dataset.getBytes(tag); + } catch (java.io.IOException e) { + throw new RuntimeException("Unable to read bytes for tag " + Integer.toHexString(tag), e); + } + } + + @Override + public int[] getInts(int tag) { + return dataset.getInts(tag); + } + + @Override + public String[] getStrings(int tag) { + return dataset.getStrings(tag); + } + + @Override + public float getFloat(int tag, float defaultValue) { + return dataset.getFloat(tag, defaultValue); + } + + @Override + public float[] getFloats(int tag) { + return dataset.getFloats(tag); + } + + @Override + public double getDouble(int tag, double defaultValue) { + return dataset.getDouble(tag, defaultValue); + } + + @Override + public double[] getDoubles(int tag) { + return dataset.getDoubles(tag); + } + + @Override + public java.util.Date getDate(int tag) { + return dataset.getDate(tag); + } + + @Override + public java.util.Date[] getDates(int tag) { + return dataset.getDates(tag); + } + + @Override + public OieDicomObject getNestedDicomObject(int tag) { + Attributes nested = dataset.getNestedDataset(tag); + return nested != null ? new Dcm5DicomObject(nested) : null; + } + + @Override + public int vm(int tag) { + if (!dataset.contains(tag)) { + return 0; + } + String[] strings = dataset.getStrings(tag); + return strings != null ? strings.length : 1; + } + + @Override + public String vrOf(int tag) { + VR vr = ElementDictionary.vrOf(tag, dataset.getPrivateCreator(tag)); + return vr != null ? vr.name() : null; + } + + @Override + public String nameOf(int tag) { + return ElementDictionary.keywordOf(tag, dataset.getPrivateCreator(tag)); + } + + @Override + public void putString(int tag, String vr, String value) { + dataset.setString(tag, VR.valueOf(vr), value); + } + + @Override + public void putInt(int tag, String vr, int value) { + dataset.setInt(tag, VR.valueOf(vr), value); + } + + @Override + public void putBytes(int tag, String vr, byte[] value) { + dataset.setBytes(tag, VR.valueOf(vr), value); + } + + @Override + public OieDicomElement putSequence(int tag) { + Sequence seq = dataset.newSequence(tag, 0); + return new Dcm5DicomElement(tag, dataset, seq); + } + + @Override + public OieDicomElement putFragments(int tag, String vr, boolean bigEndian, int capacity) { + Fragments frags = dataset.newFragments(tag, VR.valueOf(vr), capacity); + return new Dcm5DicomElement(tag, dataset, frags, vr); + } + + @Override + public void add(OieDicomElement element) { + if (element instanceof Dcm5DicomElement) { + ((Dcm5DicomElement) element).copyTo(dataset); + } else { + // Cross-library element: extract via interface methods + int tag = element.tag(); + String vrName = String.valueOf(element.vr()); + if ("SQ".equals(vrName)) { + Sequence seq = dataset.newSequence(tag, element.countItems()); + for (int i = 0; i < element.countItems(); i++) { + OieDicomObject item = element.getDicomObject(i); + if (item != null) { + seq.add(new Attributes((Attributes) item.unwrap())); + } + } + } else if (element.hasItems()) { + Fragments frags = dataset.newFragments(tag, VR.valueOf(vrName), element.countItems()); + for (int i = 0; i < element.countItems(); i++) { + frags.add(element.getFragment(i)); + } + } else { + byte[] bytes = element.getBytes(); + if (bytes != null) { + dataset.setBytes(tag, VR.valueOf(vrName), bytes); + } + } + } + } + + @Override + public OieDicomElement remove(int tag) { + if (!dataset.contains(tag)) { + return null; + } + OieDicomElement removed = get(tag); + dataset.remove(tag); + return removed; + } + + @Override + public void clear() { + dataset.clear(); + } + + @Override + public boolean hasFileMetaInfo() { + return fmi != null && !fmi.isEmpty(); + } + + @Override + public void initFileMetaInformation(String cuid, String iuid, String tsuid) { + // dcm4che5 param order: (iuid, cuid, tsuid) — swapped from our interface + this.fmi = Attributes.createFileMetaInformation(iuid, cuid, tsuid); + } + + @Override + public boolean bigEndian() { + return dataset.bigEndian(); + } + + @Override + public Iterator commandIterator() { + final List commandElements = new ArrayList<>(); + try { + dataset.accept(new Attributes.Visitor() { + @Override + public boolean visit(Attributes attrs, int tag, VR vr, Object value) { + // Command group elements have tag group 0x0000 + if ((tag >>> 16) == 0x0000) { + commandElements.add(new Dcm5DicomElement(tag, attrs)); + } + return true; + } + }, false); + } catch (Exception e) { + // Visitor should not throw in practice + } + return Collections.unmodifiableList(commandElements).iterator(); + } + + @Override + public Object unwrap() { + return dataset; + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomReceiver.java b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomReceiver.java new file mode 100644 index 0000000000..cb850c063a --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomReceiver.java @@ -0,0 +1,513 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom.dcm5; + +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.io.IOUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.Tag; +import org.dcm4che3.data.UID; +import org.dcm4che3.data.VR; +import org.dcm4che3.io.DicomOutputStream; +import org.dcm4che3.net.ApplicationEntity; +import org.dcm4che3.net.Association; +import org.dcm4che3.net.Connection; +import org.dcm4che3.net.Device; +import org.dcm4che3.net.PDVInputStream; +import org.dcm4che3.net.Status; +import org.dcm4che3.net.TransferCapability; +import org.dcm4che3.net.pdu.PresentationContext; +import org.dcm4che3.net.pdu.UserIdentityRQ; +import org.dcm4che3.net.service.BasicCEchoSCP; +import org.dcm4che3.net.service.BasicCStoreSCP; +import org.dcm4che3.net.service.DicomServiceException; +import org.dcm4che3.net.service.DicomServiceRegistry; + +import com.mirth.connect.connectors.dimse.DICOMConfiguration; +import com.mirth.connect.connectors.dimse.dicom.DicomConstants; +import com.mirth.connect.connectors.dimse.dicom.OieDicomReceiver; +import com.mirth.connect.donkey.model.message.RawMessage; +import com.mirth.connect.donkey.server.channel.DispatchResult; +import com.mirth.connect.donkey.server.channel.SourceConnector; + +/** + * dcm4che5 implementation of OieDicomReceiver. Composes from Device + Connection + + * ApplicationEntity + BasicCStoreSCP instead of delegating to a monolithic DcmRcv tool class. + * + *

The {@code onCStoreRQ} handler populates the sourceMap with the exact same keys + * as MirthDcmRcv.onCStoreRQ to ensure behavioral parity. + */ +public class Dcm5DicomReceiver implements OieDicomReceiver { + + private static final Logger logger = LogManager.getLogger(Dcm5DicomReceiver.class); + + private final Device device; + private final Connection conn; + private final ApplicationEntity ae; + private final SourceConnector sourceConnector; + private final DICOMConfiguration dicomConfiguration; + + /** + * Default transfer syntaxes matching dcm4che2's NON_RETIRED_LE_TS. + * Registered for all SOP classes when no explicit restriction is configured. + */ + private static final String[] NON_RETIRED_LE_TS = { + DicomConstants.JPEG_LS_LOSSLESS, + DicomConstants.JPEG_LOSSLESS_SV1, + DicomConstants.JPEG_LOSSLESS_NH14, + DicomConstants.JPEG_2000_LOSSLESS, + DicomConstants.DEFLATED_EXPLICIT_VR_LITTLE_ENDIAN, + DicomConstants.RLE_LOSSLESS, + DicomConstants.EXPLICIT_VR_LITTLE_ENDIAN, + DicomConstants.IMPLICIT_VR_LITTLE_ENDIAN, + DicomConstants.JPEG_BASELINE, + DicomConstants.JPEG_EXTENDED, + DicomConstants.JPEG_LS_NEAR_LOSSLESS, + DicomConstants.JPEG_2000, + DicomConstants.MPEG2, + }; + + private String[] transferSyntax = NON_RETIRED_LE_TS; + private ExecutorService executor; + private ScheduledExecutorService scheduledExecutor; + + // TLS config stored for deferred initTLS() + private String keyStoreURL; + private String keyStorePassword; + private String keyPassword; + private String keyStoreType; + private String trustStoreURL; + private String trustStorePassword; + private String trustStoreType; + + public Dcm5DicomReceiver(SourceConnector sourceConnector, DICOMConfiguration configuration) { + this.sourceConnector = sourceConnector; + this.dicomConfiguration = configuration; + this.device = new Device("DCMRCV"); + + if (configuration instanceof Dcm5DICOMConfiguration) { + this.conn = ((Dcm5DICOMConfiguration) configuration).createDcm5Connection(); + } else { + Object custom = configuration.createNetworkConnection(); + this.conn = (custom instanceof Connection) ? (Connection) custom : new Connection(); + } + + device.addConnection(conn); + this.ae = new ApplicationEntity("*"); + ae.setAssociationAcceptor(true); + ae.addConnection(conn); + device.addApplicationEntity(ae); + + // Register DICOM service handlers + DicomServiceRegistry serviceRegistry = new DicomServiceRegistry(); + serviceRegistry.addDicomService(new BasicCStoreSCP("*") { + @Override + protected void store(Association as, PresentationContext pc, Attributes rq, + PDVInputStream data, Attributes rsp) throws IOException { + onCStoreRQ(as, pc, rq, data, rsp); + } + }); + serviceRegistry.addDicomService(new BasicCEchoSCP()); + device.setDimseRQHandler(serviceRegistry); + } + + /** + * Handles incoming C-STORE requests. Populates sourceMap with the same keys as + * MirthDcmRcv.onCStoreRQ for behavioral parity. + */ + private void onCStoreRQ(Association as, PresentationContext pc, Attributes rq, + PDVInputStream data, Attributes rsp) throws IOException { + String cuid = rq.getString(Tag.AffectedSOPClassUID); + String iuid = rq.getString(Tag.AffectedSOPInstanceUID); + String tsuid = pc.getTransferSyntax(); + + Attributes fmi = Attributes.createFileMetaInformation(iuid, cuid, tsuid); + + String originalThreadName = Thread.currentThread().getName(); + ByteArrayOutputStream baos = null; + DicomOutputStream dos = null; + + try { + Thread.currentThread().setName("DICOM Receiver Thread on " + sourceConnector.getChannel().getName() + + " (" + sourceConnector.getChannelId() + ") < " + originalThreadName); + + Map sourceMap = buildSourceMap(as); + sourceMap.putAll(dicomConfiguration.getCStoreRequestInformation(as)); + + // Write DICOM file bytes (FMI + data stream) + baos = new ByteArrayOutputStream(); + BufferedOutputStream bos = new BufferedOutputStream(baos); + dos = new DicomOutputStream(bos, UID.ExplicitVRLittleEndian); + dos.writeFileMetaInformation(fmi); + data.copyTo(dos); + dos.close(); + + byte[] dicomMessage = baos.toByteArray(); + baos = null; + + DispatchResult dispatchResult = null; + try { + dispatchResult = sourceConnector.dispatchRawMessage(new RawMessage(dicomMessage, null, sourceMap)); + + if (dispatchResult != null && dispatchResult.getSelectedResponse() != null + && dispatchResult.getSelectedResponse().getStatus() == com.mirth.connect.donkey.model.message.Status.ERROR) { + throw new DicomServiceException(Status.ProcessingFailure, + dispatchResult.getSelectedResponse().getStatusMessage()); + } + } finally { + sourceConnector.finishDispatch(dispatchResult); + } + } catch (Throwable t) { + logger.error("Error receiving DICOM message on channel " + sourceConnector.getChannelId(), t); + if (t instanceof DicomServiceException) { + throw (DicomServiceException) t; + } else { + throw new DicomServiceException(Status.ProcessingFailure, + "Error processing DICOM message: " + t.getMessage()); + } + } finally { + Thread.currentThread().setName(originalThreadName); + IOUtils.closeQuietly(baos); + } + } + + /** + * Builds the sourceMap from a DICOM association. Package-visible for testing. + * The keys MUST match MirthDcmRcv.onCStoreRQ exactly for behavioral parity. + */ + Map buildSourceMap(Association as) { + Map sourceMap = new HashMap(); + + sourceMap.put("localApplicationEntityTitle", as.getLocalAET()); + sourceMap.put("remoteApplicationEntityTitle", as.getRemoteAET()); + + if (as.getSocket() != null) { + sourceMap.put("localAddress", as.getSocket().getLocalAddress().getHostAddress()); + sourceMap.put("localPort", as.getSocket().getLocalPort()); + if (as.getSocket().getRemoteSocketAddress() instanceof InetSocketAddress) { + sourceMap.put("remoteAddress", ((InetSocketAddress) as.getSocket().getRemoteSocketAddress()).getAddress().getHostAddress()); + sourceMap.put("remotePort", ((InetSocketAddress) as.getSocket().getRemoteSocketAddress()).getPort()); + } + } + + if (as.getAAssociateAC() != null) { + sourceMap.put("associateACProtocolVersion", as.getAAssociateAC().getProtocolVersion()); + sourceMap.put("associateACImplClassUID", as.getAAssociateAC().getImplClassUID()); + sourceMap.put("associateACImplVersionName", as.getAAssociateAC().getImplVersionName()); + sourceMap.put("associateACApplicationContext", as.getAAssociateAC().getApplicationContext()); + + if (as.getAAssociateAC().getNumberOfPresentationContexts() > 0) { + Map pcMap = new LinkedHashMap(); + for (PresentationContext pctx : as.getAAssociateAC().getPresentationContexts()) { + pcMap.put(pctx.getPCID(), pctx.toString()); + } + sourceMap.put("associateACPresentationContexts", MapUtils.unmodifiableMap(pcMap)); + } + } + + if (as.getAAssociateRQ() != null) { + sourceMap.put("associateRQProtocolVersion", as.getAAssociateRQ().getProtocolVersion()); + sourceMap.put("associateRQImplClassUID", as.getAAssociateRQ().getImplClassUID()); + sourceMap.put("associateRQImplVersionName", as.getAAssociateRQ().getImplVersionName()); + sourceMap.put("associateRQApplicationContext", as.getAAssociateRQ().getApplicationContext()); + + if (as.getAAssociateRQ().getNumberOfPresentationContexts() > 0) { + Map pcMap = new LinkedHashMap(); + for (PresentationContext pctx : as.getAAssociateRQ().getPresentationContexts()) { + pcMap.put(pctx.getPCID(), pctx.toString()); + } + sourceMap.put("associateRQPresentationContexts", MapUtils.unmodifiableMap(pcMap)); + } + + if (as.getAAssociateRQ().getUserIdentityRQ() != null) { + UserIdentityRQ uid = as.getAAssociateRQ().getUserIdentityRQ(); + sourceMap.put("username", uid.getUsername()); + sourceMap.put("passcode", String.valueOf(uid.getPasscode())); + + int type = uid.getType(); + String typeString; + switch (type) { + case 1: typeString = "USERNAME"; break; + case 2: typeString = "USERNAME_PASSCODE"; break; + case 3: typeString = "KERBEROS"; break; + case 4: typeString = "SAML"; break; + default: typeString = String.valueOf(type); + } + sourceMap.put("userIdentityType", typeString); + } + } + + return sourceMap; + } + + @Override + public void setPort(int port) { + conn.setPort(port); + } + + @Override + public void setHostname(String hostname) { + conn.setHostname(hostname); + } + + @Override + public void setDestination(String destination) { + // Matches dcm4che2's de facto behavior: MirthDcmRcv streams DIMSE data directly + // to the channel and never consults DcmRcv.setDestination, so the UI flag has + // been a no-op on both backends. Only reached when the user explicitly set a + // non-blank value in the Listener's "Store Received Objects in Directory" field. + logger.warn("destination={} has no effect on either DICOM backend (the flag has been " + + "silently ignored upstream for years). Remove this setting to clear the warning.", + destination); + } + + @Override + public void setTransferSyntax(String[] transferSyntax) { + this.transferSyntax = transferSyntax; + } + + @Override + public void setAEtitle(String aeTitle) { + if (aeTitle != null && !aeTitle.isEmpty()) { + ae.setAETitle(aeTitle); + } + } + + @Override + public void setAssociationReaperPeriod(int period) { + // dcm4che5 manages association lifecycle via idle timeouts, not a reaper period. + // The closest equivalent is the connection idle timeout. + conn.setIdleTimeout(period); + } + + @Override + public void setIdleTimeout(int timeout) { + conn.setIdleTimeout(timeout); + } + + @Override + public void setRequestTimeout(int timeout) { + conn.setRequestTimeout(timeout); + } + + @Override + public void setReleaseTimeout(int timeout) { + conn.setReleaseTimeout(timeout); + } + + @Override + public void setSocketCloseDelay(int delay) { + conn.setSocketCloseDelay(delay); + } + + @Override + public void setDimseRspDelay(int delay) { + // dcm4che5 handles DIMSE response timing internally. + // No direct equivalent — response delay is not configurable. + logger.trace("setDimseRspDelay ignored in dcm5 receiver: " + delay); + } + + @Override + public void setMaxPDULengthReceive(int length) { + conn.setReceivePDULength(length); + } + + @Override + public void setMaxPDULengthSend(int length) { + conn.setSendPDULength(length); + } + + @Override + public void setSendBufferSize(int size) { + conn.setSendBufferSize(size); + } + + @Override + public void setReceiveBufferSize(int size) { + conn.setReceiveBufferSize(size); + } + + @Override + public void setFileBufferSize(int size) { + // dcm5 receiver streams directly to memory, with no file buffer concept. + // Only reached when the user explicitly changed bufSize from default, + // so warn instead of trace to surface the ignored setting. + logger.warn("bufSize={} has no effect on dcm4che5 receiver (dcm4che3 manages buffers internally). " + + "Revert dicom.library=dcm4che2 if this tuning is load-bearing.", size); + } + + @Override + public void setPackPDV(boolean packPDV) { + conn.setPackPDV(packPDV); + } + + @Override + public void setTcpNoDelay(boolean tcpNoDelay) { + conn.setTcpNoDelay(tcpNoDelay); + } + + @Override + public void setMaxOpsPerformed(int maxOps) { + conn.setMaxOpsPerformed(maxOps); + } + + @Override + public void setTlsWithoutEncryption() { + conn.setTlsCipherSuites("SSL_RSA_WITH_NULL_SHA"); + } + + @Override + public void setTls3DES_EDE_CBC() { + conn.setTlsCipherSuites("SSL_RSA_WITH_3DES_EDE_CBC_SHA"); + } + + @Override + public void setTlsAES_128_CBC() { + conn.setTlsCipherSuites("TLS_RSA_WITH_AES_128_CBC_SHA"); + } + + @Override + public void setTlsCipherSuites(String[] cipherSuites) { + conn.setTlsCipherSuites(cipherSuites); + } + + @Override + public void setTrustStoreURL(String url) { + this.trustStoreURL = url; + } + + @Override + public void setTrustStorePassword(String password) { + this.trustStorePassword = password; + } + + @Override + public void setKeyPassword(String password) { + this.keyPassword = password; + } + + @Override + public void setKeyStoreURL(String url) { + this.keyStoreURL = url; + } + + @Override + public void setKeyStorePassword(String password) { + this.keyStorePassword = password; + } + + @Override + public void setKeyStoreType(String type) { + this.keyStoreType = type; + } + + @Override + public void setTrustStoreType(String type) { + this.trustStoreType = type; + } + + @Override + public void setTlsNeedClientAuth(boolean needClientAuth) { + conn.setTlsNeedClientAuth(needClientAuth); + } + + @Override + public void setTlsProtocol(String[] protocols) { + conn.setTlsProtocols(protocols); + } + + @Override + public void initTLS() throws Exception { + if (keyStoreURL != null) { + device.setKeyStoreURL(keyStoreURL); + device.setKeyStoreType(keyStoreType != null ? keyStoreType : Dcm5TlsUtil.inferStoreType(keyStoreURL)); + if (keyStorePassword != null) { + device.setKeyStorePin(keyStorePassword); + } + if (keyPassword != null) { + device.setKeyStoreKeyPin(keyPassword); + } + } + if (trustStoreURL != null) { + device.setTrustStoreURL(trustStoreURL); + device.setTrustStoreType(trustStoreType != null ? trustStoreType : Dcm5TlsUtil.inferStoreType(trustStoreURL)); + if (trustStorePassword != null) { + device.setTrustStorePin(trustStorePassword); + } + } + } + + @Override + public void initTransferCapability() { + if (transferSyntax != null && transferSyntax.length > 0) { + // All transfer syntaxes must be in a single TransferCapability for the wildcard + // abstract syntax, because ApplicationEntity stores TCs in a map keyed by abstract + // syntax — multiple adds with "*" would overwrite each other. + ae.addTransferCapability( + new TransferCapability(null, "*", TransferCapability.Role.SCP, transferSyntax)); + } + // Add verification SOP class + ae.addTransferCapability( + new TransferCapability(null, UID.Verification, TransferCapability.Role.SCP, UID.ImplicitVRLittleEndian)); + } + + @Override + public void start() throws Exception { + executor = Executors.newCachedThreadPool(); + scheduledExecutor = Executors.newSingleThreadScheduledExecutor(); + device.setExecutor(executor); + device.setScheduledExecutor(scheduledExecutor); + device.bindConnections(); + } + + private static final int SHUTDOWN_TIMEOUT_MS = 5000; + + @Override + public void stop() { + device.unbindConnections(); + if (executor != null) { + executor.shutdown(); + } + if (scheduledExecutor != null) { + scheduledExecutor.shutdown(); + } + try { + if (executor != null && !executor.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + executor.shutdownNow(); + } + if (scheduledExecutor != null && !scheduledExecutor.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + scheduledExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + if (executor != null) executor.shutdownNow(); + if (scheduledExecutor != null) scheduledExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + @Override + public Object unwrap() { + return device; + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomSender.java b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomSender.java new file mode 100644 index 0000000000..53d74160ef --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomSender.java @@ -0,0 +1,589 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom.dcm5; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.apache.commons.io.IOUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.Tag; +import org.dcm4che3.data.UID; +import org.dcm4che3.data.VR; +import org.dcm4che3.io.DicomInputStream; +import org.dcm4che3.util.UIDUtils; +import org.dcm4che3.net.ApplicationEntity; +import org.dcm4che3.net.Association; +import org.dcm4che3.net.Commands; +import org.dcm4che3.net.Connection; +import org.dcm4che3.net.DataWriterAdapter; +import org.dcm4che3.net.Device; +import org.dcm4che3.net.DimseRSPHandler; +import org.dcm4che3.net.IncompatibleConnectionException; +import org.dcm4che3.net.Status; +import org.dcm4che3.net.TransferCapability; +import org.dcm4che3.net.pdu.AAssociateRQ; +import org.dcm4che3.net.pdu.PresentationContext; +import org.dcm4che3.net.pdu.UserIdentityRQ; + +import com.mirth.connect.connectors.dimse.DICOMConfiguration; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; +import com.mirth.connect.connectors.dimse.dicom.OieDicomSender; +import com.mirth.connect.connectors.dimse.dicom.OieDimseRspHandler; + +/** + * dcm4che5 implementation of OieDicomSender. Composes from Device + Connection + + * ApplicationEntity + Association instead of delegating to a monolithic DcmSnd tool class. + */ +public class Dcm5DicomSender implements OieDicomSender { + + private static final Logger logger = LogManager.getLogger(Dcm5DicomSender.class); + + private final DICOMConfiguration configuration; + private final Device device; + private final Connection localConn; + private final Connection remoteConn; + private final ApplicationEntity localAE; + private final ApplicationEntity remoteAE; + private Association association; + + private final List files = new ArrayList<>(); + private final Map> sopClassToTsMap = new HashMap<>(); + private boolean storageCommitment; + private int priority = 0; + private boolean offerDefaultTsInSeparatePC = false; + private UserIdentityRQ userIdentityRQ; + private int shutdownDelay = 1000; + + private ExecutorService executor; + private ScheduledExecutorService scheduledExecutor; + + // TLS config stored for deferred initTLS() + private String keyStoreURL; + private String keyStorePassword; + private String keyPassword; + private String keyStoreType; + private String trustStoreURL; + private String trustStorePassword; + private String trustStoreType; + + // Storage commitment result (set by N-EVENT-REPORT handler) + private volatile Attributes stgCmtResult; + + /** Tracks sent file metadata for storage commitment. */ + private static final class FileInfo { + final File file; + String cuid; + String iuid; + String tsuid; + boolean transferred; + + FileInfo(File file) { + this.file = file; + } + } + + public Dcm5DicomSender(DICOMConfiguration configuration) { + this.configuration = configuration; + this.device = new Device("DCMSND"); + + if (configuration instanceof Dcm5DICOMConfiguration) { + this.localConn = ((Dcm5DICOMConfiguration) configuration).createDcm5Connection(); + } else { + Object custom = configuration.createNetworkConnection(); + this.localConn = (custom instanceof Connection) ? (Connection) custom : new Connection(); + } + + this.remoteConn = new Connection(); + this.localAE = new ApplicationEntity("DCMSND"); + this.remoteAE = new ApplicationEntity(); + + device.addConnection(localConn); + localAE.addConnection(localConn); + device.addApplicationEntity(localAE); + } + + @Override + public void setCalledAET(String aet) { + remoteAE.setAETitle(aet); + } + + @Override + public void setRemoteHost(String host) { + remoteConn.setHostname(host); + } + + @Override + public void setRemotePort(int port) { + remoteConn.setPort(port); + } + + @Override + public void setCalling(String aet) { + localAE.setAETitle(aet); + } + + @Override + public void setLocalHost(String host) { + localConn.setHostname(host); + } + + @Override + public void setLocalPort(int port) { + localConn.setPort(port); + } + + @Override + public void addFile(File file) { + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + addFile(child); + } + } + } else { + FileInfo info = new FileInfo(file); + files.add(info); + // Scan file for SOP Class UID and Transfer Syntax UID + DicomInputStream dis = null; + try { + dis = new DicomInputStream(file); + Attributes fmi = dis.readFileMetaInformation(); + if (fmi != null) { + info.cuid = fmi.getString(Tag.MediaStorageSOPClassUID); + info.iuid = fmi.getString(Tag.MediaStorageSOPInstanceUID); + info.tsuid = fmi.getString(Tag.TransferSyntaxUID); + if (info.cuid != null && info.tsuid != null) { + sopClassToTsMap.computeIfAbsent(info.cuid, k -> new HashSet<>()).add(info.tsuid); + } + } + } catch (Exception e) { + logger.trace("Could not read DICOM file meta info: " + file, e); + } finally { + IOUtils.closeQuietly(dis); + } + } + } + + @Override + public void setAcceptTimeout(int timeout) { + localConn.setAcceptTimeout(timeout); + } + + @Override + public void setMaxOpsInvoked(int maxOps) { + localConn.setMaxOpsInvoked(maxOps); + } + + @Override + public void setTranscoderBufferSize(int size) { + // dcm4che5 has no transcoder buffer — transcoding is handled internally via DataWriterAdapter. + // Only reached when the user explicitly changed bufSize from default, so warn instead of + // trace to surface the ignored setting. + logger.warn("bufSize={} has no effect on dcm4che5 sender (dcm4che3 manages transcoder buffers internally). " + + "Revert dicom.library=dcm4che2 if this tuning is load-bearing.", size); + } + + @Override + public void setConnectTimeout(int timeout) { + localConn.setConnectTimeout(timeout); + } + + @Override + public void setPriority(int priority) { + this.priority = priority; + } + + @Override + public void setPackPDV(boolean packPDV) { + localConn.setPackPDV(packPDV); + } + + @Override + public void setMaxPDULengthReceive(int length) { + localConn.setReceivePDULength(length); + } + + @Override + public void setMaxPDULengthSend(int length) { + localConn.setSendPDULength(length); + } + + @Override + public void setReceiveBufferSize(int size) { + localConn.setReceiveBufferSize(size); + } + + @Override + public void setSendBufferSize(int size) { + localConn.setSendBufferSize(size); + } + + @Override + public void setAssociationReaperPeriod(int period) { + // dcm4che5 manages association lifecycle via idle timeouts, not a reaper period. + localConn.setIdleTimeout(period); + } + + @Override + public void setReleaseTimeout(int timeout) { + localConn.setReleaseTimeout(timeout); + } + + @Override + public void setDimseRspTimeout(int timeout) { + localConn.setResponseTimeout(timeout); + } + + @Override + public void setShutdownDelay(int delay) { + this.shutdownDelay = delay; + } + + @Override + public void setSocketCloseDelay(int delay) { + localConn.setSocketCloseDelay(delay); + } + + @Override + public void setTcpNoDelay(boolean tcpNoDelay) { + localConn.setTcpNoDelay(tcpNoDelay); + } + + @Override + public void setOfferDefaultTransferSyntaxInSeparatePresentationContext(boolean ts1) { + this.offerDefaultTsInSeparatePC = ts1; + } + + @Override + public void setStorageCommitment(boolean stgcmt) { + this.storageCommitment = stgcmt; + } + + @Override + public void setUserIdentity(String username, String passcode, boolean positiveResponseRequested) { + if (passcode != null && !passcode.isEmpty()) { + userIdentityRQ = UserIdentityRQ.usernamePasscode(username, passcode.toCharArray(), positiveResponseRequested); + } else { + userIdentityRQ = UserIdentityRQ.username(username, positiveResponseRequested); + } + } + + @Override + public void setTlsWithoutEncryption() { + localConn.setTlsCipherSuites("SSL_RSA_WITH_NULL_SHA"); + remoteConn.setTlsCipherSuites("SSL_RSA_WITH_NULL_SHA"); + } + + @Override + public void setTls3DES_EDE_CBC() { + localConn.setTlsCipherSuites("SSL_RSA_WITH_3DES_EDE_CBC_SHA"); + remoteConn.setTlsCipherSuites("SSL_RSA_WITH_3DES_EDE_CBC_SHA"); + } + + @Override + public void setTlsAES_128_CBC() { + localConn.setTlsCipherSuites("TLS_RSA_WITH_AES_128_CBC_SHA"); + remoteConn.setTlsCipherSuites("TLS_RSA_WITH_AES_128_CBC_SHA"); + } + + @Override + public void setTlsCipherSuites(String[] cipherSuites) { + localConn.setTlsCipherSuites(cipherSuites); + remoteConn.setTlsCipherSuites(cipherSuites); + } + + @Override + public void setTrustStoreURL(String url) { + this.trustStoreURL = url; + } + + @Override + public void setTrustStorePassword(String password) { + this.trustStorePassword = password; + } + + @Override + public void setKeyPassword(String password) { + this.keyPassword = password; + } + + @Override + public void setKeyStoreURL(String url) { + this.keyStoreURL = url; + } + + @Override + public void setKeyStorePassword(String password) { + this.keyStorePassword = password; + } + + @Override + public void setKeyStoreType(String type) { + this.keyStoreType = type; + } + + @Override + public void setTrustStoreType(String type) { + this.trustStoreType = type; + } + + @Override + public void setTlsNeedClientAuth(boolean needClientAuth) { + localConn.setTlsNeedClientAuth(needClientAuth); + } + + @Override + public void setTlsProtocol(String[] protocols) { + localConn.setTlsProtocols(protocols); + remoteConn.setTlsProtocols(protocols); + } + + @Override + public void initTLS() throws Exception { + if (keyStoreURL != null) { + device.setKeyStoreURL(keyStoreURL); + device.setKeyStoreType(keyStoreType != null ? keyStoreType : Dcm5TlsUtil.inferStoreType(keyStoreURL)); + if (keyStorePassword != null) { + device.setKeyStorePin(keyStorePassword); + } + if (keyPassword != null) { + device.setKeyStoreKeyPin(keyPassword); + } + } + if (trustStoreURL != null) { + device.setTrustStoreURL(trustStoreURL); + device.setTrustStoreType(trustStoreType != null ? trustStoreType : Dcm5TlsUtil.inferStoreType(trustStoreURL)); + if (trustStorePassword != null) { + device.setTrustStorePin(trustStorePassword); + } + } + } + + @Override + public void configureTransferCapability() { + for (Map.Entry> entry : sopClassToTsMap.entrySet()) { + String[] tsArray = entry.getValue().toArray(new String[0]); + localAE.addTransferCapability( + new TransferCapability(null, entry.getKey(), TransferCapability.Role.SCU, tsArray)); + + if (offerDefaultTsInSeparatePC && !entry.getValue().contains(UID.ImplicitVRLittleEndian)) { + localAE.addTransferCapability( + new TransferCapability(null, entry.getKey(), TransferCapability.Role.SCU, UID.ImplicitVRLittleEndian)); + } + } + } + + @Override + public void start() throws IOException { + executor = Executors.newCachedThreadPool(); + scheduledExecutor = Executors.newSingleThreadScheduledExecutor(); + device.setExecutor(executor); + device.setScheduledExecutor(scheduledExecutor); + + // Register N-EVENT-REPORT handler for storage commitment responses + if (storageCommitment) { + org.dcm4che3.net.service.DicomServiceRegistry serviceRegistry = new org.dcm4che3.net.service.DicomServiceRegistry(); + serviceRegistry.addDicomService(new org.dcm4che3.net.service.AbstractDicomService(UID.StorageCommitmentPushModel) { + @Override + public void onDimseRQ(Association as, PresentationContext pc, + org.dcm4che3.net.Dimse dimse, Attributes rq, Attributes data) + throws IOException { + if (dimse == org.dcm4che3.net.Dimse.N_EVENT_REPORT_RQ) { + // Send success response + Attributes rsp = org.dcm4che3.net.Commands.mkNEventReportRSP(rq, Status.Success); + as.tryWriteDimseRSP(pc, rsp); + // Notify waitForStgCmtResult + onNEventReportRSP(data); + } + } + }); + device.setDimseRQHandler(serviceRegistry); + localAE.setAssociationAcceptor(true); + } + } + + @Override + public void open() throws Exception { + AAssociateRQ aarq = new AAssociateRQ(); + aarq.setCalledAET(remoteAE.getAETitle()); + aarq.setCallingAET(localAE.getAETitle()); + + if (userIdentityRQ != null) { + aarq.setUserIdentityRQ(userIdentityRQ); + } + + // Add presentation contexts from transfer capabilities + int pcid = 1; + for (Map.Entry> entry : sopClassToTsMap.entrySet()) { + String[] tsArray = entry.getValue().toArray(new String[0]); + aarq.addPresentationContext(new PresentationContext(pcid, entry.getKey(), tsArray)); + pcid += 2; + + if (offerDefaultTsInSeparatePC && !entry.getValue().contains(UID.ImplicitVRLittleEndian)) { + aarq.addPresentationContext(new PresentationContext(pcid, entry.getKey(), UID.ImplicitVRLittleEndian)); + pcid += 2; + } + } + + // Add storage commitment if enabled + if (storageCommitment) { + aarq.addPresentationContext(new PresentationContext(pcid, UID.StorageCommitmentPushModel, UID.ImplicitVRLittleEndian)); + } + + try { + association = localAE.connect(localConn, remoteConn, aarq); + } catch (IncompatibleConnectionException e) { + throw new IOException("Failed to open DICOM association", e); + } + } + + @Override + public void send(OieDimseRspHandler handler) throws Exception { + for (FileInfo info : files) { + DicomInputStream dis = null; + try { + dis = new DicomInputStream(info.file); + Attributes fmi = dis.readFileMetaInformation(); + Attributes dataset = dis.readDataset(-1, -1); + + String cuid = fmi.getString(Tag.MediaStorageSOPClassUID); + String iuid = fmi.getString(Tag.MediaStorageSOPInstanceUID); + String tsuid = fmi.getString(Tag.TransferSyntaxUID); + + DimseRSPHandler rspHandler = new DimseRSPHandler(association.nextMessageID()) { + @Override + public void onDimseRSP(Association as, Attributes cmd, Attributes data) { + super.onDimseRSP(as, cmd, data); + OieDicomObject wrappedCmd = cmd != null ? new Dcm5DicomObject(cmd) : null; + OieDicomObject wrappedData = data != null ? new Dcm5DicomObject(data) : null; + handler.onDimseRSP(wrappedCmd, wrappedData); + } + }; + + association.cstore(cuid, iuid, priority, new DataWriterAdapter(dataset), tsuid, rspHandler); + info.transferred = true; + } finally { + IOUtils.closeQuietly(dis); + } + } + } + + @Override + public boolean isStorageCommitment() { + return storageCommitment; + } + + @Override + public boolean commit() throws Exception { + if (!storageCommitment || association == null) { + return false; + } + + Attributes actionInfo = new Attributes(); + actionInfo.setString(Tag.TransactionUID, VR.UI, UIDUtils.createUID()); + + // Build reference SOP sequence from successfully transferred files + org.dcm4che3.data.Sequence refSOPSeq = actionInfo.newSequence(Tag.ReferencedSOPSequence, files.size()); + for (FileInfo info : files) { + if (info.transferred && info.cuid != null && info.iuid != null) { + Attributes refSOP = new Attributes(2); + refSOP.setString(Tag.ReferencedSOPClassUID, VR.UI, info.cuid); + refSOP.setString(Tag.ReferencedSOPInstanceUID, VR.UI, info.iuid); + refSOPSeq.add(refSOP); + } + } + + try { + stgCmtResult = null; + org.dcm4che3.net.DimseRSP rsp = association.naction( + UID.StorageCommitmentPushModel, + UID.StorageCommitmentPushModelInstance, + 1, actionInfo, UID.ImplicitVRLittleEndian); + rsp.next(); + Attributes cmd = rsp.getCommand(); + int status = cmd.getInt(Tag.Status, -1); + return status == Status.Success; + } catch (Exception e) { + logger.error("Failed to send Storage Commitment request", e); + return false; + } + } + + @Override + public synchronized OieDicomObject waitForStgCmtResult() throws InterruptedException { + while (stgCmtResult == null) { + wait(); + } + return new Dcm5DicomObject(stgCmtResult); + } + + /** + * Called when the remote SCP sends an N-EVENT-REPORT with the storage commitment result. + * Sets the result and wakes up any thread waiting in waitForStgCmtResult(). + */ + synchronized void onNEventReportRSP(Attributes info) { + stgCmtResult = info; + notifyAll(); + } + + @Override + public void close() { + if (association != null) { + try { + association.release(); + } catch (IOException e) { + logger.trace("Error releasing association", e); + } + association = null; + } + } + + @Override + public void stop() { + device.unbindConnections(); + if (executor != null) { + executor.shutdown(); + } + if (scheduledExecutor != null) { + scheduledExecutor.shutdown(); + } + try { + if (executor != null && !executor.awaitTermination(shutdownDelay, TimeUnit.MILLISECONDS)) { + executor.shutdownNow(); + } + if (scheduledExecutor != null && !scheduledExecutor.awaitTermination(shutdownDelay, TimeUnit.MILLISECONDS)) { + scheduledExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + if (executor != null) executor.shutdownNow(); + if (scheduledExecutor != null) scheduledExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + @Override + public Object unwrap() { + return device; + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5TlsUtil.java b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5TlsUtil.java new file mode 100644 index 0000000000..c00fad7ac2 --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5TlsUtil.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom.dcm5; + +/** + * Shared TLS utilities for dcm5 sender and receiver. + */ +final class Dcm5TlsUtil { + + private Dcm5TlsUtil() {} + + /** + * Infers keystore/truststore type from a URL's file extension. + * Returns "PKCS12" for .p12/.pfx files, "JKS" otherwise. + */ + static String inferStoreType(String url) { + if (url != null) { + String lower = url.toLowerCase(); + if (lower.endsWith(".p12") || lower.endsWith(".pfx")) { + return "PKCS12"; + } + } + return "JKS"; + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5VR.java b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5VR.java new file mode 100644 index 0000000000..0df3cbfb4d --- /dev/null +++ b/server/src/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5VR.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.connectors.dimse.dicom.dcm5; + +import org.dcm4che3.data.VR; + +import com.mirth.connect.connectors.dimse.dicom.OieVR; + +public class Dcm5VR implements OieVR { + + private final VR vr; + + public Dcm5VR(VR vr) { + this.vr = vr; + } + + @Override + public String toString() { + return vr.toString(); + } + + @Override + public int code() { + return vr.code(); + } + + @Override + public int padding() { + return vr.paddingByte(); + } + + @Override + public Object unwrap() { + return vr; + } +} diff --git a/server/src/com/mirth/connect/connectors/dimse/source.xml b/server/src/com/mirth/connect/connectors/dimse/source.xml index 312120e1ae..fb9024517d 100644 --- a/server/src/com/mirth/connect/connectors/dimse/source.xml +++ b/server/src/com/mirth/connect/connectors/dimse/source.xml @@ -10,13 +10,15 @@ com.mirth.connect.connectors.dimse.DICOMReceiverProperties - - - - - + + + + + + + dicom SOURCE diff --git a/server/src/com/mirth/connect/model/ExtensionLibrary.java b/server/src/com/mirth/connect/model/ExtensionLibrary.java index 7e48b83516..85a3ff87b0 100644 --- a/server/src/com/mirth/connect/model/ExtensionLibrary.java +++ b/server/src/com/mirth/connect/model/ExtensionLibrary.java @@ -28,6 +28,9 @@ public enum Type { @XStreamAsAttribute private Type type; + @XStreamAsAttribute + private String variant; + public Type getType() { return type; } @@ -44,6 +47,14 @@ public void setPath(String path) { this.path = path; } + public String getVariant() { + return variant; + } + + public void setVariant(String variant) { + this.variant = variant; + } + @Override public String toString() { return ToStringBuilder.reflectionToString(this, CalendarToStringStyle.instance()); diff --git a/server/src/com/mirth/connect/model/converters/DICOMConverter.java b/server/src/com/mirth/connect/model/converters/DICOMConverter.java index ee30ece3f1..3c83d4a303 100644 --- a/server/src/com/mirth/connect/model/converters/DICOMConverter.java +++ b/server/src/com/mirth/connect/model/converters/DICOMConverter.java @@ -1,116 +1,31 @@ /* * Copyright (c) Mirth Corporation. All rights reserved. - * + * * http://www.mirthcorp.com - * + * * The software in this package is published under the terms of the MPL license a copy of which has * been included with this distribution in the LICENSE.txt file. */ package com.mirth.connect.model.converters; -import java.io.BufferedInputStream; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.InputStream; -import org.apache.commons.codec.binary.Base64InputStream; -import org.apache.commons.io.IOUtils; -import org.dcm4che2.data.BasicDicomObject; -import org.dcm4che2.data.DicomObject; -import org.dcm4che2.data.TransferSyntax; -import org.dcm4che2.io.DicomInputStream; -import org.dcm4che2.io.DicomOutputStream; - -import com.mirth.connect.donkey.util.ByteCounterOutputStream; +import com.mirth.connect.connectors.dimse.dicom.DicomLibraryFactory; +import com.mirth.connect.connectors.dimse.dicom.OieDicomConverter; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; public class DICOMConverter { - public static DicomObject byteArrayToDicomObject(byte[] bytes, boolean decodeBase64) throws IOException { - DicomObject basicDicomObject = new BasicDicomObject(); - DicomInputStream dis = null; - - try { - ByteArrayInputStream bais = new ByteArrayInputStream(bytes); - InputStream inputStream; - if (decodeBase64) { - inputStream = new BufferedInputStream(new Base64InputStream(bais)); - } else { - inputStream = bais; - } - dis = new DicomInputStream(inputStream); - /* - * This parameter was added in dcm4che 2.0.28. We use it to retain the memory allocation - * behavior from 2.0.25. http://www.mirthcorp.com/community/issues/browse/MIRTH-2166 - * http://www.dcm4che.org/jira/browse/DCM-554 - */ - dis.setAllocateLimit(-1); - dis.readDicomObject(basicDicomObject, -1); - } catch (IOException e) { - throw e; - } finally { - IOUtils.closeQuietly(dis); - } - - return basicDicomObject; + private static OieDicomConverter getConverter() { + return DicomLibraryFactory.getConverter(); } - public static byte[] dicomObjectToByteArray(DicomObject dicomObject) throws IOException { - BasicDicomObject basicDicomObject = (BasicDicomObject) dicomObject; - DicomOutputStream dos = null; - - try { - ByteCounterOutputStream bcos = new ByteCounterOutputStream(); - ByteArrayOutputStream baos; - - if (basicDicomObject.fileMetaInfo().isEmpty()) { - try { - // Create a dicom output stream with the byte counter output stream. - dos = new DicomOutputStream(bcos); - // "Write" the dataset once to determine the total number of bytes required. This is fast because no data is actually being copied. - dos.writeDataset(basicDicomObject, TransferSyntax.ImplicitVRLittleEndian); - } finally { - IOUtils.closeQuietly(dos); - } - - // Create the actual byte array output stream with a buffer size equal to the number of bytes required. - baos = new ByteArrayOutputStream(bcos.size()); - // Create a dicom output stream with the byte array output stream - dos = new DicomOutputStream(baos); - - // Create ACR/NEMA Dump - dos.writeDataset(basicDicomObject, TransferSyntax.ImplicitVRLittleEndian); - } else { - try { - // Create a dicom output stream with the byte counter output stream. - dos = new DicomOutputStream(bcos); - // "Write" the dataset once to determine the total number of bytes required. This is fast because no data is actually being copied. - dos.writeDicomFile(basicDicomObject); - } finally { - IOUtils.closeQuietly(dos); - } - - // Create the actual byte array output stream with a buffer size equal to the number of bytes required. - baos = new ByteArrayOutputStream(bcos.size()); - // Create a dicom output stream with the byte array output stream - dos = new DicomOutputStream(baos); - - // Create DICOM File - dos.writeDicomFile(basicDicomObject); - } - - // Memory Optimization since the dicom object is no longer needed at this point. - dicomObject.clear(); + public static OieDicomObject byteArrayToDicomObject(byte[] bytes, boolean decodeBase64) throws IOException { + return getConverter().byteArrayToDicomObject(bytes, decodeBase64); + } - return baos.toByteArray(); - } catch (IOException e) { - throw e; - } catch (Throwable t) { - t.printStackTrace(); - return null; - } finally { - IOUtils.closeQuietly(dos); - } + public static byte[] dicomObjectToByteArray(OieDicomObject dicomObject) throws IOException { + return getConverter().dicomObjectToByteArray(dicomObject); } } diff --git a/server/src/com/mirth/connect/plugins/datatypes/dicom/DICOMReference.java b/server/src/com/mirth/connect/plugins/datatypes/dicom/DICOMReference.java index 716b863eef..56678404c4 100644 --- a/server/src/com/mirth/connect/plugins/datatypes/dicom/DICOMReference.java +++ b/server/src/com/mirth/connect/plugins/datatypes/dicom/DICOMReference.java @@ -1,22 +1,23 @@ /* * Copyright (c) Mirth Corporation. All rights reserved. - * + * * http://www.mirthcorp.com - * + * * The software in this package is published under the terms of the MPL license a copy of which has * been included with this distribution in the LICENSE.txt file. */ package com.mirth.connect.plugins.datatypes.dicom; -import org.dcm4che2.data.ElementDictionary; +import com.mirth.connect.connectors.dimse.dicom.DicomLibraryFactory; +import com.mirth.connect.connectors.dimse.dicom.OieDicomConverter; public class DICOMReference { private static DICOMReference instance = null; - private ElementDictionary elementDictionary = null; + private OieDicomConverter converter = null; private DICOMReference() { - elementDictionary = ElementDictionary.getDictionary(); + converter = DicomLibraryFactory.getConverter(); } public static DICOMReference getInstance() { @@ -30,7 +31,7 @@ public static DICOMReference getInstance() { public String getDescription(String key, String version) { if (key != null && !key.equals("")) { try { - return elementDictionary.nameOf(Integer.decode("0x" + key).intValue()); + return converter.getElementName(Integer.decode("0x" + key).intValue()); } catch (NumberFormatException e) { return ""; } diff --git a/server/src/com/mirth/connect/plugins/datatypes/dicom/DICOMSerializer.java b/server/src/com/mirth/connect/plugins/datatypes/dicom/DICOMSerializer.java index 9b16c4bbb2..42ebf882e7 100644 --- a/server/src/com/mirth/connect/plugins/datatypes/dicom/DICOMSerializer.java +++ b/server/src/com/mirth/connect/plugins/datatypes/dicom/DICOMSerializer.java @@ -1,47 +1,30 @@ /* * Copyright (c) Mirth Corporation. All rights reserved. - * + * * http://www.mirthcorp.com - * + * * The software in this package is published under the terms of the MPL license a copy of which has * been included with this distribution in the LICENSE.txt file. */ package com.mirth.connect.plugins.datatypes.dicom; -import java.io.BufferedInputStream; -import java.io.ByteArrayInputStream; import java.io.IOException; -import java.io.StringWriter; import java.util.HashMap; import java.util.Map; -import javax.xml.XMLConstants; -import javax.xml.parsers.SAXParser; -import javax.xml.parsers.SAXParserFactory; -import javax.xml.transform.OutputKeys; -import javax.xml.transform.TransformerFactory; -import javax.xml.transform.sax.SAXTransformerFactory; -import javax.xml.transform.sax.TransformerHandler; -import javax.xml.transform.stream.StreamResult; - -import org.apache.commons.codec.binary.Base64InputStream; import org.apache.commons.codec.binary.StringUtils; -import org.apache.commons.io.IOUtils; -import org.dcm4che2.data.BasicDicomObject; -import org.dcm4che2.data.DicomObject; -import org.dcm4che2.data.Tag; -import org.dcm4che2.io.ContentHandlerAdapter; -import org.dcm4che2.io.DicomInputStream; -import org.dcm4che2.io.SAXWriter; import org.w3c.dom.DOMException; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NamedNodeMap; import org.w3c.dom.Node; import org.w3c.dom.NodeList; -import org.xml.sax.InputSource; +import com.mirth.connect.connectors.dimse.dicom.DicomConstants; +import com.mirth.connect.connectors.dimse.dicom.DicomLibraryFactory; +import com.mirth.connect.connectors.dimse.dicom.OieDicomConverter; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; import com.mirth.connect.donkey.model.message.MessageSerializer; import com.mirth.connect.donkey.model.message.MessageSerializerException; import com.mirth.connect.donkey.util.Base64Util; @@ -68,10 +51,11 @@ public static Map getDefaultProperties() { } public static byte[] removePixelData(byte[] content) throws IOException { - DicomObject dicomObject = DICOMConverter.byteArrayToDicomObject(content, false); - dicomObject.remove(Tag.PixelData); + OieDicomConverter converter = DicomLibraryFactory.getConverter(); + OieDicomObject dicomObject = converter.byteArrayToDicomObject(content, false); + dicomObject.remove(DicomConstants.TAG_PIXEL_DATA); - return DICOMConverter.dicomObjectToByteArray(dicomObject); + return converter.dicomObjectToByteArray(dicomObject); } @Override @@ -132,14 +116,10 @@ public String fromXML(String source) throws MessageSerializerException { charset = "utf-8"; } - // parse the Document into a DicomObject - SAXParserFactory factory = SAXParserFactory.newInstance(); - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); - SAXParser parser = factory.newSAXParser(); - DicomObject dicomObject = new BasicDicomObject(); - ContentHandlerAdapter contentHandler = new ContentHandlerAdapter(dicomObject); - byte[] documentBytes = documentSerializer.toXML(document).trim().getBytes(charset); - parser.parse(new InputSource(new ByteArrayInputStream(documentBytes)), contentHandler); + // parse the Document into a DicomObject via the converter + OieDicomConverter converter = DicomLibraryFactory.getConverter(); + String xmlString = documentSerializer.toXML(document).trim(); + OieDicomObject dicomObject = converter.xmlToDicomObject(xmlString, charset); return StringUtils.newStringUsAscii(Base64Util.encodeBase64(DICOMConverter.dicomObjectToByteArray(dicomObject))); } catch (Exception e) { throw new MessageSerializerException("Error converting XML to DICOM", e, ErrorMessageBuilder.buildErrorMessage(this.getClass().getSimpleName(), "Error converting XML to DICOM", e)); @@ -151,49 +131,19 @@ public String toXML(String source) throws MessageSerializerException { try { byte[] encodedMessage = org.apache.commons.codec.binary.StringUtils.getBytesUsAscii(source); - StringWriter output = new StringWriter(); - DicomInputStream dis = new DicomInputStream(new BufferedInputStream(new Base64InputStream(new ByteArrayInputStream(encodedMessage)))); - /* - * This parameter was added in dcm4che 2.0.28. We use it to retain the memory allocation - * behavior from 2.0.25. http://www.mirthcorp.com/community/issues/browse/MIRTH-2166 - * http://www.dcm4che.org/jira/browse/DCM-554 - */ - dis.setAllocateLimit(-1); - - try { - TransformerFactory tf = TransformerFactory.newInstance(); - tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); - tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ""); - SAXTransformerFactory factory = (SAXTransformerFactory) tf; - TransformerHandler handler = factory.newTransformerHandler(); - handler.getTransformer().setOutputProperty(OutputKeys.INDENT, "no"); - handler.setResult(new StreamResult(output)); - - final SAXWriter writer = new SAXWriter(handler, null); - dis.setHandler(writer); - dis.readDicomObject(new BasicDicomObject(), -1); - String serializedDicomObject = output.toString(); - - // rename the "attr" element to the tag ID - Document document = documentSerializer.fromXML(serializedDicomObject); - NodeList attrElements = document.getElementsByTagName("attr"); - - for (int i = 0; i < attrElements.getLength(); i++) { - Element attrElement = (Element) attrElements.item(i); - renameAttrToTag(document, attrElement); - } + OieDicomConverter converter = DicomLibraryFactory.getConverter(); + String serializedDicomObject = converter.dicomBytesToXml(encodedMessage); - return documentSerializer.toXML(document); - } catch (Exception e) { - throw e; - } finally { - IOUtils.closeQuietly(dis); - IOUtils.closeQuietly(output); + // rename the "attr" element to the tag ID + Document document = documentSerializer.fromXML(serializedDicomObject); + NodeList attrElements = document.getElementsByTagName("attr"); - if (dis != null) { - dis.close(); - } + for (int i = 0; i < attrElements.getLength(); i++) { + Element attrElement = (Element) attrElements.item(i); + renameAttrToTag(document, attrElement); } + + return documentSerializer.toXML(document); } catch (Exception e) { throw new MessageSerializerException("Error converting DICOM to XML", e, ErrorMessageBuilder.buildErrorMessage(this.getClass().getSimpleName(), "Error converting DICOM to XML", e)); } diff --git a/server/src/com/mirth/connect/server/attachments/dicom/DICOMAttachmentHandler.java b/server/src/com/mirth/connect/server/attachments/dicom/DICOMAttachmentHandler.java index a460fbcbda..eddf7bf6af 100644 --- a/server/src/com/mirth/connect/server/attachments/dicom/DICOMAttachmentHandler.java +++ b/server/src/com/mirth/connect/server/attachments/dicom/DICOMAttachmentHandler.java @@ -1,8 +1,8 @@ /* * Copyright (c) Mirth Corporation. All rights reserved. - * + * * http://www.mirthcorp.com - * + * * The software in this package is published under the terms of the MPL license a copy of which has * been included with this distribution in the LICENSE.txt file. */ @@ -10,10 +10,10 @@ package com.mirth.connect.server.attachments.dicom; import org.apache.commons.codec.binary.StringUtils; -import org.dcm4che2.data.DicomElement; -import org.dcm4che2.data.DicomObject; -import org.dcm4che2.data.Tag; +import com.mirth.connect.connectors.dimse.dicom.DicomConstants; +import com.mirth.connect.connectors.dimse.dicom.OieDicomElement; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; import com.mirth.connect.donkey.model.message.RawMessage; import com.mirth.connect.donkey.model.message.attachment.Attachment; import com.mirth.connect.donkey.model.message.attachment.AttachmentException; @@ -25,8 +25,8 @@ public class DICOMAttachmentHandler implements AttachmentHandler { - private DicomObject dicomObject; - private DicomElement dicomElement; + private OieDicomObject dicomObject; + private OieDicomElement dicomElement; private int index; private String attachmentId; @@ -40,7 +40,7 @@ public void initialize(RawMessage message, Channel channel) throws AttachmentExc if (message.isBinary()) { messageBytes = message.getRawBytes(); } else { - // Taking a string is much more inefficient than taking in a byte array. + // Taking a string is much more inefficient than taking in a byte array. // If the user manually sends a message, it will arrive as a base64 encoded string, so we must support Strings for DICOM still. // However, DICOM messages that use this initializer should be relatively small in size. messageBytes = StringUtils.getBytesUsAscii(message.getRawData()); @@ -48,7 +48,7 @@ public void initialize(RawMessage message, Channel channel) throws AttachmentExc } dicomObject = DICOMConverter.byteArrayToDicomObject(messageBytes, decode); - dicomElement = dicomObject.remove(Tag.PixelData); + dicomElement = dicomObject.remove(DicomConstants.TAG_PIXEL_DATA); attachmentId = ServerUUIDGenerator.getUUID(); } catch (Throwable t) { throw new AttachmentException(t); diff --git a/server/src/com/mirth/connect/server/launcher/MirthLauncher.java b/server/src/com/mirth/connect/server/launcher/MirthLauncher.java index cd3d343fd7..5e53c50599 100644 --- a/server/src/com/mirth/connect/server/launcher/MirthLauncher.java +++ b/server/src/com/mirth/connect/server/launcher/MirthLauncher.java @@ -18,6 +18,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Properties; import java.util.jar.JarFile; @@ -47,6 +48,8 @@ public class MirthLauncher { "./server-lib/log4j/log4j-api-2.25.3.jar", "./server-lib/log4j/log4j-1.2-api-2.25.3.jar" }; + static final Map VARIANT_DEFAULTS = Map.of("dicom.library", "dcm4che2"); + private static String appDataDir = null; private static LoggerWrapper logger; @@ -112,7 +115,7 @@ public static void main(String[] args) { String currentVersion = versionProperties.getProperty("mirth.version"); addManifestToClasspath(manifest, classpathUrls); - addExtensionsToClasspath(classpathUrls, currentVersion); + addExtensionsToClasspath(classpathUrls, currentVersion, mirthProperties); URLClassLoader classLoader = new URLClassLoader(classpathUrls.toArray(new URL[classpathUrls.size()]), Thread.currentThread().getContextClassLoader()); Class mirthClass = classLoader.loadClass("com.mirth.connect.server.Mirth"); Thread mirthThread = (Thread) mirthClass.newInstance(); @@ -233,7 +236,7 @@ private static void addManifestToClasspath(ManifestEntry[] manifestEntries, List } } - private static void addExtensionsToClasspath(List urls, String currentVersion) throws Exception { + private static void addExtensionsToClasspath(List urls, String currentVersion, Properties mirthProperties) throws Exception { FileFilter extensionFileFilter = new NameFileFilter(new String[] { "plugin.xml", "source.xml", "destination.xml" }, IOCase.INSENSITIVE); FileFilter directoryFilter = FileFilterUtils.directoryFileFilter(); @@ -266,6 +269,14 @@ private static void addExtensionsToClasspath(List urls, String currentVersi String type = libraryElement.getAttribute("type"); if (type.equalsIgnoreCase("server") || type.equalsIgnoreCase("shared")) { + String variant = libraryElement.getAttribute("variant"); + + if (!shouldLoadLibrary(variant, mirthProperties)) { + File pathFile = new File(directory, libraryElement.getAttribute("path")); + logger.trace("skipping variant-mismatched library: " + pathFile.getAbsolutePath()); + continue; + } + File pathFile = new File(directory, libraryElement.getAttribute("path")); if (pathFile.exists()) { @@ -287,6 +298,38 @@ private static void addExtensionsToClasspath(List urls, String currentVersi } } + /** + * Determines whether a library should be loaded based on its variant attribute + * and the current mirth.properties values. + * + *

Variant format: {@code "propertyKey:requiredValue"} (e.g., {@code "dicom.library:dcm4che2"}). + * Libraries without a variant attribute are always loaded. + * + * @param variant the variant attribute value (may be null or empty) + * @param mirthProperties the loaded mirth.properties + * @return true if the library should be loaded + */ + static boolean shouldLoadLibrary(String variant, Properties mirthProperties) { + if (variant == null || variant.isEmpty()) { + return true; + } + + int colonIdx = variant.indexOf(':'); + if (colonIdx <= 0) { + return true; + } + + String propName = variant.substring(0, colonIdx); + String requiredValue = variant.substring(colonIdx + 1); + String actual = mirthProperties.getProperty(propName); + + if (actual == null || actual.trim().isEmpty()) { + actual = VARIANT_DEFAULTS.getOrDefault(propName, ""); + } + + return requiredValue.equalsIgnoreCase(actual.trim()); + } + private static boolean isExtensionCompatible(String extensionVersion, String currentVersion) { if (extensionVersion != null) { String[] extensionMirthVersions = extensionVersion.split(","); diff --git a/server/src/com/mirth/connect/server/servlets/WebStartServlet.java b/server/src/com/mirth/connect/server/servlets/WebStartServlet.java index ecc077b7ab..81493f3e17 100644 --- a/server/src/com/mirth/connect/server/servlets/WebStartServlet.java +++ b/server/src/com/mirth/connect/server/servlets/WebStartServlet.java @@ -22,6 +22,7 @@ import java.util.Enumeration; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import javax.servlet.ServletException; @@ -55,6 +56,11 @@ import com.mirth.connect.util.MirthSSLUtil; public class WebStartServlet extends HttpServlet { + // Default values for variant-filtered libraries when the corresponding + // property is absent from mirth.properties. Must stay in sync with + // MirthLauncher.VARIANT_DEFAULTS so client and server select the same variant. + private static final Map LIBRARY_VARIANT_DEFAULTS = Map.of("dicom.library", "dcm4che2"); + private Logger logger = LogManager.getLogger(this.getClass()); private ConfigurationController configurationController = ControllerFactory.getFactory().createConfigurationController(); private ExtensionController extensionController = ControllerFactory.getFactory().createExtensionController(); @@ -258,7 +264,7 @@ protected Document getAdministratorJnlp(HttpServletRequest request) throws Excep Set extensionPathsToAddToJnlp = new HashSet(); for (MetaData extension : allExtensions) { - if (extensionController.isExtensionEnabled(extension.getName()) && doesExtensionHaveClientOrSharedLibraries(extension)) { + if (extensionController.isExtensionEnabled(extension.getName()) && doesExtensionHaveClientOrSharedLibraries(extension, mirthProperties)) { extensionPathsToAddToJnlp.add(extension.getPath()); } } @@ -306,9 +312,10 @@ public static String getClientLibPath() { } } - private boolean doesExtensionHaveClientOrSharedLibraries(MetaData extension) { + private boolean doesExtensionHaveClientOrSharedLibraries(MetaData extension, PropertiesConfiguration mirthProperties) { for (ExtensionLibrary lib : extension.getLibraries()) { - if (lib.getType().equals(ExtensionLibrary.Type.CLIENT) || lib.getType().equals(ExtensionLibrary.Type.SHARED)) { + if ((lib.getType().equals(ExtensionLibrary.Type.CLIENT) || lib.getType().equals(ExtensionLibrary.Type.SHARED)) + && shouldServeLibrary(lib.getVariant(), mirthProperties)) { return true; } } @@ -316,6 +323,36 @@ private boolean doesExtensionHaveClientOrSharedLibraries(MetaData extension) { return false; } + /** + * Determines whether an extension library should be served to the Administrator + * based on its {@code variant} attribute and the current mirth.properties values. + * Mirrors {@code MirthLauncher.shouldLoadLibrary} so the client receives the same + * variant the server is running. + * + *

Variant format: {@code "propertyKey:requiredValue"} (e.g., {@code "dicom.library:dcm4che2"}). + * Libraries without a variant attribute are always served. + */ + static boolean shouldServeLibrary(String variant, PropertiesConfiguration mirthProperties) { + if (variant == null || variant.isEmpty()) { + return true; + } + + int colonIdx = variant.indexOf(':'); + if (colonIdx <= 0) { + return true; + } + + String propName = variant.substring(0, colonIdx); + String requiredValue = variant.substring(colonIdx + 1); + String actual = mirthProperties != null ? mirthProperties.getString(propName) : null; + + if (actual == null || actual.trim().isEmpty()) { + actual = LIBRARY_VARIANT_DEFAULTS.getOrDefault(propName, ""); + } + + return requiredValue.equalsIgnoreCase(actual.trim()); + } + protected Document getExtensionJnlp(String extensionPath) throws Exception { List allExtensions = new ArrayList(); allExtensions.addAll(ControllerFactory.getFactory().createExtensionController().getConnectorMetaData().values()); @@ -323,12 +360,15 @@ protected Document getExtensionJnlp(String extensionPath) throws Exception { Set librariesToAddToJnlp = new HashSet(); List extensionsWithThePath = new ArrayList(); + PropertiesConfiguration mirthProperties = getMirthProperties(); + for (MetaData metaData : allExtensions) { if (metaData.getPath().equals(extensionPath)) { extensionsWithThePath.add(metaData.getName()); for (ExtensionLibrary library : metaData.getLibraries()) { - if (library.getType().equals(ExtensionLibrary.Type.CLIENT) || library.getType().equals(ExtensionLibrary.Type.SHARED)) { + if ((library.getType().equals(ExtensionLibrary.Type.CLIENT) || library.getType().equals(ExtensionLibrary.Type.SHARED)) + && shouldServeLibrary(library.getVariant(), mirthProperties)) { librariesToAddToJnlp.add(library.getPath()); } } diff --git a/server/src/com/mirth/connect/server/userutil/DICOMUtil.java b/server/src/com/mirth/connect/server/userutil/DICOMUtil.java index 5115700987..2eb087a010 100644 --- a/server/src/com/mirth/connect/server/userutil/DICOMUtil.java +++ b/server/src/com/mirth/connect/server/userutil/DICOMUtil.java @@ -1,8 +1,8 @@ /* * Copyright (c) Mirth Corporation. All rights reserved. - * + * * http://www.mirthcorp.com - * + * * The software in this package is published under the terms of the MPL license a copy of which has * been included with this distribution in the LICENSE.txt file. */ @@ -13,8 +13,7 @@ import java.util.ArrayList; import java.util.List; -import org.dcm4che2.data.DicomObject; - +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; import com.mirth.connect.donkey.model.message.MessageSerializerException; import com.mirth.connect.donkey.util.Base64Util; import com.mirth.connect.model.converters.DICOMConverter; @@ -30,7 +29,7 @@ private DICOMUtil() {} /** * Re-attaches DICOM attachments with the header data in the connector message and returns the * resulting merged data as a Base64-encoded string. - * + * * @param connectorMessage * The connector message to retrieve merged DICOM data for. * @return The merged DICOM data, Base64-encoded. @@ -42,7 +41,7 @@ public static String getDICOMRawData(ImmutableConnectorMessage connectorMessage) /** * Re-attaches DICOM attachments with the header data in the connector message and returns the * resulting merged data as a byte array. - * + * * @param connectorMessage * The connector message to retrieve merged DICOM data for. * @return The merged DICOM data as a byte array. @@ -54,7 +53,7 @@ public static byte[] getDICOMRawBytes(ImmutableConnectorMessage connectorMessage /** * Re-attaches DICOM attachments with the header data in the connector message and returns the * resulting merged data as a byte array. - * + * * @param connectorMessage * The connector message to retrieve merged DICOM data for. * @return The merged DICOM data as a byte array. @@ -66,7 +65,7 @@ public static byte[] getDICOMMessage(ImmutableConnectorMessage connectorMessage) /** * Re-attaches DICOM attachments with the header data in the connector message and returns the * resulting merged data as a Base-64 encoded String. - * + * * @param connectorMessage * The connector message containing header data to merge DICOM attachments with. * @param attachments @@ -84,7 +83,7 @@ public static String mergeHeaderAttachments(ImmutableConnectorMessage connectorM /** * Re-attaches DICOM attachments with the given header data and returns the resulting merged * data as a Base-64 encoded String. - * + * * @param header * The header data to merge DICOM attachments with. * @param images @@ -107,7 +106,7 @@ public static String mergeHeaderPixelData(byte[] header, List images) th /** * Returns the number of slices in the fully-merged DICOM data associated with a given connector * message. - * + * * @param connectorMessage * The connector message to retrieve DICOM data for. * @return The number of slices in the DICOM data. @@ -118,7 +117,7 @@ public static int getSliceCount(ImmutableConnectorMessage connectorMessage) { /** * Converts merged DICOM data associated with a connector message into a specified image format. - * + * * @param imageType * The image format to convert the DICOM data to (e.g. "jpg"). * @param connectorMessage @@ -133,7 +132,7 @@ public static String convertDICOM(String imageType, ImmutableConnectorMessage co /** * Converts merged DICOM data associated with a connector message into a specified image format. - * + * * @param imageType * The image format to convert the DICOM data to (e.g. "jpg"). * @param connectorMessage @@ -146,7 +145,7 @@ public static String convertDICOM(String imageType, ImmutableConnectorMessage co /** * Converts merged DICOM data associated with a connector message into a specified image format. - * + * * @param imageType * The image format to convert the DICOM data to (e.g. "jpg"). * @param connectorMessage @@ -162,7 +161,7 @@ public static String convertDICOM(String imageType, ImmutableConnectorMessage co /** * Converts merged DICOM data associated with a connector message into a specified image format. - * + * * @param imageType * The image format to convert the DICOM data to (e.g. "jpg"). * @param connectorMessage @@ -180,7 +179,7 @@ public static String convertDICOM(String imageType, ImmutableConnectorMessage co /** * Converts merged DICOM data associated with a connector message into a specified image format. - * + * * @param imageType * The image format to convert the DICOM data to (e.g. "jpg"). * @param connectorMessage @@ -193,7 +192,7 @@ public static byte[] convertDICOMToByteArray(String imageType, ImmutableConnecto /** * Converts merged DICOM data associated with a connector message into a specified image format. - * + * * @param imageType * The image format to convert the DICOM data to (e.g. "jpg"). * @param connectorMessage @@ -209,7 +208,7 @@ public static byte[] convertDICOMToByteArray(String imageType, ImmutableConnecto /** * Converts merged DICOM data associated with a connector message into a specified image format. - * + * * @param imageType * The image format to convert the DICOM data to (e.g. "jpg"). * @param connectorMessage @@ -226,30 +225,39 @@ public static byte[] convertDICOMToByteArray(String imageType, ImmutableConnecto } /** - * Converts a byte array into a dcm4che DicomObject. - * + * Converts a byte array into a version-neutral DICOM object wrapper. + * + *

The returned {@link OieDicomObject} provides common accessors such as + * {@code getString(int)}, {@code getString(int, String)}, and + * {@code getInt(int, int)}. If you need access to library-specific methods + * (e.g., dcm4che2 {@code DicomObject}), call {@link OieDicomObject#unwrap()} + * and cast: + *

{@code
+     * DicomObject dcm = (DicomObject) dicomObj.unwrap();
+     * }
+ * * @param bytes * The binary data to convert. * @param decodeBase64 * If true, the data is assumed to be Base64-encoded. - * @return The converted DicomObject. + * @return The converted OieDicomObject. * @throws IOException - * If Base64 encoding failed. + * If parsing fails. */ - public static DicomObject byteArrayToDicomObject(byte[] bytes, boolean decodeBase64) throws IOException { + public static OieDicomObject byteArrayToDicomObject(byte[] bytes, boolean decodeBase64) throws IOException { return DICOMConverter.byteArrayToDicomObject(bytes, decodeBase64); } /** - * Converts a dcm4che DicomObject into a byte array. - * + * Converts a DICOM object into a byte array. + * * @param dicomObject - * The DicomObject to convert. + * The OieDicomObject to convert. * @return The converted byte array. * @throws IOException - * If Base64 encoding failed. + * If serialization fails. */ - public static byte[] dicomObjectToByteArray(DicomObject dicomObject) throws IOException { + public static byte[] dicomObjectToByteArray(OieDicomObject dicomObject) throws IOException { return DICOMConverter.dicomObjectToByteArray(dicomObject); } } diff --git a/server/src/com/mirth/connect/server/util/DICOMMessageUtil.java b/server/src/com/mirth/connect/server/util/DICOMMessageUtil.java index 57b107840c..d1778743af 100644 --- a/server/src/com/mirth/connect/server/util/DICOMMessageUtil.java +++ b/server/src/com/mirth/connect/server/util/DICOMMessageUtil.java @@ -1,8 +1,8 @@ /* * Copyright (c) Mirth Corporation. All rights reserved. - * + * * http://www.mirthcorp.com - * + * * The software in this package is published under the terms of the MPL license a copy of which has * been included with this distribution in the LICENSE.txt file. */ @@ -31,11 +31,10 @@ import org.apache.commons.io.IOUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.dcm4che2.data.DicomElement; -import org.dcm4che2.data.DicomObject; -import org.dcm4che2.data.Tag; -import org.dcm4che2.data.VR; +import com.mirth.connect.connectors.dimse.dicom.DicomConstants; +import com.mirth.connect.connectors.dimse.dicom.OieDicomElement; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; import com.mirth.connect.donkey.model.message.ConnectorMessage; import com.mirth.connect.donkey.model.message.MessageSerializerException; import com.mirth.connect.donkey.model.message.attachment.Attachment; @@ -134,12 +133,12 @@ public static byte[] mergeHeaderAttachments(ImmutableConnectorMessage message, L public static byte[] mergeHeaderPixelData(byte[] header, List attachments) throws IOException { // 1. read in header - DicomObject dcmObj = DICOMConverter.byteArrayToDicomObject(header, false); + OieDicomObject dcmObj = DICOMConverter.byteArrayToDicomObject(header, false); // 2. Add pixel data to DicomObject if (attachments != null && !attachments.isEmpty()) { if (attachments.size() > 1) { - DicomElement dicomElement = dcmObj.putFragments(Tag.PixelData, VR.OB, dcmObj.bigEndian(), attachments.size()); + OieDicomElement dicomElement = dcmObj.putFragments(DicomConstants.TAG_PIXEL_DATA, DicomConstants.VR_OB, dcmObj.bigEndian(), attachments.size()); for (Attachment attachment : attachments) { dicomElement.addFragment(attachment.getContent()); @@ -147,12 +146,14 @@ public static byte[] mergeHeaderPixelData(byte[] header, List attach dcmObj.add(dicomElement); } else { - dcmObj.putBytes(Tag.PixelData, VR.OB, attachments.get(0).getContent()); + dcmObj.putBytes(DicomConstants.TAG_PIXEL_DATA, DicomConstants.VR_OB, attachments.get(0).getContent()); } } // Memory Optimization. Free the references to the data in the attachments list. - attachments.clear(); + if (attachments != null) { + attachments.clear(); + } return DICOMConverter.dicomObjectToByteArray(dcmObj); } diff --git a/server/src/org/dcm4che2/tool/dcmrcv/MirthDcmRcv.java b/server/src/org/dcm4che2/tool/dcmrcv/MirthDcmRcv.java index d7b390962a..649b65d10e 100644 --- a/server/src/org/dcm4che2/tool/dcmrcv/MirthDcmRcv.java +++ b/server/src/org/dcm4che2/tool/dcmrcv/MirthDcmRcv.java @@ -52,7 +52,11 @@ public NetworkConnection getNetworkConnection() { @Override protected NetworkConnection createNetworkConnection() { - return dicomConfiguration.createNetworkConnection(); + Object custom = dicomConfiguration.createNetworkConnection(); + if (custom instanceof NetworkConnection) { + return (NetworkConnection) custom; + } + return new NetworkConnection(); } @Override diff --git a/server/src/org/dcm4che2/tool/dcmsnd/MirthDcmSnd.java b/server/src/org/dcm4che2/tool/dcmsnd/MirthDcmSnd.java index 122acbd9a8..cd6908186a 100644 --- a/server/src/org/dcm4che2/tool/dcmsnd/MirthDcmSnd.java +++ b/server/src/org/dcm4che2/tool/dcmsnd/MirthDcmSnd.java @@ -41,7 +41,11 @@ public NetworkConnection getRemoteStgcmtNetworkConnection() { @Override protected NetworkConnection createNetworkConnection() { - return dicomConfiguration.createNetworkConnection(); + Object custom = dicomConfiguration.createNetworkConnection(); + if (custom instanceof NetworkConnection) { + return (NetworkConnection) custom; + } + return new NetworkConnection(); } @Override diff --git a/server/test/com/mirth/connect/connectors/dimse/DICOMDispatcherTest.java b/server/test/com/mirth/connect/connectors/dimse/DICOMDispatcherTest.java index 1869f38c06..17a3fbd587 100644 --- a/server/test/com/mirth/connect/connectors/dimse/DICOMDispatcherTest.java +++ b/server/test/com/mirth/connect/connectors/dimse/DICOMDispatcherTest.java @@ -1,24 +1,21 @@ package com.mirth.connect.connectors.dimse; import static org.junit.Assert.assertEquals; -import static org.mockito.Mockito.mock; import java.io.File; import java.io.IOException; +import java.util.Iterator; -import org.dcm4che2.data.BasicDicomObject; -import org.dcm4che2.data.DicomObject; -import org.dcm4che2.data.SequenceDicomElement; -import org.dcm4che2.data.Tag; -import org.dcm4che2.data.VR; -import org.dcm4che2.net.ConfigurationException; -import org.dcm4che2.net.NetworkConnection; -import org.dcm4che2.tool.dcmsnd.CustomDimseRSPHandler; -import org.dcm4che2.tool.dcmsnd.MirthDcmSnd; -import org.dcm4che2.util.StringUtils; import org.junit.Test; import com.mirth.connect.connectors.dimse.DICOMDispatcher.CommandDataDimseRSPHandler; +import com.mirth.connect.connectors.dimse.dicom.DicomConstants; +import com.mirth.connect.connectors.dimse.dicom.OieDicomElement; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; +import com.mirth.connect.connectors.dimse.dicom.OieDicomSender; +import com.mirth.connect.connectors.dimse.dicom.OieDimseRspHandler; +import com.mirth.connect.connectors.dimse.dicom.DicomLibraryFactory; +import com.mirth.connect.connectors.dimse.dicom.OieDicomConverter; import com.mirth.connect.donkey.model.message.ConnectorMessage; import com.mirth.connect.donkey.model.message.Response; import com.mirth.connect.donkey.model.message.Status; @@ -32,7 +29,7 @@ public class DICOMDispatcherTest { @Test public void testSendWithStatusCodes() { - // send message using our custom MirthDcmSnd + // send message using our custom sender TestDICOMDispatcher dispatcher = new TestDICOMDispatcher(); dispatcher.configuration = new DefaultDICOMConfiguration(); DICOMDispatcherProperties props = new DICOMDispatcherProperties(); @@ -44,8 +41,8 @@ public void testSendWithStatusCodes() { Status status = null; String statusMessage = null; - TestMirthDcmSnd.setCommitSucceeded(true); - TestMirthDcmSnd.setCmdStatus(0); + TestDicomSender.setCommitSucceeded(true); + TestDicomSender.setCmdStatus(0); response = dispatcher.send(props, message); status = response.getStatus(); statusMessage = response.getStatusMessage(); @@ -55,39 +52,39 @@ public void testSendWithStatusCodes() { assertEquals("DICOM message successfully sent", statusMessage); // check with 0xB000 || 0xB006 || 0xB007 status - TestMirthDcmSnd.setCmdStatus(0xB000); + TestDicomSender.setCmdStatus(0xB000); response = dispatcher.send(props, message); status = response.getStatus(); statusMessage = response.getStatusMessage(); assertEquals(Status.SENT, status); - assertEquals("DICOM message successfully sent with warning status code: 0x" + StringUtils.shortToHex(0xB000), statusMessage); + assertEquals("DICOM message successfully sent with warning status code: 0x" + DicomConstants.shortToHex(0xB000), statusMessage); - TestMirthDcmSnd.setCmdStatus(0xB006); + TestDicomSender.setCmdStatus(0xB006); response = dispatcher.send(props, message); status = response.getStatus(); statusMessage = response.getStatusMessage(); assertEquals(Status.SENT, status); - assertEquals("DICOM message successfully sent with warning status code: 0x" + StringUtils.shortToHex(0xB006), statusMessage); + assertEquals("DICOM message successfully sent with warning status code: 0x" + DicomConstants.shortToHex(0xB006), statusMessage); - TestMirthDcmSnd.setCmdStatus(0xB007); + TestDicomSender.setCmdStatus(0xB007); response = dispatcher.send(props, message); status = response.getStatus(); statusMessage = response.getStatusMessage(); assertEquals(Status.SENT, status); - assertEquals("DICOM message successfully sent with warning status code: 0x" + StringUtils.shortToHex(0xB007), statusMessage); + assertEquals("DICOM message successfully sent with warning status code: 0x" + DicomConstants.shortToHex(0xB007), statusMessage); // check other status == QUEUED - TestMirthDcmSnd.setCmdStatus(0xB008); + TestDicomSender.setCmdStatus(0xB008); response = dispatcher.send(props, message); status = response.getStatus(); statusMessage = response.getStatusMessage(); assertEquals(Status.QUEUED, status); - assertEquals("Error status code received from DICOM server: 0x" + StringUtils.shortToHex(0xB008), statusMessage); + assertEquals("Error status code received from DICOM server: 0x" + DicomConstants.shortToHex(0xB008), statusMessage); } @Test public void testResponseData() throws DonkeyElementException { - // send message using our custom MirthDcmSnd + // send message using our custom sender TestDICOMDispatcher dispatcher = new TestDICOMDispatcher(); dispatcher.configuration = new DefaultDICOMConfiguration(); DICOMDispatcherProperties props = new DICOMDispatcherProperties(); @@ -95,8 +92,8 @@ public void testResponseData() throws DonkeyElementException { props.setPort("9000"); ConnectorMessage message = new ConnectorMessage(); - TestMirthDcmSnd.setCmdStatus(0); - TestMirthDcmSnd.setCommitSucceeded(true); + TestDicomSender.setCmdStatus(0); + TestDicomSender.setCommitSucceeded(true); Response response = dispatcher.send(props, message); String responseData = response.getMessage(); @@ -115,8 +112,8 @@ public void testStorageCommitment() throws Exception { props.setStgcmt(true); ConnectorMessage message = new ConnectorMessage(); - TestMirthDcmSnd.setCmdStatus(0); - TestMirthDcmSnd.setCommitSucceeded(false); + TestDicomSender.setCmdStatus(0); + TestDicomSender.setCommitSucceeded(false); Response response = null; Status status = null; @@ -130,9 +127,9 @@ public void testStorageCommitment() throws Exception { assertEquals("DICOM message successfully sent but Storage Commitment failed with reason: Unknown", statusMessage); // Test the case where the stgcmt request succeeds but contains failed SOP items - TestMirthDcmSnd.setCommitSucceeded(true); - TestMirthDcmSnd.setFailedSOP(true); - TestMirthDcmSnd.setFailureReason(1); + TestDicomSender.setCommitSucceeded(true); + TestDicomSender.setFailedSOP(true); + TestDicomSender.setFailureReason(1); response = dispatcher.send(props, message); status = response.getStatus(); @@ -141,11 +138,11 @@ public void testStorageCommitment() throws Exception { assertEquals(Status.QUEUED, status); assertEquals("DICOM message successfully sent but Storage Commitment failed with reason: 1", statusMessage); - TestMirthDcmSnd.setCommitSucceeded(false); - TestMirthDcmSnd.setFailedSOP(false); - TestMirthDcmSnd.setFailureReason(0); + TestDicomSender.setCommitSucceeded(false); + TestDicomSender.setFailedSOP(false); + TestDicomSender.setFailureReason(0); - // test that a failed storage commitment doesn't cause the message to fail + // test that a failed storage commitment doesn't cause the message to fail // if the dispatcher isn't configured to care props.setStgcmt(false); response = dispatcher.send(props, message); @@ -157,32 +154,32 @@ public void testStorageCommitment() throws Exception { // check with 0xB000 and requesting storage commitment props.setStgcmt(true); - TestMirthDcmSnd.setCmdStatus(0xB000); + TestDicomSender.setCmdStatus(0xB000); response = dispatcher.send(props, message); status = response.getStatus(); statusMessage = response.getStatusMessage(); assertEquals(Status.QUEUED, status); - String expectedMessage = "DICOM message successfully sent with warning status code: 0x" + StringUtils.shortToHex(0xB000) + " but Storage Commitment failed with reason: Unknown"; + String expectedMessage = "DICOM message successfully sent with warning status code: 0x" + DicomConstants.shortToHex(0xB000) + " but Storage Commitment failed with reason: Unknown"; assertEquals(expectedMessage, statusMessage); // check other status and requesting storage commitment - TestMirthDcmSnd.setCmdStatus(0xB008); + TestDicomSender.setCmdStatus(0xB008); response = dispatcher.send(props, message); status = response.getStatus(); statusMessage = response.getStatusMessage(); assertEquals(Status.QUEUED, status); - assertEquals("Error status code received from DICOM server: 0x" + StringUtils.shortToHex(0xB008), statusMessage); + assertEquals("Error status code received from DICOM server: 0x" + DicomConstants.shortToHex(0xB008), statusMessage); } - private static class TestMirthDcmSnd extends MirthDcmSnd { + /** + * Test OieDicomSender that stubs out all network operations. + */ + private static class TestDicomSender implements OieDicomSender { private static int cmdStatus; private static boolean commitSucceeded = true; private static boolean failedSOP = false; private static int failureReason = 0; - - public TestMirthDcmSnd(DICOMConfiguration configuration) { - super(configuration); - } + private boolean storageCommitment = false; public static void setCmdStatus(int status) { cmdStatus = status; @@ -193,69 +190,85 @@ public static void setCommitSucceeded(boolean succeeded) { } public static void setFailedSOP(boolean failedSOP) { - TestMirthDcmSnd.failedSOP = failedSOP; + TestDicomSender.failedSOP = failedSOP; } public static void setFailureReason(int failureReason) { - TestMirthDcmSnd.failureReason = failureReason; - } - - @Override - protected void init() { - conn = createNetworkConnection(); - remoteConn = createNetworkConnection(); + TestDicomSender.failureReason = failureReason; } @Override - public void start() throws IOException {} - - @Override - public void open() throws IOException, ConfigurationException, InterruptedException {} - - @Override - public void close() {} - - @Override - public void stop() {} - - @Override - public void addFile(File f) {} - - @Override - public void send(CustomDimseRSPHandler responseHandler) { - CommandDataDimseRSPHandler handler = (CommandDataDimseRSPHandler) responseHandler; - BasicDicomObject cmd = new BasicDicomObject(); - cmd.putInt(Tag.Status, VR.IS, cmdStatus); - handler.onDimseRSP(null, cmd, null); + public void send(OieDimseRspHandler handler) { + OieDicomConverter converter = DicomLibraryFactory.getConverter(); + OieDicomObject cmd = converter.createDicomObject(); + cmd.putInt(DicomConstants.TAG_STATUS, DicomConstants.VR_IS, cmdStatus); + handler.onDimseRSP(cmd, null); } @Override - public synchronized DicomObject waitForStgCmtResult() throws InterruptedException { - BasicDicomObject rsp = new BasicDicomObject(); + public OieDicomObject waitForStgCmtResult() throws InterruptedException { + OieDicomConverter converter = DicomLibraryFactory.getConverter(); + OieDicomObject rsp = converter.createDicomObject(); if (failedSOP) { - SequenceDicomElement failedSOPSq = (SequenceDicomElement) rsp.putSequence(Tag.FailedSOPSequence); - BasicDicomObject failedSOPItem = new BasicDicomObject(); - failedSOPItem.putInt(Tag.FailureReason, VR.IS, failureReason); + OieDicomElement failedSOPSq = rsp.putSequence(DicomConstants.TAG_FAILED_SOP_SEQUENCE); + OieDicomObject failedSOPItem = converter.createDicomObject(); + failedSOPItem.putInt(DicomConstants.TAG_FAILURE_REASON, DicomConstants.VR_IS, failureReason); failedSOPSq.addDicomObject(failedSOPItem); } return rsp; } - @Override - protected NetworkConnection createNetworkConnection() { - return mock(NetworkConnection.class); - } - - @Override - public boolean commit() { - return commitSucceeded; - } + @Override public boolean commit() { return commitSucceeded; } + @Override public boolean isStorageCommitment() { return storageCommitment; } + @Override public void setCalledAET(String aet) {} + @Override public void setRemoteHost(String host) {} + @Override public void setRemotePort(int port) {} + @Override public void setCalling(String aet) {} + @Override public void setLocalHost(String host) {} + @Override public void setLocalPort(int port) {} + @Override public void addFile(File file) {} + @Override public void setAcceptTimeout(int timeout) {} + @Override public void setMaxOpsInvoked(int maxOps) {} + @Override public void setTranscoderBufferSize(int size) {} + @Override public void setConnectTimeout(int timeout) {} + @Override public void setPriority(int priority) {} + @Override public void setPackPDV(boolean packPDV) {} + @Override public void setMaxPDULengthReceive(int length) {} + @Override public void setMaxPDULengthSend(int length) {} + @Override public void setReceiveBufferSize(int size) {} + @Override public void setSendBufferSize(int size) {} + @Override public void setAssociationReaperPeriod(int period) {} + @Override public void setReleaseTimeout(int timeout) {} + @Override public void setDimseRspTimeout(int timeout) {} + @Override public void setShutdownDelay(int delay) {} + @Override public void setSocketCloseDelay(int delay) {} + @Override public void setTcpNoDelay(boolean tcpNoDelay) {} + @Override public void setOfferDefaultTransferSyntaxInSeparatePresentationContext(boolean ts1) {} + @Override public void setStorageCommitment(boolean stgcmt) { this.storageCommitment = stgcmt; } + @Override public void setUserIdentity(String username, String passcode, boolean positiveResponseRequested) {} + @Override public void setTlsWithoutEncryption() {} + @Override public void setTls3DES_EDE_CBC() {} + @Override public void setTlsAES_128_CBC() {} + @Override public void setTrustStoreURL(String url) {} + @Override public void setTrustStorePassword(String password) {} + @Override public void setKeyPassword(String password) {} + @Override public void setKeyStoreURL(String url) {} + @Override public void setKeyStorePassword(String password) {} + @Override public void setTlsNeedClientAuth(boolean needClientAuth) {} + @Override public void setTlsProtocol(String[] protocols) {} + @Override public void initTLS() {} + @Override public void configureTransferCapability() {} + @Override public void start() {} + @Override public void open() {} + @Override public void close() {} + @Override public void stop() {} + @Override public Object unwrap() { return null; } } private class TestDICOMDispatcher extends DICOMDispatcher { @Override - protected MirthDcmSnd getDcmSnd(DICOMConfiguration configuration) { - return new TestMirthDcmSnd(configuration); + protected OieDicomSender createDicomSender(DICOMConfiguration configuration) { + return new TestDicomSender(); } @Override diff --git a/server/test/com/mirth/connect/connectors/dimse/DICOMReceiverTest.java b/server/test/com/mirth/connect/connectors/dimse/DICOMReceiverTest.java new file mode 100644 index 0000000000..359239d59f --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/DICOMReceiverTest.java @@ -0,0 +1,186 @@ +package com.mirth.connect.connectors.dimse; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.List; + +import org.junit.Test; + +import com.mirth.connect.client.core.ControllerException; +import com.mirth.connect.connectors.dimse.dicom.OieDicomReceiver; +import com.mirth.connect.donkey.model.event.Event; +import com.mirth.connect.donkey.server.channel.Channel; +import com.mirth.connect.model.ServerEvent; +import com.mirth.connect.model.filters.EventFilter; +import com.mirth.connect.server.controllers.EventController; +import com.mirth.connect.server.event.EventListener; + +public class DICOMReceiverTest { + + @Test + public void testOnStartSetsPort() throws Exception { + TestDicomReceiver stub = new TestDicomReceiver(); + TestDICOMReceiver receiver = createTestReceiver(stub); + receiver.connectorProperties.getListenerConnectorProperties().setPort("1234"); + + receiver.onStart(); + + assertEquals(1234, stub.port); + } + + @Test + public void testOnStartSetsHostname() throws Exception { + TestDicomReceiver stub = new TestDicomReceiver(); + TestDICOMReceiver receiver = createTestReceiver(stub); + receiver.connectorProperties.getListenerConnectorProperties().setHost("192.168.1.100"); + + receiver.onStart(); + + assertEquals("192.168.1.100", stub.hostname); + } + + @Test + public void testOnStartSetsAETitle() throws Exception { + TestDicomReceiver stub = new TestDicomReceiver(); + TestDICOMReceiver receiver = createTestReceiver(stub); + receiver.connectorProperties.setDest("MY_AE_TITLE"); + + receiver.onStart(); + + assertEquals("MY_AE_TITLE", stub.destination); + } + + @Test + public void testOnStartTransferSyntax() throws Exception { + TestDicomReceiver stub = new TestDicomReceiver(); + TestDICOMReceiver receiver = createTestReceiver(stub); + receiver.connectorProperties.setDefts(true); + + receiver.onStart(); + + assertArrayEquals(new String[] { "1.2.840.10008.1.2" }, stub.transferSyntax); + } + + @Test + public void testOnStartCallsStart() throws Exception { + TestDicomReceiver stub = new TestDicomReceiver(); + TestDICOMReceiver receiver = createTestReceiver(stub); + + receiver.onStart(); + + assertTrue("start() should have been called", stub.started); + } + + @Test + public void testOnStopCallsStop() throws Exception { + TestDicomReceiver stub = new TestDicomReceiver(); + TestDICOMReceiver receiver = createTestReceiver(stub); + + receiver.onStop(); + + assertTrue("stop() should have been called", stub.stopped); + } + + private TestDICOMReceiver createTestReceiver(TestDicomReceiver stub) { + TestDICOMReceiver receiver = new TestDICOMReceiver(stub); + receiver.connectorProperties = new DICOMReceiverProperties(); + receiver.dicomReceiver = stub; + return receiver; + } + + /** + * Stub OieDicomReceiver that captures setter calls for assertions. + */ + private static class TestDicomReceiver implements OieDicomReceiver { + int port; + String hostname; + String destination; + String[] transferSyntax; + String aeTitle; + boolean started; + boolean stopped; + + @Override public void setPort(int port) { this.port = port; } + @Override public void setHostname(String hostname) { this.hostname = hostname; } + @Override public void setDestination(String destination) { this.destination = destination; } + @Override public void setTransferSyntax(String[] transferSyntax) { this.transferSyntax = transferSyntax; } + @Override public void setAEtitle(String aeTitle) { this.aeTitle = aeTitle; } + @Override public void setAssociationReaperPeriod(int period) {} + @Override public void setIdleTimeout(int timeout) {} + @Override public void setRequestTimeout(int timeout) {} + @Override public void setReleaseTimeout(int timeout) {} + @Override public void setSocketCloseDelay(int delay) {} + @Override public void setDimseRspDelay(int delay) {} + @Override public void setMaxPDULengthReceive(int length) {} + @Override public void setMaxPDULengthSend(int length) {} + @Override public void setSendBufferSize(int size) {} + @Override public void setReceiveBufferSize(int size) {} + @Override public void setFileBufferSize(int size) {} + @Override public void setPackPDV(boolean packPDV) {} + @Override public void setTcpNoDelay(boolean tcpNoDelay) {} + @Override public void setMaxOpsPerformed(int maxOps) {} + @Override public void setTlsWithoutEncryption() {} + @Override public void setTls3DES_EDE_CBC() {} + @Override public void setTlsAES_128_CBC() {} + @Override public void setTrustStoreURL(String url) {} + @Override public void setTrustStorePassword(String password) {} + @Override public void setKeyPassword(String password) {} + @Override public void setKeyStoreURL(String url) {} + @Override public void setKeyStorePassword(String password) {} + @Override public void setTlsNeedClientAuth(boolean needClientAuth) {} + @Override public void setTlsProtocol(String[] protocols) {} + @Override public void initTLS() {} + @Override public void initTransferCapability() {} + @Override public void start() { this.started = true; } + @Override public void stop() { this.stopped = true; } + @Override public Object unwrap() { return null; } + } + + /** + * Test subclass of DICOMReceiver that bypasses deployment and infrastructure + * dependencies. Sets up a no-op EventController and DICOMConfiguration, and + * provides a fake Channel for template value replacement. + */ + private static class TestDICOMReceiver extends DICOMReceiver { + private final OieDicomReceiver stubReceiver; + + TestDICOMReceiver(OieDicomReceiver stubReceiver) { + this.stubReceiver = stubReceiver; + this.configuration = new DefaultDICOMConfiguration() { + @Override + public void configureConnectorDeploy(com.mirth.connect.donkey.server.channel.Connector connector) {} + + @Override + public void configureReceiver(OieDicomReceiver receiver, DICOMReceiver connector, DICOMReceiverProperties connectorProperties) {} + }; + this.eventController = new NoOpEventController(); + + // Set up a minimal Channel so getChannel().getName() works in onStart + Channel ch = new Channel(); + ch.setName("testChannel"); + this.channel = ch; + setChannelId("testChannelId"); + } + + @Override + protected OieDicomReceiver createDicomReceiver(DICOMConfiguration configuration) { + return stubReceiver; + } + } + + private static class NoOpEventController extends EventController { + @Override public void addListener(EventListener listener) {} + @Override public void removeListener(EventListener listener) {} + @Override public void dispatchEvent(Event event) {} + @Override public void insertEvent(ServerEvent serverEvent) {} + @Override public Integer getMaxEventId() throws ControllerException { return 0; } + @Override public List getEvents(EventFilter filter, Integer offset, Integer limit) throws ControllerException { return null; } + @Override public Long getEventCount(EventFilter filter) throws ControllerException { return 0L; } + @Override public void removeAllEvents() {} + @Override public String exportAllEvents() { return null; } + @Override public String exportAndRemoveAllEvents() { return null; } + @Override public List getEventsByAsc(EventFilter filter, Integer offset, Integer limit) throws ControllerException { return null; } + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/DicomLibraryFactoryTest.java b/server/test/com/mirth/connect/connectors/dimse/dicom/DicomLibraryFactoryTest.java new file mode 100644 index 0000000000..8d3983ea08 --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/DicomLibraryFactoryTest.java @@ -0,0 +1,118 @@ +package com.mirth.connect.connectors.dimse.dicom; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.After; +import org.junit.Test; + +import com.mirth.connect.connectors.dimse.DICOMConfiguration; +import com.mirth.connect.connectors.dimse.dicom.DicomLibraryFactory.DicomLibrary; + +public class DicomLibraryFactoryTest { + + @After + public void resetFactory() { + DicomLibraryFactory.resetForTesting(null); + } + + @Test + public void testDefaultLibraryIsDcm4che2() { + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE2); + assertEquals(DicomLibrary.DCM4CHE2, DicomLibraryFactory.getActiveLibrary()); + } + + @Test + public void testGetConverterReturnsSingleton() { + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE2); + OieDicomConverter c1 = DicomLibraryFactory.getConverter(); + OieDicomConverter c2 = DicomLibraryFactory.getConverter(); + assertNotNull(c1); + assertSame(c1, c2); + } + + @Test + public void testCreateSenderReturnsNonNull() { + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE2); + // Use a null-safe test configuration + OieDicomSender sender = DicomLibraryFactory.createSender(new TestDICOMConfiguration()); + assertNotNull(sender); + } + + @Test + public void testLoadConfigurationWithNullReturnsDefault() { + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE2); + DICOMConfiguration config = DicomLibraryFactory.loadConfiguration(null); + assertNotNull(config); + } + + @Test + public void testLoadConfigurationWithEmptyReturnsDefault() { + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE2); + DICOMConfiguration config = DicomLibraryFactory.loadConfiguration(" "); + assertNotNull(config); + } + + @Test + public void testLoadConfigurationWithInvalidClassReturnsDefault() { + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE2); + DICOMConfiguration config = DicomLibraryFactory.loadConfiguration("com.nonexistent.FakeClass"); + assertNotNull(config); + } + + @Test + public void testDcm5ConverterCreation() { + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE5); + OieDicomConverter converter = DicomLibraryFactory.getConverter(); + assertNotNull(converter); + assertEquals("Dcm5DicomConverter", converter.getClass().getSimpleName()); + } + + @Test + public void testDcm5SenderCreation() { + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE5); + OieDicomSender sender = DicomLibraryFactory.createSender(new TestDICOMConfiguration()); + assertNotNull(sender); + assertEquals("Dcm5DicomSender", sender.getClass().getSimpleName()); + } + + @Test + public void testDcm5ReceiverCreation() { + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE5); + OieDicomReceiver receiver = DicomLibraryFactory.createReceiver( + org.mockito.Mockito.mock(com.mirth.connect.donkey.server.channel.SourceConnector.class), + new TestDICOMConfiguration()); + assertNotNull(receiver); + assertEquals("Dcm5DicomReceiver", receiver.getClass().getSimpleName()); + } + + @Test + public void testDcm5ConverterSingleton() { + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE5); + OieDicomConverter c1 = DicomLibraryFactory.getConverter(); + OieDicomConverter c2 = DicomLibraryFactory.getConverter(); + assertSame(c1, c2); + } + + /** + * Minimal DICOMConfiguration for testing — avoids ControllerFactory dependencies. + */ + private static class TestDICOMConfiguration implements DICOMConfiguration { + @Override + public void configureConnectorDeploy(com.mirth.connect.donkey.server.channel.Connector connector) {} + @Override + public void configureReceiver(OieDicomReceiver receiver, + com.mirth.connect.connectors.dimse.DICOMReceiver connector, + com.mirth.connect.connectors.dimse.DICOMReceiverProperties connectorProperties) {} + @Override + public void configureSender(OieDicomSender sender, + com.mirth.connect.connectors.dimse.DICOMDispatcher connector, + com.mirth.connect.connectors.dimse.DICOMDispatcherProperties connectorProperties) {} + @Override + public java.util.Map getCStoreRequestInformation(Object association) { + return new java.util.HashMap<>(); + } + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/GenerateDicomTestFiles.java b/server/test/com/mirth/connect/connectors/dimse/dicom/GenerateDicomTestFiles.java new file mode 100644 index 0000000000..9b4150789b --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/GenerateDicomTestFiles.java @@ -0,0 +1,117 @@ +package com.mirth.connect.connectors.dimse.dicom; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.dcm4che3.data.Tag; +import org.dcm4che3.util.UIDUtils; +import org.junit.Test; + +import com.mirth.connect.connectors.dimse.dicom.DicomLibraryFactory.DicomLibrary; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomConverter; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomObject; + +/** + * Generates proper DICOM Part 10 test files for manual testing with storescu. + * Run with: ./gradlew :server:test --tests "com.mirth.connect.connectors.dimse.dicom.GenerateDicomTestFiles" + */ +public class GenerateDicomTestFiles { + + private static final String OUTPUT_DIR = "tests"; + + // SOP Class UIDs + private static final String CT_IMAGE_STORAGE = "1.2.840.10008.5.1.4.1.1.2"; + private static final String MR_IMAGE_STORAGE = "1.2.840.10008.5.1.4.1.1.4"; + private static final String US_IMAGE_STORAGE = "1.2.840.10008.5.1.4.1.1.6.1"; + + // Transfer Syntax + private static final String IMPLICIT_VR_LE = "1.2.840.10008.1.2"; + + @Test + public void generateTestFiles() throws Exception { + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE5); + try { + Path outputDir = Paths.get(OUTPUT_DIR); + Files.createDirectories(outputDir); + + createCtFile(outputDir); + createMrFile(outputDir); + createUsFile(outputDir); + + System.out.println("DICOM Part 10 test files generated in: " + outputDir.toAbsolutePath()); + } finally { + DicomLibraryFactory.resetForTesting(null); + } + } + + private void createCtFile(Path outputDir) throws IOException { + Dcm5DicomConverter converter = new Dcm5DicomConverter(); + Dcm5DicomObject obj = (Dcm5DicomObject) converter.createDicomObject(); + + obj.putString(Tag.PatientName, "PN", "Doe^John"); + obj.putString(Tag.PatientID, "LO", "PAT001"); + obj.putString(Tag.PatientBirthDate, "DA", "19800101"); + obj.putString(Tag.PatientSex, "CS", "M"); + obj.putString(Tag.Modality, "CS", "CT"); + obj.putString(Tag.StudyDate, "DA", "20230101"); + obj.putString(Tag.StudyTime, "TM", "120000"); + obj.putString(Tag.SpecificCharacterSet, "CS", "ISO_IR 100"); + obj.putString(Tag.StudyInstanceUID, "UI", UIDUtils.createUID()); + obj.putString(Tag.SeriesInstanceUID, "UI", UIDUtils.createUID()); + obj.putString(Tag.SOPInstanceUID, "UI", UIDUtils.createUID()); + obj.putString(Tag.SOPClassUID, "UI", CT_IMAGE_STORAGE); + + obj.initFileMetaInformation(CT_IMAGE_STORAGE, UIDUtils.createUID(), IMPLICIT_VR_LE); + + byte[] bytes = converter.dicomObjectToByteArray(obj); + Path file = outputDir.resolve("test-dicom-input-1.dcm"); + Files.write(file, bytes); + System.out.println("Created: " + file + " (" + bytes.length + " bytes) - CT/Doe^John/PAT001"); + } + + private void createMrFile(Path outputDir) throws IOException { + Dcm5DicomConverter converter = new Dcm5DicomConverter(); + Dcm5DicomObject obj = (Dcm5DicomObject) converter.createDicomObject(); + + obj.putString(Tag.PatientName, "PN", "Smith^Jane"); + obj.putString(Tag.PatientID, "LO", "PAT002"); + obj.putString(Tag.PatientSex, "CS", "F"); + obj.putString(Tag.Modality, "CS", "MR"); + obj.putString(Tag.StudyDate, "DA", "20230215"); + obj.putString(Tag.SpecificCharacterSet, "CS", "ISO_IR 100"); + obj.putString(Tag.StudyInstanceUID, "UI", UIDUtils.createUID()); + obj.putString(Tag.SeriesInstanceUID, "UI", UIDUtils.createUID()); + obj.putString(Tag.SOPInstanceUID, "UI", UIDUtils.createUID()); + obj.putString(Tag.SOPClassUID, "UI", MR_IMAGE_STORAGE); + + obj.initFileMetaInformation(MR_IMAGE_STORAGE, UIDUtils.createUID(), IMPLICIT_VR_LE); + + byte[] bytes = converter.dicomObjectToByteArray(obj); + Path file = outputDir.resolve("test-dicom-input-2.dcm"); + Files.write(file, bytes); + System.out.println("Created: " + file + " (" + bytes.length + " bytes) - MR/Smith^Jane/PAT002"); + } + + private void createUsFile(Path outputDir) throws IOException { + Dcm5DicomConverter converter = new Dcm5DicomConverter(); + Dcm5DicomObject obj = (Dcm5DicomObject) converter.createDicomObject(); + + obj.putString(Tag.PatientName, "PN", "Brown^Bob"); + obj.putString(Tag.PatientID, "LO", "PAT003"); + obj.putString(Tag.Modality, "CS", "US"); + obj.putString(Tag.StudyInstanceUID, "UI", UIDUtils.createUID()); + obj.putString(Tag.SeriesInstanceUID, "UI", UIDUtils.createUID()); + obj.putString(Tag.SOPInstanceUID, "UI", UIDUtils.createUID()); + obj.putString(Tag.SOPClassUID, "UI", US_IMAGE_STORAGE); + + obj.initFileMetaInformation(US_IMAGE_STORAGE, UIDUtils.createUID(), IMPLICIT_VR_LE); + + byte[] bytes = converter.dicomObjectToByteArray(obj); + Path file = outputDir.resolve("test-dicom-input-3.dcm"); + Files.write(file, bytes); + System.out.println("Created: " + file + " (" + bytes.length + " bytes) - US/Brown^Bob/PAT003"); + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DICOMConfigurationTest.java b/server/test/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DICOMConfigurationTest.java new file mode 100644 index 0000000000..453a14306f --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/dcm2/Dcm2DICOMConfigurationTest.java @@ -0,0 +1,109 @@ +package com.mirth.connect.connectors.dimse.dicom.dcm2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.util.HashMap; +import java.util.Map; + +import org.dcm4che2.net.Association; +import org.dcm4che2.net.NetworkConnection; +import org.dcm4che2.tool.dcmrcv.MirthDcmRcv; +import org.dcm4che2.tool.dcmsnd.MirthDcmSnd; +import org.junit.Test; + +import com.mirth.connect.connectors.dimse.DICOMDispatcher; +import com.mirth.connect.connectors.dimse.DICOMDispatcherProperties; +import com.mirth.connect.connectors.dimse.DICOMReceiver; +import com.mirth.connect.connectors.dimse.DICOMReceiverProperties; +import com.mirth.connect.connectors.dimse.dicom.OieDicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.OieDicomSender; +import com.mirth.connect.donkey.server.channel.Connector; + +/** + * Tests that the Dcm2DICOMConfiguration bridge defaults correctly delegate + * version-neutral calls to the legacy dcm4che2-typed methods. + */ +public class Dcm2DICOMConfigurationTest { + + @Test + public void testConfigureReceiverBridgesToConfigureDcmRcv() throws Exception { + BridgeTrackingConfig config = new BridgeTrackingConfig(); + + // Use a real Dcm2DicomReceiver wrapping a real MirthDcmRcv. + // The Dcm2DICOMConfiguration default configureReceiver calls unwrap() and casts. + MirthDcmRcv realRcv = new MirthDcmRcv(null, config); + Dcm2DicomReceiver receiver = new Dcm2DicomReceiver(realRcv); + + // Call configureReceiver — this uses the Dcm2DICOMConfiguration default bridge: + // configureReceiver → (MirthDcmRcv) receiver.unwrap() → configureDcmRcv + config.configureReceiver(receiver, null, null); + assertTrue("configureDcmRcv should have been called via bridge default", config.configureDcmRcvCalled); + } + + @Test + public void testConfigureSenderBridgesToConfigureDcmSnd() throws Exception { + BridgeTrackingConfig config = new BridgeTrackingConfig(); + + MirthDcmSnd realSnd = new MirthDcmSnd(config); + Dcm2DicomSender sender = new Dcm2DicomSender(realSnd); + + // Call configureSender — uses the Dcm2DICOMConfiguration default bridge: + // configureSender → (MirthDcmSnd) sender.unwrap() → configureDcmSnd + config.configureSender(sender, null, null); + assertTrue("configureDcmSnd should have been called via bridge default", config.configureDcmSndCalled); + } + + @Test + public void testGetCStoreRequestInfoBridgesToAssociationOverload() { + BridgeTrackingConfig config = new BridgeTrackingConfig(); + Map result = config.getCStoreRequestInformation((Object) null); + assertNotNull(result); + assertTrue("getCStoreRequestInformation(Association) should have been called", + config.getCStoreRequestInfoCalled); + } + + @Test + public void testCreateNetworkConnectionBridgesToLegacy() { + BridgeTrackingConfig config = new BridgeTrackingConfig(); + Object nc = config.createNetworkConnection(); + assertNotNull(nc); + assertTrue(nc instanceof NetworkConnection); + assertTrue("createLegacyNetworkConnection should have been called", + config.createLegacyNetworkConnectionCalled); + } + + /** + * Tracks all bridge paths. Does NOT override configureReceiver/configureSender, + * so the Dcm2DICOMConfiguration interface defaults (the real bridge logic) are exercised. + */ + private static class BridgeTrackingConfig implements Dcm2DICOMConfiguration { + boolean configureDcmRcvCalled = false; + boolean configureDcmSndCalled = false; + boolean getCStoreRequestInfoCalled = false; + boolean createLegacyNetworkConnectionCalled = false; + + @Override + public void configureConnectorDeploy(Connector connector) {} + @Override + public void configureDcmRcv(MirthDcmRcv dcmrcv, DICOMReceiver connector, DICOMReceiverProperties props) { + configureDcmRcvCalled = true; + } + @Override + public void configureDcmSnd(MirthDcmSnd dcmsnd, DICOMDispatcher connector, DICOMDispatcherProperties props) { + configureDcmSndCalled = true; + } + @Override + public Map getCStoreRequestInformation(Association association) { + getCStoreRequestInfoCalled = true; + return new HashMap<>(); + } + @Override + public NetworkConnection createLegacyNetworkConnection() { + createLegacyNetworkConnectionCalled = true; + return new NetworkConnection(); + } + } + +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DICOMConfigurationTest.java b/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DICOMConfigurationTest.java new file mode 100644 index 0000000000..2ef9efa60a --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DICOMConfigurationTest.java @@ -0,0 +1,77 @@ +package com.mirth.connect.connectors.dimse.dicom.dcm5; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.util.HashMap; +import java.util.Map; + +import org.dcm4che3.net.Association; +import org.dcm4che3.net.Connection; +import org.junit.Test; + +import com.mirth.connect.connectors.dimse.DICOMConfiguration; +import com.mirth.connect.connectors.dimse.DICOMDispatcher; +import com.mirth.connect.connectors.dimse.DICOMDispatcherProperties; +import com.mirth.connect.connectors.dimse.DICOMReceiver; +import com.mirth.connect.connectors.dimse.DICOMReceiverProperties; +import com.mirth.connect.connectors.dimse.dicom.OieDicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.OieDicomSender; +import com.mirth.connect.donkey.server.channel.Connector; + +public class Dcm5DICOMConfigurationTest { + + @Test + public void testGetCStoreRequestInfoBridgesToAssociationOverload() { + final boolean[] called = { false }; + Dcm5DICOMConfiguration config = new TestDcm5Config() { + @Override + public Map getCStoreRequestInformation(Association association) { + called[0] = true; + return new HashMap<>(); + } + }; + + config.getCStoreRequestInformation((Object) null); + assertTrue("bridge did not delegate to Association overload", called[0]); + } + + @Test + public void testCreateNetworkConnectionBridgesToDcm5Connection() { + Dcm5DICOMConfiguration config = new TestDcm5Config(); + Object conn = config.createNetworkConnection(); + assertNotNull(conn); + assertTrue(conn instanceof Connection); + } + + @Test + public void testImplementsDICOMConfiguration() { + Dcm5DICOMConfiguration config = new TestDcm5Config(); + assertTrue(config instanceof DICOMConfiguration); + } + + /** Minimal test implementation of Dcm5DICOMConfiguration. */ + private static class TestDcm5Config implements Dcm5DICOMConfiguration { + @Override + public void configureConnectorDeploy(Connector connector) {} + + @Override + public void configureDcm5Receiver(Dcm5DicomReceiver receiver, DICOMReceiver connector, + DICOMReceiverProperties connectorProperties) {} + + @Override + public void configureDcm5Sender(Dcm5DicomSender sender, DICOMDispatcher connector, + DICOMDispatcherProperties connectorProperties) {} + + @Override + public Map getCStoreRequestInformation(Association association) { + return new HashMap<>(); + } + + @Override + public Connection createDcm5Connection() { + return new Connection(); + } + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomConverterTest.java b/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomConverterTest.java new file mode 100644 index 0000000000..baf81920da --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomConverterTest.java @@ -0,0 +1,156 @@ +package com.mirth.connect.connectors.dimse.dicom.dcm5; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.Tag; +import org.dcm4che3.data.UID; +import org.dcm4che3.data.VR; +import org.dcm4che3.io.DicomOutputStream; +import org.junit.Test; + +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; + +public class Dcm5DicomConverterTest { + + private final Dcm5DicomConverter converter = new Dcm5DicomConverter(); + + @Test + public void testByteArrayRoundTripWithoutFmi() throws Exception { + Dcm5DicomObject original = new Dcm5DicomObject(); + original.putString(Tag.PatientName, "PN", "Test^Patient"); + original.putString(Tag.PatientID, "LO", "12345"); + + byte[] bytes = converter.dicomObjectToByteArray(original); + assertNotNull(bytes); + assertTrue(bytes.length > 0); + + OieDicomObject parsed = converter.byteArrayToDicomObject(bytes, false); + assertNotNull(parsed); + assertEquals("Test^Patient", parsed.getString(Tag.PatientName)); + assertEquals("12345", parsed.getString(Tag.PatientID)); + } + + @Test + public void testByteArrayRoundTripWithFmi() throws Exception { + Dcm5DicomObject original = new Dcm5DicomObject(); + original.putString(Tag.PatientName, "PN", "FmiTest"); + original.initFileMetaInformation("1.2.840.10008.5.1.4.1.1.2", "1.2.3.4.5", "1.2.840.10008.1.2"); + + byte[] bytes = converter.dicomObjectToByteArray(original); + assertNotNull(bytes); + + OieDicomObject parsed = converter.byteArrayToDicomObject(bytes, false); + assertNotNull(parsed); + assertTrue(parsed.hasFileMetaInfo()); + } + + @Test + public void testByteArrayBase64RoundTrip() throws Exception { + // Create DICOM bytes + Dcm5DicomObject original = new Dcm5DicomObject(); + original.putString(Tag.PatientName, "PN", "Base64Test"); + original.initFileMetaInformation("1.2.840.10008.5.1.4.1.1.2", "1.2.3.4.5", "1.2.840.10008.1.2"); + byte[] dicomBytes = converter.dicomObjectToByteArray(original); + + // Base64 encode + byte[] base64Bytes = Base64.getEncoder().encode(dicomBytes); + + // Parse with decodeBase64=true + OieDicomObject parsed = converter.byteArrayToDicomObject(base64Bytes, true); + assertNotNull(parsed); + } + + @Test + public void testCreateDicomObject() { + OieDicomObject obj = converter.createDicomObject(); + assertNotNull(obj); + assertTrue(obj instanceof Dcm5DicomObject); + assertFalse(obj.hasFileMetaInfo()); + } + + @Test + public void testGetElementName() { + String name = converter.getElementName(Tag.PatientName); + assertNotNull(name); + assertFalse(name.isEmpty()); + assertEquals("PatientName", name); + } + + @Test + public void testGetElementNameUnknown() { + String name = converter.getElementName(0x99999999); + assertNotNull(name); + // Unknown tags return empty string + } + + @Test + public void testDicomBytesToXml() throws Exception { + // Build a DICOM file with FMI + Attributes fmi = Attributes.createFileMetaInformation("1.2.3.4.5", "1.2.840.10008.5.1.4.1.1.2", "1.2.840.10008.1.2.1"); + Attributes dataset = new Attributes(); + dataset.setString(Tag.PatientName, VR.PN, "XmlTest^Patient"); + dataset.setString(Tag.PatientID, VR.LO, "XML123"); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DicomOutputStream dos = new DicomOutputStream(baos, UID.ExplicitVRLittleEndian); + dos.writeDataset(fmi, dataset); + dos.close(); + + byte[] base64Bytes = Base64.getEncoder().encode(baos.toByteArray()); + + String xml = converter.dicomBytesToXml(base64Bytes); + assertNotNull(xml); + assertTrue(xml.contains("PatientName") || xml.contains("00100010")); + } + + @Test + public void testXmlToDicomObject() throws Exception { + // dcm4che5 XML format uses and + String xml = "" + + "" + + "" + + "Test" + + "" + + "ID123" + + ""; + + OieDicomObject obj = converter.xmlToDicomObject(xml, "UTF-8"); + assertNotNull(obj); + assertEquals("ID123", obj.getString(Tag.PatientID)); + } + + @Test + public void testXxePrevention() { + String maliciousXml = "" + + "]>" + + "&xxe;"; + + try { + converter.xmlToDicomObject(maliciousXml, "UTF-8"); + fail("Expected exception for XXE attack"); + } catch (Exception e) { + // Expected — DOCTYPE is disallowed + } + } + + @Test + public void testDicomObjectToByteArrayClearsObject() throws Exception { + Dcm5DicomObject obj = new Dcm5DicomObject(); + obj.putString(Tag.PatientName, "PN", "ClearTest"); + + converter.dicomObjectToByteArray(obj); + + // After serialization, the object should be cleared + assertNull(obj.getString(Tag.PatientName)); + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomElementTest.java b/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomElementTest.java new file mode 100644 index 0000000000..f6cbd4202d --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomElementTest.java @@ -0,0 +1,244 @@ +package com.mirth.connect.connectors.dimse.dicom.dcm5; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.Fragments; +import org.dcm4che3.data.Sequence; +import org.dcm4che3.data.Tag; +import org.dcm4che3.data.VR; +import org.junit.Test; + +public class Dcm5DicomElementTest { + + @Test + public void testValueModeTag() { + Attributes attrs = new Attributes(); + attrs.setString(Tag.PatientName, VR.PN, "Test"); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PatientName, attrs); + assertEquals(Tag.PatientName, elem.tag()); + } + + @Test + public void testValueModeVr() { + Attributes attrs = new Attributes(); + attrs.setString(Tag.PatientName, VR.PN, "Test"); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PatientName, attrs); + assertEquals("PN", elem.vr().toString()); + } + + @Test + public void testValueModeGetValueAsString() { + Attributes attrs = new Attributes(); + attrs.setString(Tag.PatientName, VR.PN, "Doe^John"); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PatientName, attrs); + assertEquals("Doe^John", elem.getValueAsString(0)); + } + + @Test + public void testValueModeGetBytes() { + Attributes attrs = new Attributes(); + byte[] data = new byte[] { 0x01, 0x02, 0x03 }; + attrs.setBytes(Tag.PixelData, VR.OB, data); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PixelData, attrs); + assertArrayEquals(data, elem.getBytes()); + } + + @Test + public void testValueModeLength() { + Attributes attrs = new Attributes(); + byte[] data = new byte[] { 0x01, 0x02, 0x03 }; + attrs.setBytes(Tag.PixelData, VR.OB, data); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PixelData, attrs); + assertEquals(3, elem.length()); + } + + @Test + public void testValueModeHasItemsFalse() { + Attributes attrs = new Attributes(); + attrs.setString(Tag.PatientName, VR.PN, "Test"); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PatientName, attrs); + assertFalse(elem.hasItems()); + assertEquals(0, elem.countItems()); + } + + @Test + public void testValueModeGetDicomObjectNull() { + Attributes attrs = new Attributes(); + attrs.setString(Tag.PatientName, VR.PN, "Test"); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PatientName, attrs); + assertNull(elem.getDicomObject()); + } + + @Test + public void testSequenceModeVrIsSQ() { + Attributes attrs = new Attributes(); + Sequence seq = attrs.newSequence(Tag.ReferencedStudySequence, 0); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.ReferencedStudySequence, attrs, seq); + assertEquals("SQ", elem.vr().toString()); + } + + @Test + public void testSequenceModeHasItemsEmpty() { + Attributes attrs = new Attributes(); + Sequence seq = attrs.newSequence(Tag.ReferencedStudySequence, 0); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.ReferencedStudySequence, attrs, seq); + assertFalse(elem.hasItems()); + assertEquals(0, elem.countItems()); + } + + @Test + public void testSequenceModeAddAndGetDicomObject() { + Attributes attrs = new Attributes(); + Sequence seq = attrs.newSequence(Tag.ReferencedStudySequence, 0); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.ReferencedStudySequence, attrs, seq); + + Dcm5DicomObject item = new Dcm5DicomObject(); + item.putString(Tag.StudyInstanceUID, "UI", "1.2.3"); + elem.addDicomObject(item); + + assertTrue(elem.hasItems()); + assertEquals(1, elem.countItems()); + assertNotNull(elem.getDicomObject()); + assertEquals("1.2.3", elem.getDicomObject().getString(Tag.StudyInstanceUID)); + } + + @Test + public void testSequenceModeLength() { + Attributes attrs = new Attributes(); + Sequence seq = attrs.newSequence(Tag.ReferencedStudySequence, 0); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.ReferencedStudySequence, attrs, seq); + assertEquals(-1, elem.length()); + } + + @Test + public void testSequenceModeGetValueAsStringNull() { + Attributes attrs = new Attributes(); + Sequence seq = attrs.newSequence(Tag.ReferencedStudySequence, 0); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.ReferencedStudySequence, attrs, seq); + assertNull(elem.getValueAsString(0)); + } + + @Test + public void testSequenceModeGetBytesNull() { + Attributes attrs = new Attributes(); + Sequence seq = attrs.newSequence(Tag.ReferencedStudySequence, 0); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.ReferencedStudySequence, attrs, seq); + assertNull(elem.getBytes()); + } + + @Test(expected = UnsupportedOperationException.class) + public void testSequenceModeGetFragmentThrows() { + Attributes attrs = new Attributes(); + Sequence seq = attrs.newSequence(Tag.ReferencedStudySequence, 0); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.ReferencedStudySequence, attrs, seq); + elem.getFragment(0); + } + + @Test(expected = UnsupportedOperationException.class) + public void testSequenceModeAddFragmentThrows() { + Attributes attrs = new Attributes(); + Sequence seq = attrs.newSequence(Tag.ReferencedStudySequence, 0); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.ReferencedStudySequence, attrs, seq); + elem.addFragment(new byte[] { 1 }); + } + + @Test + public void testFragmentsModeAddAndGetFragment() { + Attributes attrs = new Attributes(); + Fragments frags = attrs.newFragments(Tag.PixelData, VR.OB, 2); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PixelData, attrs, frags, "OB"); + + byte[] fragment = new byte[] { 0x10, 0x20, 0x30 }; + elem.addFragment(fragment); + + assertTrue(elem.hasItems()); + assertEquals(1, elem.countItems()); + assertArrayEquals(fragment, elem.getFragment(0)); + } + + @Test + public void testFragmentsModeVr() { + Attributes attrs = new Attributes(); + Fragments frags = attrs.newFragments(Tag.PixelData, VR.OB, 0); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PixelData, attrs, frags, "OB"); + assertEquals("OB", elem.vr().toString()); + } + + @Test + public void testFragmentsModeLength() { + Attributes attrs = new Attributes(); + Fragments frags = attrs.newFragments(Tag.PixelData, VR.OB, 0); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PixelData, attrs, frags, "OB"); + assertEquals(-1, elem.length()); + } + + @Test(expected = UnsupportedOperationException.class) + public void testFragmentsModeAddDicomObjectThrows() { + Attributes attrs = new Attributes(); + Fragments frags = attrs.newFragments(Tag.PixelData, VR.OB, 0); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PixelData, attrs, frags, "OB"); + elem.addDicomObject(new Dcm5DicomObject()); + } + + @Test + public void testFragmentsModeGetDicomObjectNull() { + Attributes attrs = new Attributes(); + Fragments frags = attrs.newFragments(Tag.PixelData, VR.OB, 0); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PixelData, attrs, frags, "OB"); + assertNull(elem.getDicomObject()); + } + + @Test(expected = UnsupportedOperationException.class) + public void testValueModeGetFragmentThrows() { + Attributes attrs = new Attributes(); + attrs.setString(Tag.PatientName, VR.PN, "Test"); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PatientName, attrs); + elem.getFragment(0); + } + + @Test(expected = UnsupportedOperationException.class) + public void testValueModeAddFragmentThrows() { + Attributes attrs = new Attributes(); + attrs.setString(Tag.PatientName, VR.PN, "Test"); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PatientName, attrs); + elem.addFragment(new byte[] { 1 }); + } + + @Test(expected = UnsupportedOperationException.class) + public void testValueModeAddDicomObjectThrows() { + Attributes attrs = new Attributes(); + attrs.setString(Tag.PatientName, VR.PN, "Test"); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PatientName, attrs); + elem.addDicomObject(new Dcm5DicomObject()); + } + + @Test + public void testValueModeUnwrapReturnsParent() { + Attributes attrs = new Attributes(); + attrs.setString(Tag.PatientName, VR.PN, "Test"); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PatientName, attrs); + assertTrue(elem.unwrap() == attrs); + } + + @Test + public void testSequenceModeUnwrapReturnsSequence() { + Attributes attrs = new Attributes(); + Sequence seq = attrs.newSequence(Tag.ReferencedStudySequence, 0); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.ReferencedStudySequence, attrs, seq); + assertTrue(elem.unwrap() == seq); + } + + @Test + public void testFragmentsModeUnwrapReturnsFragments() { + Attributes attrs = new Attributes(); + Fragments frags = attrs.newFragments(Tag.PixelData, VR.OB, 0); + Dcm5DicomElement elem = new Dcm5DicomElement(Tag.PixelData, attrs, frags, "OB"); + assertTrue(elem.unwrap() == frags); + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomObjectTest.java b/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomObjectTest.java new file mode 100644 index 0000000000..0276b52dee --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomObjectTest.java @@ -0,0 +1,200 @@ +package com.mirth.connect.connectors.dimse.dicom.dcm5; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Iterator; + +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.Tag; +import org.junit.Test; + +import com.mirth.connect.connectors.dimse.dicom.OieDicomElement; + +public class Dcm5DicomObjectTest { + + @Test + public void testCreateEmpty() { + Dcm5DicomObject obj = new Dcm5DicomObject(); + assertNotNull(obj.unwrap()); + assertFalse(obj.hasFileMetaInfo()); + assertFalse(obj.bigEndian()); + } + + @Test + public void testPutAndGetString() { + Dcm5DicomObject obj = new Dcm5DicomObject(); + obj.putString(Tag.PatientName, "PN", "Doe^John"); + assertEquals("Doe^John", obj.getString(Tag.PatientName)); + } + + @Test + public void testPutAndGetInt() { + Dcm5DicomObject obj = new Dcm5DicomObject(); + obj.putInt(Tag.Rows, "US", 512); + assertEquals(512, obj.getInt(Tag.Rows)); + } + + @Test + public void testPutAndGetBytes() { + Dcm5DicomObject obj = new Dcm5DicomObject(); + byte[] data = new byte[] { 1, 2, 3, 4 }; + obj.putBytes(Tag.PixelData, "OB", data); + OieDicomElement elem = obj.get(Tag.PixelData); + assertNotNull(elem); + assertNotNull(elem.getBytes()); + assertEquals(4, elem.getBytes().length); + } + + @Test + public void testGetReturnsNullForMissingTag() { + Dcm5DicomObject obj = new Dcm5DicomObject(); + assertNull(obj.get(Tag.PatientName)); + assertNull(obj.getString(Tag.PatientName)); + assertEquals(0, obj.getInt(Tag.Rows)); + } + + @Test + public void testPutStringAcceptsObjectVrViaToString() { + // Simulates a user transformer script passing a library-specific VR constant + // (e.g., dcm4che2 VR.PN) — the Object overload delegates via toString(). + Object libraryVr = new Object() { + @Override public String toString() { return "PN"; } + }; + Dcm5DicomObject obj = new Dcm5DicomObject(); + obj.putString(Tag.PatientName, libraryVr, "Doe^John"); + assertEquals("Doe^John", obj.getString(Tag.PatientName)); + } + + @Test + public void testPutIntAcceptsObjectVrViaToString() { + Object libraryVr = new Object() { + @Override public String toString() { return "US"; } + }; + Dcm5DicomObject obj = new Dcm5DicomObject(); + obj.putInt(Tag.Rows, libraryVr, 512); + assertEquals(512, obj.getInt(Tag.Rows)); + } + + @Test + public void testPutSequence() { + Dcm5DicomObject obj = new Dcm5DicomObject(); + OieDicomElement seq = obj.putSequence(Tag.ReferencedStudySequence); + assertNotNull(seq); + assertEquals("SQ", seq.vr().toString()); + assertFalse(seq.hasItems()); + assertEquals(0, seq.countItems()); + } + + @Test + public void testSequenceAddAndGet() { + Dcm5DicomObject obj = new Dcm5DicomObject(); + OieDicomElement seq = obj.putSequence(Tag.ReferencedStudySequence); + + Dcm5DicomObject item = new Dcm5DicomObject(); + item.putString(Tag.StudyInstanceUID, "UI", "1.2.3.4"); + seq.addDicomObject(item); + + assertTrue(seq.hasItems()); + assertEquals(1, seq.countItems()); + assertNotNull(seq.getDicomObject()); + assertEquals("1.2.3.4", seq.getDicomObject().getString(Tag.StudyInstanceUID)); + } + + @Test + public void testPutFragments() { + Dcm5DicomObject obj = new Dcm5DicomObject(); + OieDicomElement frags = obj.putFragments(Tag.PixelData, "OB", false, 2); + assertNotNull(frags); + assertEquals("OB", frags.vr().toString()); + } + + @Test + public void testRemove() { + Dcm5DicomObject obj = new Dcm5DicomObject(); + obj.putString(Tag.PatientName, "PN", "Test"); + assertNotNull(obj.get(Tag.PatientName)); + + OieDicomElement removed = obj.remove(Tag.PatientName); + assertNotNull(removed); + assertNull(obj.get(Tag.PatientName)); + } + + @Test + public void testRemoveNonexistent() { + Dcm5DicomObject obj = new Dcm5DicomObject(); + assertNull(obj.remove(Tag.PatientName)); + } + + @Test + public void testClear() { + Dcm5DicomObject obj = new Dcm5DicomObject(); + obj.putString(Tag.PatientName, "PN", "Test"); + obj.putString(Tag.PatientID, "LO", "12345"); + obj.clear(); + assertNull(obj.get(Tag.PatientName)); + assertNull(obj.get(Tag.PatientID)); + } + + @Test + public void testInitFileMetaInformation() { + Dcm5DicomObject obj = new Dcm5DicomObject(); + assertFalse(obj.hasFileMetaInfo()); + + obj.initFileMetaInformation("1.2.840.10008.5.1.4.1.1.2", "1.2.3.4.5", "1.2.840.10008.1.2"); + assertTrue(obj.hasFileMetaInfo()); + assertNotNull(obj.getFmi()); + } + + @Test + public void testCommandIterator() { + Dcm5DicomObject obj = new Dcm5DicomObject(); + // Command group tags have group 0x0000 + obj.putString(0x00000002, "UI", "1.2.840.10008.1.1"); + obj.putInt(0x00000100, "US", 0x0001); + // Non-command tag should not appear + obj.putString(Tag.PatientName, "PN", "Test"); + + Iterator it = obj.commandIterator(); + int count = 0; + while (it.hasNext()) { + OieDicomElement elem = it.next(); + assertTrue((elem.tag() >>> 16) == 0x0000); + count++; + } + assertEquals(2, count); + } + + @Test + public void testWrapExistingAttributes() { + Attributes attrs = new Attributes(); + attrs.setString(Tag.PatientName, org.dcm4che3.data.VR.PN, "Wrapped"); + Dcm5DicomObject obj = new Dcm5DicomObject(attrs); + assertEquals("Wrapped", obj.getString(Tag.PatientName)); + assertTrue(obj.unwrap() == attrs); + } + + @Test + public void testWrapWithFmi() { + Attributes fmi = Attributes.createFileMetaInformation("1.2.3", "1.2.840.10008.5.1.4.1.1.2", "1.2.840.10008.1.2"); + Attributes dataset = new Attributes(); + Dcm5DicomObject obj = new Dcm5DicomObject(fmi, dataset); + assertTrue(obj.hasFileMetaInfo()); + assertTrue(obj.getFmi() == fmi); + } + + @Test + public void testDefaultGetStringReturnsDefault() { + Dcm5DicomObject obj = new Dcm5DicomObject(); + assertEquals("FALLBACK", obj.getString(Tag.PatientName, "FALLBACK")); + } + + @Test + public void testDefaultGetIntReturnsDefault() { + Dcm5DicomObject obj = new Dcm5DicomObject(); + assertEquals(42, obj.getInt(Tag.Rows, 42)); + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomReceiverTest.java b/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomReceiverTest.java new file mode 100644 index 0000000000..e1d968404c --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomReceiverTest.java @@ -0,0 +1,143 @@ +package com.mirth.connect.connectors.dimse.dicom.dcm5; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.Map; + +import org.dcm4che3.net.Association; +import org.dcm4che3.net.Device; +import org.dcm4che3.net.pdu.AAssociateAC; +import org.dcm4che3.net.pdu.AAssociateRQ; +import org.dcm4che3.net.pdu.PresentationContext; +import org.dcm4che3.net.pdu.UserIdentityRQ; +import org.junit.Test; + +import com.mirth.connect.connectors.dimse.DICOMConfiguration; +import com.mirth.connect.connectors.dimse.DICOMDispatcher; +import com.mirth.connect.connectors.dimse.DICOMDispatcherProperties; +import com.mirth.connect.connectors.dimse.DICOMReceiver; +import com.mirth.connect.connectors.dimse.DICOMReceiverProperties; +import com.mirth.connect.connectors.dimse.dicom.OieDicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.OieDicomSender; +import com.mirth.connect.donkey.server.channel.Connector; +import com.mirth.connect.donkey.server.channel.SourceConnector; + +public class Dcm5DicomReceiverTest { + + @Test + public void testConstructionCreatesDevice() { + Dcm5DicomReceiver receiver = createReceiver(); + assertNotNull(receiver); + assertNotNull(receiver.unwrap()); + assertTrue(receiver.unwrap() instanceof Device); + } + + @Test + public void testImplementsOieDicomReceiver() { + Dcm5DicomReceiver receiver = createReceiver(); + assertTrue(receiver instanceof OieDicomReceiver); + } + + /** + * Verifies that buildSourceMap produces the exact same keys as MirthDcmRcv.onCStoreRQ. + * This is the most critical parity test for the dcm5 receiver. + */ + @Test + public void testSourceMapKeysMatchMirthDcmRcv() throws Exception { + Dcm5DicomReceiver receiver = createReceiver(); + + // Mock Association with full metadata + Association as = mock(Association.class); + when(as.getLocalAET()).thenReturn("LOCAL_AE"); + when(as.getRemoteAET()).thenReturn("REMOTE_AE"); + + // Mock Socket + Socket socket = mock(Socket.class); + InetAddress localAddr = InetAddress.getByName("127.0.0.1"); + InetAddress remoteAddr = InetAddress.getByName("192.168.1.100"); + when(socket.getLocalAddress()).thenReturn(localAddr); + when(socket.getLocalPort()).thenReturn(11112); + when(socket.getRemoteSocketAddress()).thenReturn(new InetSocketAddress(remoteAddr, 50000)); + when(as.getSocket()).thenReturn(socket); + + // Mock AAssociateAC + AAssociateAC ac = mock(AAssociateAC.class); + when(ac.getProtocolVersion()).thenReturn(1); + when(ac.getImplClassUID()).thenReturn("1.2.3.4"); + when(ac.getImplVersionName()).thenReturn("DCM5TEST"); + when(ac.getApplicationContext()).thenReturn("1.2.840.10008.3.1.1.1"); + when(ac.getNumberOfPresentationContexts()).thenReturn(0); + when(as.getAAssociateAC()).thenReturn(ac); + + // Mock AAssociateRQ with UserIdentity + AAssociateRQ rq = mock(AAssociateRQ.class); + when(rq.getProtocolVersion()).thenReturn(1); + when(rq.getImplClassUID()).thenReturn("1.2.3.5"); + when(rq.getImplVersionName()).thenReturn("DCM5REQ"); + when(rq.getApplicationContext()).thenReturn("1.2.840.10008.3.1.1.1"); + when(rq.getNumberOfPresentationContexts()).thenReturn(0); + + UserIdentityRQ uid = mock(UserIdentityRQ.class); + when(uid.getUsername()).thenReturn("testuser"); + when(uid.getPasscode()).thenReturn("testpass".toCharArray()); + when(uid.getType()).thenReturn(2); // USERNAME_PASSCODE + when(rq.getUserIdentityRQ()).thenReturn(uid); + when(as.getAAssociateRQ()).thenReturn(rq); + + Map sourceMap = receiver.buildSourceMap(as); + + // Verify ALL keys that MirthDcmRcv.onCStoreRQ populates + assertEquals("LOCAL_AE", sourceMap.get("localApplicationEntityTitle")); + assertEquals("REMOTE_AE", sourceMap.get("remoteApplicationEntityTitle")); + assertEquals("127.0.0.1", sourceMap.get("localAddress")); + assertEquals(11112, sourceMap.get("localPort")); + assertEquals("192.168.1.100", sourceMap.get("remoteAddress")); + assertEquals(50000, sourceMap.get("remotePort")); + assertEquals(1, sourceMap.get("associateACProtocolVersion")); + assertEquals("1.2.3.4", sourceMap.get("associateACImplClassUID")); + assertEquals("DCM5TEST", sourceMap.get("associateACImplVersionName")); + assertEquals("1.2.840.10008.3.1.1.1", sourceMap.get("associateACApplicationContext")); + assertEquals(1, sourceMap.get("associateRQProtocolVersion")); + assertEquals("1.2.3.5", sourceMap.get("associateRQImplClassUID")); + assertEquals("DCM5REQ", sourceMap.get("associateRQImplVersionName")); + assertEquals("1.2.840.10008.3.1.1.1", sourceMap.get("associateRQApplicationContext")); + assertEquals("testuser", sourceMap.get("username")); + assertEquals("testpass", sourceMap.get("passcode")); + assertEquals("USERNAME_PASSCODE", sourceMap.get("userIdentityType")); + } + + @Test + public void testSourceMapWithNullSocket() { + Dcm5DicomReceiver receiver = createReceiver(); + Association as = mock(Association.class); + when(as.getLocalAET()).thenReturn("AE"); + when(as.getRemoteAET()).thenReturn("REMOTE"); + when(as.getSocket()).thenReturn(null); + when(as.getAAssociateAC()).thenReturn(null); + when(as.getAAssociateRQ()).thenReturn(null); + + Map sourceMap = receiver.buildSourceMap(as); + assertEquals("AE", sourceMap.get("localApplicationEntityTitle")); + assertEquals("REMOTE", sourceMap.get("remoteApplicationEntityTitle")); + // No socket keys should be present + assertTrue(!sourceMap.containsKey("localAddress")); + } + + private Dcm5DicomReceiver createReceiver() { + return new Dcm5DicomReceiver(mock(SourceConnector.class), new TestConfig()); + } + + private static class TestConfig implements DICOMConfiguration { + @Override public void configureConnectorDeploy(Connector connector) {} + @Override public void configureReceiver(OieDicomReceiver r, DICOMReceiver c, DICOMReceiverProperties p) {} + @Override public void configureSender(OieDicomSender s, DICOMDispatcher c, DICOMDispatcherProperties p) {} + @Override public Map getCStoreRequestInformation(Object association) { return new java.util.HashMap<>(); } + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomSenderTest.java b/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomSenderTest.java new file mode 100644 index 0000000000..b054cfc1f8 --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/dcm5/Dcm5DicomSenderTest.java @@ -0,0 +1,55 @@ +package com.mirth.connect.connectors.dimse.dicom.dcm5; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.util.Map; + +import org.dcm4che3.net.Device; +import org.junit.Test; + +import com.mirth.connect.connectors.dimse.DICOMConfiguration; +import com.mirth.connect.connectors.dimse.DICOMDispatcher; +import com.mirth.connect.connectors.dimse.DICOMDispatcherProperties; +import com.mirth.connect.connectors.dimse.DICOMReceiver; +import com.mirth.connect.connectors.dimse.DICOMReceiverProperties; +import com.mirth.connect.connectors.dimse.dicom.OieDicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.OieDicomSender; +import com.mirth.connect.donkey.server.channel.Connector; + +public class Dcm5DicomSenderTest { + + @Test + public void testConstructionCreatesDevice() { + Dcm5DicomSender sender = new Dcm5DicomSender(new TestConfig()); + assertNotNull(sender); + assertNotNull(sender.unwrap()); + assertTrue(sender.unwrap() instanceof Device); + } + + @Test + public void testImplementsOieDicomSender() { + Dcm5DicomSender sender = new Dcm5DicomSender(new TestConfig()); + assertTrue(sender instanceof OieDicomSender); + } + + @Test + public void testStorageCommitmentDefault() { + Dcm5DicomSender sender = new Dcm5DicomSender(new TestConfig()); + assertTrue(!sender.isStorageCommitment()); + } + + @Test + public void testSetStorageCommitment() { + Dcm5DicomSender sender = new Dcm5DicomSender(new TestConfig()); + sender.setStorageCommitment(true); + assertTrue(sender.isStorageCommitment()); + } + + private static class TestConfig implements DICOMConfiguration { + @Override public void configureConnectorDeploy(Connector connector) {} + @Override public void configureReceiver(OieDicomReceiver r, DICOMReceiver c, DICOMReceiverProperties p) {} + @Override public void configureSender(OieDicomSender s, DICOMDispatcher c, DICOMDispatcherProperties p) {} + @Override public Map getCStoreRequestInformation(Object association) { return new java.util.HashMap<>(); } + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/integration/ConverterParityIntegrationTest.java b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/ConverterParityIntegrationTest.java new file mode 100644 index 0000000000..ab87bdc173 --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/ConverterParityIntegrationTest.java @@ -0,0 +1,139 @@ +package com.mirth.connect.connectors.dimse.dicom.integration; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; + +import org.dcm4che3.data.Tag; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import com.mirth.connect.connectors.dimse.dicom.DicomLibraryFactory; +import com.mirth.connect.connectors.dimse.dicom.DicomLibraryFactory.DicomLibrary; +import com.mirth.connect.connectors.dimse.dicom.OieDicomConverter; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; +import com.mirth.connect.connectors.dimse.dicom.dcm2.Dcm2DicomConverter; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomConverter; + +/** + * Verifies semantic equivalence between dcm2 and dcm5 converters. Both converters + * must produce DICOM data that is correctly parseable by the other. + * + *

Note: Byte-level equivalence is NOT expected because: + *

    + *
  • FMI implementation version names differ
  • + *
  • dcm2 uses two-pass write (ByteCounterOutputStream), dcm5 uses single-pass
  • + *
  • Internal padding/alignment may differ
  • + *
+ */ +public class ConverterParityIntegrationTest { + + private Dcm2DicomConverter dcm2Converter; + private Dcm5DicomConverter dcm5Converter; + + @Before + public void setUp() { + dcm2Converter = new Dcm2DicomConverter(); + dcm5Converter = new Dcm5DicomConverter(); + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE5); + } + + @After + public void tearDown() { + DicomLibraryFactory.resetForTesting(null); + } + + @Test + public void testDcm5BytesParsedByDcm2() throws Exception { + // Create DICOM data using dcm5 + OieDicomObject dcm5Obj = dcm5Converter.createDicomObject(); + dcm5Obj.putString(Tag.PatientName, "PN", "Cross^Library"); + dcm5Obj.putString(Tag.PatientID, "LO", "XLIB001"); + dcm5Obj.putString(Tag.Modality, "CS", "MR"); + dcm5Obj.initFileMetaInformation( + "1.2.840.10008.5.1.4.1.1.4", // MR Image Storage + "1.2.3.4.5.6.7.8.9", + "1.2.840.10008.1.2"); // Implicit VR LE + byte[] dcm5Bytes = dcm5Converter.dicomObjectToByteArray(dcm5Obj); + + // Parse dcm5 bytes using dcm2 + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE2); + OieDicomObject parsedByDcm2 = dcm2Converter.byteArrayToDicomObject(dcm5Bytes, false); + assertEquals("Cross^Library", parsedByDcm2.getString(Tag.PatientName)); + assertEquals("XLIB001", parsedByDcm2.getString(Tag.PatientID)); + assertEquals("MR", parsedByDcm2.getString(Tag.Modality)); + } + + @Test + public void testDcm2BytesParsedByDcm5() throws Exception { + // Create DICOM data using dcm2 + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE2); + OieDicomObject dcm2Obj = dcm2Converter.createDicomObject(); + dcm2Obj.putString(Tag.PatientName, "PN", "Reverse^Test"); + dcm2Obj.putString(Tag.PatientID, "LO", "REV001"); + dcm2Obj.putString(Tag.Modality, "CS", "CT"); + dcm2Obj.initFileMetaInformation( + "1.2.840.10008.5.1.4.1.1.2", // CT Image Storage + "1.2.3.4.5.6.7.8.10", + "1.2.840.10008.1.2"); + byte[] dcm2Bytes = dcm2Converter.dicomObjectToByteArray(dcm2Obj); + + // Parse dcm2 bytes using dcm5 + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE5); + OieDicomObject parsedByDcm5 = dcm5Converter.byteArrayToDicomObject(dcm2Bytes, false); + assertEquals("Reverse^Test", parsedByDcm5.getString(Tag.PatientName)); + assertEquals("REV001", parsedByDcm5.getString(Tag.PatientID)); + assertEquals("CT", parsedByDcm5.getString(Tag.Modality)); + } + + @Test + public void testBidirectionalRoundTrip() throws Exception { + // dcm5 -> bytes -> dcm2 parse -> dcm2 bytes -> dcm5 parse + OieDicomObject original = dcm5Converter.createDicomObject(); + original.putString(Tag.PatientName, "PN", "RoundTrip^Full"); + original.putString(Tag.PatientID, "LO", "RT001"); + original.putString(Tag.StudyDescription, "LO", "Integration Test"); + original.initFileMetaInformation( + "1.2.840.10008.5.1.4.1.1.2", + "1.2.3.4.5.6.7.8.11", + "1.2.840.10008.1.2"); + byte[] dcm5Bytes = dcm5Converter.dicomObjectToByteArray(original); + + // dcm2 parses dcm5 bytes + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE2); + OieDicomObject intermediate = dcm2Converter.byteArrayToDicomObject(dcm5Bytes, false); + assertEquals("RoundTrip^Full", intermediate.getString(Tag.PatientName)); + + // dcm2 re-serializes + byte[] dcm2Bytes = dcm2Converter.dicomObjectToByteArray(intermediate); + + // dcm5 parses dcm2 bytes + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE5); + OieDicomObject final5 = dcm5Converter.byteArrayToDicomObject(dcm2Bytes, false); + assertEquals("RoundTrip^Full", final5.getString(Tag.PatientName)); + assertEquals("RT001", final5.getString(Tag.PatientID)); + assertEquals("Integration Test", final5.getString(Tag.StudyDescription)); + } + + @Test + public void testElementNameParity() { + // Both converters should return equivalent element names for standard tags. + // Note: dcm2 returns "Patient's Name", dcm5 returns "PatientName" — both are valid. + String dcm2Name = dcm2Converter.getElementName(Tag.PatientName); + String dcm5Name = dcm5Converter.getElementName(Tag.PatientName); + assertNotNull(dcm2Name); + assertNotNull(dcm5Name); + // Both should be non-empty for a well-known tag + assertNotEquals("dcm2 element name should not be empty", "", dcm2Name); + assertNotEquals("dcm5 element name should not be empty", "", dcm5Name); + + // PatientID should also be recognized by both + assertNotEquals("", dcm2Converter.getElementName(Tag.PatientID)); + assertNotEquals("", dcm5Converter.getElementName(Tag.PatientID)); + + // Modality + assertNotEquals("", dcm2Converter.getElementName(Tag.Modality)); + assertNotEquals("", dcm5Converter.getElementName(Tag.Modality)); + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/integration/CrossLibraryIntegrationTest.java b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/CrossLibraryIntegrationTest.java new file mode 100644 index 0000000000..1b56f50066 --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/CrossLibraryIntegrationTest.java @@ -0,0 +1,158 @@ +package com.mirth.connect.connectors.dimse.dicom.integration; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.nio.file.Files; + +import org.dcm4che3.data.Tag; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import com.mirth.connect.connectors.dimse.dicom.DicomLibraryFactory; +import com.mirth.connect.connectors.dimse.dicom.DicomLibraryFactory.DicomLibrary; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; +import com.mirth.connect.connectors.dimse.dicom.dcm2.Dcm2DicomConverter; +import com.mirth.connect.connectors.dimse.dicom.dcm2.Dcm2DicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.dcm2.Dcm2DicomSender; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomConverter; +import com.mirth.connect.donkey.model.message.RawMessage; +import com.mirth.connect.donkey.server.channel.SourceConnector; + +/** + * Cross-library integration tests proving that dcm4che2 and dcm4che5 can + * interoperate at the DICOM protocol level. Both libraries use different + * Java packages (org.dcm4che2 vs org.dcm4che3), so they coexist on the classpath. + */ +public class CrossLibraryIntegrationTest extends DicomIntegrationTestBase { + + private Dcm2DicomReceiver dcm2Receiver; + private Dcm2DicomSender dcm2Sender; + + @Override + public void tearDown() { + if (dcm2Sender != null) { + try { dcm2Sender.close(); } catch (Exception e) { /* ignore */ } + try { dcm2Sender.stop(); } catch (Exception e) { /* ignore */ } + dcm2Sender = null; + } + if (dcm2Receiver != null) { + try { dcm2Receiver.stop(); } catch (Exception e) { /* ignore */ } + dcm2Receiver = null; + } + super.tearDown(); + } + + @Test + public void testDcm2SenderToDcm5Receiver() throws Exception { + int port = allocatePort(); + ArgumentCaptor captor = ArgumentCaptor.forClass(RawMessage.class); + SourceConnector mockConnector = createMockSourceConnector(captor); + + // Start dcm5 receiver + receiver = startDcm5Receiver(port, mockConnector); + + // Create DICOM file using dcm2 converter + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE2); + File dcm2File = createDcm2TempFile("Dcm2^Sender", "DCM2TO5"); + + // Configure dcm2 sender + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE5); + dcm2Sender = new Dcm2DicomSender(new TestConfig()); + dcm2Sender.setRemoteHost("127.0.0.1"); + dcm2Sender.setRemotePort(port); + dcm2Sender.setCalledAET("TEST_SCP"); + dcm2Sender.setCalling("DCM2_SCU"); + dcm2Sender.addFile(dcm2File); + dcm2Sender.configureTransferCapability(); + dcm2Sender.start(); + + dcm2Sender.open(); + dcm2Sender.send((cmd, data) -> {}); + // dcm2 sender's send() is synchronous + dcm2Sender.close(); + + // Give the receiver time to finish processing + Thread.sleep(500); + + RawMessage received = captor.getValue(); + assertNotNull("Receiver should have dispatched a message", received); + assertNotNull(received.getRawBytes()); + + // Parse with dcm5 converter + Dcm5DicomConverter dcm5Converter = new Dcm5DicomConverter(); + OieDicomObject parsed = dcm5Converter.byteArrayToDicomObject(received.getRawBytes(), false); + assertEquals("Dcm2^Sender", parsed.getString(Tag.PatientName)); + assertEquals("DCM2TO5", parsed.getString(Tag.PatientID)); + } + + @Test + public void testDcm5SenderToDcm2Receiver() throws Exception { + int port = allocatePort(); + ArgumentCaptor captor = ArgumentCaptor.forClass(RawMessage.class); + SourceConnector mockConnector = createMockSourceConnector(captor); + + // Start dcm2 receiver + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE2); + dcm2Receiver = new Dcm2DicomReceiver(mockConnector, new TestConfig()); + dcm2Receiver.setHostname("127.0.0.1"); + dcm2Receiver.setPort(port); + dcm2Receiver.setAEtitle("DCM2_SCP"); + dcm2Receiver.setDestination(tempFolder.getRoot().getAbsolutePath()); + dcm2Receiver.setTransferSyntax(new String[] { "1.2.840.10008.1.2", "1.2.840.10008.1.2.1" }); + dcm2Receiver.initTransferCapability(); + dcm2Receiver.start(); + waitForPort(port, 2000); + + // Create dcm5 DICOM file and send with dcm5 sender + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE5); + File dcm5File = createDicomTempFile("Dcm5^Sender", "DCM5TO2"); + sender = configureDcm5Sender(port, dcm5File); + sender.setCalledAET("DCM2_SCP"); + + CapturingDimseRspHandler handler = new CapturingDimseRspHandler(1); + sender.open(); + sender.send(handler); + assertTrue("DIMSE response not received", handler.awaitResponses(5000)); + sender.close(); + + // Give dcm2 receiver time to process + Thread.sleep(500); + + RawMessage received = captor.getValue(); + assertNotNull("dcm2 receiver should have dispatched a message", received); + assertNotNull(received.getRawBytes()); + + // Parse with dcm2 converter + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE2); + Dcm2DicomConverter dcm2Converter = new Dcm2DicomConverter(); + OieDicomObject parsed = dcm2Converter.byteArrayToDicomObject(received.getRawBytes(), false); + assertEquals("Dcm5^Sender", parsed.getString(Tag.PatientName)); + assertEquals("DCM5TO2", parsed.getString(Tag.PatientID)); + } + + /** + * Creates a temp DICOM file using the dcm2 converter. + */ + private File createDcm2TempFile(String patientName, String patientId) throws Exception { + Dcm2DicomConverter converter = new Dcm2DicomConverter(); + OieDicomObject obj = converter.createDicomObject(); + obj.putString(Tag.PatientName, "PN", patientName); + obj.putString(Tag.PatientID, "LO", patientId); + // Use dcm4che2's UID generation for a pure dcm2 test artifact + obj.putString(Tag.StudyInstanceUID, "UI", org.dcm4che2.util.UIDUtils.createUID()); + obj.putString(Tag.SeriesInstanceUID, "UI", org.dcm4che2.util.UIDUtils.createUID()); + obj.putString(Tag.SOPInstanceUID, "UI", org.dcm4che2.util.UIDUtils.createUID()); + obj.putString(Tag.Modality, "CS", "CT"); + obj.initFileMetaInformation( + "1.2.840.10008.5.1.4.1.1.2", + org.dcm4che2.util.UIDUtils.createUID(), + "1.2.840.10008.1.2"); + byte[] bytes = converter.dicomObjectToByteArray(obj); + File tempFile = tempFolder.newFile(patientId + ".dcm"); + Files.write(tempFile.toPath(), bytes); + return tempFile; + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/integration/Dcm5CEchoIntegrationTest.java b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/Dcm5CEchoIntegrationTest.java new file mode 100644 index 0000000000..bd469de2a6 --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/Dcm5CEchoIntegrationTest.java @@ -0,0 +1,76 @@ +package com.mirth.connect.connectors.dimse.dicom.integration; + +import static org.junit.Assert.assertEquals; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +import org.dcm4che3.data.Tag; +import org.dcm4che3.data.UID; +import org.dcm4che3.net.ApplicationEntity; +import org.dcm4che3.net.Connection; +import org.dcm4che3.net.Device; +import org.dcm4che3.net.DimseRSP; +import org.dcm4che3.net.Association; +import org.dcm4che3.net.pdu.AAssociateRQ; +import org.dcm4che3.net.pdu.PresentationContext; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import com.mirth.connect.donkey.model.message.RawMessage; +import com.mirth.connect.donkey.server.channel.SourceConnector; + +/** + * Integration test verifying that the dcm5 receiver responds to C-ECHO requests. + * Uses raw dcm4che5 API to send C-ECHO because OieDicomSender only supports C-STORE. + */ +public class Dcm5CEchoIntegrationTest extends DicomIntegrationTestBase { + + @Test + public void testCEchoSuccess() throws Exception { + int port = allocatePort(); + ArgumentCaptor captor = ArgumentCaptor.forClass(RawMessage.class); + SourceConnector mockConnector = createMockSourceConnector(captor); + + receiver = startDcm5Receiver(port, mockConnector); + + // Build a raw dcm4che5 SCU to send C-ECHO + Device echoDevice = new Device("ECHO_SCU"); + Connection echoConn = new Connection(); + echoDevice.addConnection(echoConn); + ApplicationEntity echoAE = new ApplicationEntity("ECHO_SCU"); + echoAE.addConnection(echoConn); + echoDevice.addApplicationEntity(echoAE); + ExecutorService executor = Executors.newCachedThreadPool(); + ScheduledExecutorService scheduled = Executors.newSingleThreadScheduledExecutor(); + echoDevice.setExecutor(executor); + echoDevice.setScheduledExecutor(scheduled); + + Connection remoteConn = new Connection(); + remoteConn.setHostname("127.0.0.1"); + remoteConn.setPort(port); + + AAssociateRQ aarq = new AAssociateRQ(); + aarq.setCalledAET("TEST_SCP"); + aarq.setCallingAET("ECHO_SCU"); + aarq.addPresentationContext( + new PresentationContext(1, UID.Verification, UID.ImplicitVRLittleEndian)); + + Association as = null; + try { + as = echoAE.connect(echoConn, remoteConn, aarq); + DimseRSP rsp = as.cecho(); + rsp.next(); + int status = rsp.getCommand().getInt(Tag.Status, -1); + assertEquals("C-ECHO should return success status", 0, status); + } finally { + if (as != null) { + try { as.release(); } catch (Exception e) { /* ignore */ } + } + echoDevice.unbindConnections(); + executor.shutdownNow(); + scheduled.shutdownNow(); + } + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/integration/Dcm5ErrorHandlingIntegrationTest.java b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/Dcm5ErrorHandlingIntegrationTest.java new file mode 100644 index 0000000000..8cadc2a9b2 --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/Dcm5ErrorHandlingIntegrationTest.java @@ -0,0 +1,134 @@ +package com.mirth.connect.connectors.dimse.dicom.integration; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; + +import org.dcm4che3.data.Tag; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import com.mirth.connect.donkey.model.message.RawMessage; +import com.mirth.connect.donkey.model.message.Response; +import com.mirth.connect.donkey.server.channel.Channel; +import com.mirth.connect.donkey.server.channel.DispatchResult; +import com.mirth.connect.donkey.server.channel.SourceConnector; + +/** + * Integration tests for error handling scenarios: connection refused, + * receiver stop/restart, and dispatch errors. + */ +public class Dcm5ErrorHandlingIntegrationTest extends DicomIntegrationTestBase { + + @Test + public void testConnectionRefused() throws Exception { + int port = allocatePort(); + // No receiver started — port is unbound + File dicomFile = createDicomTempFile("NoReceiver^Test", "ERR001"); + sender = configureDcm5Sender(port, dicomFile); + + try { + sender.open(); + fail("open() should throw when no receiver is listening"); + } catch (Exception e) { + // Expected: connection refused or similar + assertNotNull(e); + } + + // Verify clean shutdown after connection failure + sender.close(); + sender.stop(); + sender = null; + } + + @Test + public void testReceiverStopAndRestart() throws Exception { + int port = allocatePort(); + ArgumentCaptor captor = ArgumentCaptor.forClass(RawMessage.class); + SourceConnector mockConnector = createMockSourceConnector(captor); + + // Start receiver, send successfully + receiver = startDcm5Receiver(port, mockConnector); + File dicomFile = createDicomTempFile("StopRestart^Test", "SR001"); + sender = configureDcm5Sender(port, dicomFile); + + CapturingDimseRspHandler handler1 = new CapturingDimseRspHandler(1); + sender.open(); + sender.send(handler1); + assertTrue("First send should succeed", handler1.awaitResponses(5000)); + sender.close(); + sender.stop(); + sender = null; + + // Stop receiver + receiver.stop(); + receiver = null; + + // Verify send fails when receiver is stopped + File dicomFile2 = createDicomTempFile("StopRestart^Fail", "SR002"); + sender = configureDcm5Sender(port, dicomFile2); + try { + sender.open(); + fail("open() should throw when receiver is stopped"); + } catch (Exception e) { + // Expected + } + sender.close(); + sender.stop(); + sender = null; + + // Restart receiver on same port + receiver = startDcm5Receiver(port, mockConnector); + File dicomFile3 = createDicomTempFile("StopRestart^Restart", "SR003"); + sender = configureDcm5Sender(port, dicomFile3); + + CapturingDimseRspHandler handler3 = new CapturingDimseRspHandler(1); + sender.open(); + sender.send(handler3); + assertTrue("Send after restart should succeed", handler3.awaitResponses(5000)); + assertEquals(Integer.valueOf(0), handler3.getStatuses().get(0)); + } + + @Test + public void testReceiverRejectsOnDispatchError() throws Exception { + int port = allocatePort(); + + // Create a mock SourceConnector where dispatchRawMessage returns an error response + Channel mockChannel = mock(Channel.class); + when(mockChannel.getName()).thenReturn("errorChannel"); + + SourceConnector mockConnector = mock(SourceConnector.class); + when(mockConnector.getChannel()).thenReturn(mockChannel); + when(mockConnector.getChannelId()).thenReturn("errorChannelId"); + + Response errorResponse = new Response( + com.mirth.connect.donkey.model.message.Status.ERROR, + "Simulated dispatch failure"); + DispatchResult errorResult = mock(DispatchResult.class); + when(errorResult.getSelectedResponse()).thenReturn(errorResponse); + when(mockConnector.dispatchRawMessage(any(RawMessage.class))).thenReturn(errorResult); + + receiver = startDcm5Receiver(port, mockConnector); + File dicomFile = createDicomTempFile("Error^Test", "DISP001"); + sender = configureDcm5Sender(port, dicomFile); + + CapturingDimseRspHandler handler = new CapturingDimseRspHandler(1); + sender.open(); + sender.send(handler); + assertTrue("DIMSE response should be received", handler.awaitResponses(5000)); + sender.close(); + + // Receiver should have sent a non-success status back to the sender + assertEquals(1, handler.getStatuses().size()); + int status = handler.getStatuses().get(0); + assertTrue("Status should indicate failure (non-zero), got: 0x" + Integer.toHexString(status), + status != 0); + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/integration/Dcm5LoopbackIntegrationTest.java b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/Dcm5LoopbackIntegrationTest.java new file mode 100644 index 0000000000..3be0e081d6 --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/Dcm5LoopbackIntegrationTest.java @@ -0,0 +1,231 @@ +package com.mirth.connect.connectors.dimse.dicom.integration; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.dcm4che3.data.Tag; +import org.dcm4che3.util.UIDUtils; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import com.mirth.connect.connectors.dimse.dicom.OieDicomElement; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomConverter; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomObject; +import com.mirth.connect.donkey.model.message.RawMessage; +import com.mirth.connect.donkey.server.channel.SourceConnector; + +/** + * Integration tests verifying dcm5 sender -> dcm5 receiver over real TCP sockets. + * These are the highest-value tests for the dcm4che5 backend — they prove the + * full DICOM C-STORE lifecycle works end-to-end. + */ +public class Dcm5LoopbackIntegrationTest extends DicomIntegrationTestBase { + + @Test + public void testSendSingleFile() throws Exception { + int port = allocatePort(); + ArgumentCaptor captor = ArgumentCaptor.forClass(RawMessage.class); + SourceConnector mockConnector = createMockSourceConnector(captor); + + receiver = startDcm5Receiver(port, mockConnector); + File dicomFile = createDicomTempFile("Doe^John", "PAT001"); + sender = configureDcm5Sender(port, dicomFile); + + CapturingDimseRspHandler handler = new CapturingDimseRspHandler(1); + sender.open(); + sender.send(handler); + assertTrue("DIMSE response not received within timeout", handler.awaitResponses(5000)); + sender.close(); + + // Verify receiver got the message + RawMessage received = captor.getValue(); + assertNotNull(received); + assertNotNull(received.getRawBytes()); + assertTrue(received.getRawBytes().length > 0); + + // Parse received bytes and verify DICOM data integrity + Dcm5DicomConverter converter = new Dcm5DicomConverter(); + OieDicomObject parsed = converter.byteArrayToDicomObject(received.getRawBytes(), false); + assertEquals("Doe^John", parsed.getString(Tag.PatientName)); + assertEquals("PAT001", parsed.getString(Tag.PatientID)); + + // Verify sender got a success response + assertEquals(1, handler.getStatuses().size()); + assertEquals(Integer.valueOf(0), handler.getStatuses().get(0)); + } + + @Test + public void testSourceMapFromRealAssociation() throws Exception { + int port = allocatePort(); + ArgumentCaptor captor = ArgumentCaptor.forClass(RawMessage.class); + SourceConnector mockConnector = createMockSourceConnector(captor); + + receiver = startDcm5Receiver(port, mockConnector); + File dicomFile = createDicomTempFile("Smith^Jane", "PAT002"); + sender = configureDcm5Sender(port, dicomFile); + + CapturingDimseRspHandler handler = new CapturingDimseRspHandler(1); + sender.open(); + sender.send(handler); + assertTrue("DIMSE response not received within timeout", handler.awaitResponses(5000)); + sender.close(); + + RawMessage received = captor.getValue(); + Map sourceMap = received.getSourceMap(); + assertNotNull(sourceMap); + + // Verify key sourceMap entries from a real association (not mocked) + assertNotNull("localApplicationEntityTitle", sourceMap.get("localApplicationEntityTitle")); + assertEquals("TEST_SCU", sourceMap.get("remoteApplicationEntityTitle")); + assertNotNull("localAddress should be set", sourceMap.get("localAddress")); + assertEquals(port, sourceMap.get("localPort")); + assertNotNull("remoteAddress should be set", sourceMap.get("remoteAddress")); + assertNotNull("remotePort should be set", sourceMap.get("remotePort")); + + // Association metadata from a real DICOM handshake + assertNotNull("associateRQImplClassUID should be set", sourceMap.get("associateRQImplClassUID")); + } + + @Test + public void testSendMultipleFiles() throws Exception { + int port = allocatePort(); + ArgumentCaptor captor = ArgumentCaptor.forClass(RawMessage.class); + SourceConnector mockConnector = createMockSourceConnector(captor); + + receiver = startDcm5Receiver(port, mockConnector); + + File file1 = createDicomTempFile("Alpha^Patient", "MULTI001"); + File file2 = createDicomTempFile("Beta^Patient", "MULTI002"); + File file3 = createDicomTempFile("Gamma^Patient", "MULTI003"); + + sender = configureDcm5Sender(port, file1, file2, file3); + + CapturingDimseRspHandler handler = new CapturingDimseRspHandler(3); + sender.open(); + sender.send(handler); + assertTrue("All DIMSE responses not received within timeout", handler.awaitResponses(10000)); + sender.close(); + + // Verify all 3 messages were dispatched + List allMessages = captor.getAllValues(); + assertEquals(3, allMessages.size()); + + // Verify each message has valid DICOM data + Dcm5DicomConverter converter = new Dcm5DicomConverter(); + List receivedNames = new ArrayList<>(); + for (RawMessage msg : allMessages) { + assertNotNull(msg.getRawBytes()); + OieDicomObject parsed = converter.byteArrayToDicomObject(msg.getRawBytes(), false); + receivedNames.add(parsed.getString(Tag.PatientName)); + } + + assertTrue("Should contain Alpha^Patient", receivedNames.contains("Alpha^Patient")); + assertTrue("Should contain Beta^Patient", receivedNames.contains("Beta^Patient")); + assertTrue("Should contain Gamma^Patient", receivedNames.contains("Gamma^Patient")); + + // All 3 should have success status + assertEquals(3, handler.getStatuses().size()); + for (int status : handler.getStatuses()) { + assertEquals(0, status); + } + } + + @Test + public void testFullLifecycleNoExceptions() throws Exception { + int port = allocatePort(); + ArgumentCaptor captor = ArgumentCaptor.forClass(RawMessage.class); + SourceConnector mockConnector = createMockSourceConnector(captor); + + receiver = startDcm5Receiver(port, mockConnector); + File dicomFile = createDicomTempFile("Lifecycle^Test", "LIFE001"); + sender = configureDcm5Sender(port, dicomFile); + + CapturingDimseRspHandler handler = new CapturingDimseRspHandler(1); + sender.open(); + sender.send(handler); + assertTrue("DIMSE response not received", handler.awaitResponses(5000)); + sender.close(); + sender.stop(); + sender = null; + + receiver.stop(); + receiver = null; + } + + @Test + public void testSendWithSequenceAndFragmentData() throws Exception { + int port = allocatePort(); + ArgumentCaptor captor = ArgumentCaptor.forClass(RawMessage.class); + SourceConnector mockConnector = createMockSourceConnector(captor); + + receiver = startDcm5Receiver(port, mockConnector); + + // Build a DICOM object with a nested sequence and encapsulated pixel data fragments + Dcm5DicomConverter converter = new Dcm5DicomConverter(); + Dcm5DicomObject obj = (Dcm5DicomObject) converter.createDicomObject(); + obj.putString(Tag.PatientName, "PN", "Complex^Data"); + obj.putString(Tag.PatientID, "LO", "SEQ001"); + obj.putString(Tag.Modality, "CS", "CT"); + obj.putString(Tag.StudyInstanceUID, "UI", UIDUtils.createUID()); + obj.putString(Tag.SeriesInstanceUID, "UI", UIDUtils.createUID()); + obj.putString(Tag.SOPInstanceUID, "UI", UIDUtils.createUID()); + + // Add a Referenced Study Sequence with a nested item + OieDicomElement seq = obj.putSequence(Tag.ReferencedStudySequence); + OieDicomObject seqItem = converter.createDicomObject(); + seqItem.putString(Tag.ReferencedSOPClassUID, "UI", "1.2.840.10008.3.1.2.3.1"); + seqItem.putString(Tag.ReferencedSOPInstanceUID, "UI", UIDUtils.createUID()); + seq.addDicomObject(seqItem); + + // Add encapsulated pixel data (OB fragments with small synthetic frames) + OieDicomElement frags = obj.putFragments(Tag.PixelData, "OB", false, 3); + frags.addFragment(new byte[0]); // offset table (empty) + frags.addFragment(new byte[] { (byte) 0xFF, (byte) 0xD8, 0x01, 0x02 }); // frame 1 + frags.addFragment(new byte[] { (byte) 0xFF, (byte) 0xD8, 0x03, 0x04 }); // frame 2 + + obj.initFileMetaInformation( + "1.2.840.10008.5.1.4.1.1.2", + UIDUtils.createUID(), + "1.2.840.10008.1.2"); + byte[] bytes = converter.dicomObjectToByteArray(obj); + File tempFile = tempFolder.newFile("complex.dcm"); + Files.write(tempFile.toPath(), bytes); + + sender = configureDcm5Sender(port, tempFile); + + CapturingDimseRspHandler handler = new CapturingDimseRspHandler(1); + sender.open(); + sender.send(handler); + assertTrue("DIMSE response not received", handler.awaitResponses(5000)); + sender.close(); + + // Verify complex data survived the network round-trip + RawMessage received = captor.getValue(); + assertNotNull(received); + Dcm5DicomConverter parseConverter = new Dcm5DicomConverter(); + OieDicomObject parsed = parseConverter.byteArrayToDicomObject(received.getRawBytes(), false); + assertEquals("Complex^Data", parsed.getString(Tag.PatientName)); + + // Verify sequence survived + OieDicomElement parsedSeq = parsed.get(Tag.ReferencedStudySequence); + assertNotNull("Sequence should survive network transfer", parsedSeq); + assertTrue("Sequence should have items", parsedSeq.hasItems()); + assertEquals(1, parsedSeq.countItems()); + assertEquals("1.2.840.10008.3.1.2.3.1", + parsedSeq.getDicomObject().getString(Tag.ReferencedSOPClassUID)); + + // Verify pixel data fragments survived + OieDicomElement parsedFrags = parsed.get(Tag.PixelData); + assertNotNull("Pixel data should survive network transfer", parsedFrags); + + assertEquals(Integer.valueOf(0), handler.getStatuses().get(0)); + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/integration/Dcm5StorageCommitmentIntegrationTest.java b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/Dcm5StorageCommitmentIntegrationTest.java new file mode 100644 index 0000000000..d72478f7b9 --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/Dcm5StorageCommitmentIntegrationTest.java @@ -0,0 +1,186 @@ +package com.mirth.connect.connectors.dimse.dicom.integration; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.Tag; +import org.dcm4che3.data.UID; +import org.dcm4che3.data.VR; +import org.dcm4che3.net.ApplicationEntity; +import org.dcm4che3.net.Association; +import org.dcm4che3.net.Commands; +import org.dcm4che3.net.Connection; +import org.dcm4che3.net.Device; +import org.dcm4che3.net.Dimse; +import org.dcm4che3.net.PDVInputStream; +import org.dcm4che3.net.Status; +import org.dcm4che3.net.TransferCapability; +import org.dcm4che3.net.pdu.PresentationContext; +import org.dcm4che3.net.service.AbstractDicomService; +import org.dcm4che3.net.service.BasicCEchoSCP; +import org.dcm4che3.net.service.BasicCStoreSCP; +import org.dcm4che3.net.service.DicomServiceRegistry; +import org.junit.Test; + +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; + +/** + * Integration test for the dcm5 sender's storage commitment protocol flow. + * + *

Uses a custom test SCP (not Dcm5DicomReceiver) that handles both C-STORE + * and N-ACTION (Storage Commitment), then responds with N-EVENT-REPORT. This + * tests the sender's commit() and waitForStgCmtResult() methods end-to-end. + */ +public class Dcm5StorageCommitmentIntegrationTest extends DicomIntegrationTestBase { + + private Device scpDevice; + private ExecutorService scpExecutor; + private ScheduledExecutorService scpScheduled; + + @Override + public void tearDown() { + if (scpDevice != null) { + scpDevice.unbindConnections(); + } + if (scpExecutor != null) { + scpExecutor.shutdownNow(); + } + if (scpScheduled != null) { + scpScheduled.shutdownNow(); + } + super.tearDown(); + } + + @Test + public void testStorageCommitmentEndToEnd() throws Exception { + int port = allocatePort(); + + // Build a test SCP that accepts C-STORE and handles storage commitment N-ACTION + startStgCmtScp(port); + + File dicomFile = createDicomTempFile("StgCmt^Test", "STGCMT001"); + + // Build sender manually (not via helper) so we can enable storage commitment before start() + sender = new com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomSender(new TestConfig()); + sender.setRemoteHost("127.0.0.1"); + sender.setRemotePort(port); + sender.setCalledAET("STGCMT_SCP"); + sender.setCalling("TEST_SCU"); + sender.setStorageCommitment(true); + sender.addFile(dicomFile); + sender.configureTransferCapability(); + sender.start(); + + CapturingDimseRspHandler handler = new CapturingDimseRspHandler(1); + sender.open(); + sender.send(handler); + assertTrue("C-STORE response not received", handler.awaitResponses(5000)); + + // Request storage commitment + boolean commitResult = sender.commit(); + assertTrue("commit() should return true", commitResult); + + // Wait for the N-EVENT-REPORT from the SCP + OieDicomObject stgCmtResult = sender.waitForStgCmtResult(); + assertNotNull("Storage commitment result should not be null", stgCmtResult); + + sender.close(); + } + + /** + * Starts a raw dcm4che5 SCP that accepts C-STORE and responds to + * Storage Commitment N-ACTION by sending back an N-EVENT-REPORT with success. + */ + private void startStgCmtScp(int port) throws Exception { + scpDevice = new Device("STGCMT_SCP"); + Connection conn = new Connection(); + conn.setHostname("127.0.0.1"); + conn.setPort(port); + scpDevice.addConnection(conn); + + ApplicationEntity ae = new ApplicationEntity("STGCMT_SCP"); + ae.setAssociationAcceptor(true); + ae.addConnection(conn); + scpDevice.addApplicationEntity(ae); + + // Accept any SOP class for C-STORE, plus Verification and Storage Commitment + ae.addTransferCapability(new TransferCapability(null, "*", + TransferCapability.Role.SCP, "1.2.840.10008.1.2", "1.2.840.10008.1.2.1")); + ae.addTransferCapability(new TransferCapability(null, UID.Verification, + TransferCapability.Role.SCP, UID.ImplicitVRLittleEndian)); + ae.addTransferCapability(new TransferCapability(null, UID.StorageCommitmentPushModel, + TransferCapability.Role.SCP, UID.ImplicitVRLittleEndian)); + + DicomServiceRegistry services = new DicomServiceRegistry(); + services.addDicomService(new BasicCEchoSCP()); + + // C-STORE handler — just accept everything + services.addDicomService(new BasicCStoreSCP("*") { + @Override + protected void store(Association as, PresentationContext pc, Attributes rq, + PDVInputStream data, Attributes rsp) throws IOException { + // Read and discard the data stream to complete the transfer + data.skipAll(); + } + }); + + // Storage Commitment N-ACTION handler — respond with success N-EVENT-REPORT + services.addDicomService(new AbstractDicomService(UID.StorageCommitmentPushModel) { + @Override + public void onDimseRQ(Association as, PresentationContext pc, + Dimse dimse, Attributes rq, Attributes data) throws IOException { + if (dimse == Dimse.N_ACTION_RQ) { + // Send N-ACTION response (success) + Attributes actionRsp = Commands.mkNActionRSP(rq, Status.Success); + as.tryWriteDimseRSP(pc, actionRsp); + + // Now send N-EVENT-REPORT back to the sender with the committed references + try { + Attributes eventInfo = new Attributes(); + eventInfo.setString(Tag.TransactionUID, VR.UI, + data.getString(Tag.TransactionUID)); + + // Copy the ReferencedSOPSequence from the action data + org.dcm4che3.data.Sequence srcSeq = data.getSequence(Tag.ReferencedSOPSequence); + if (srcSeq != null) { + org.dcm4che3.data.Sequence destSeq = + eventInfo.newSequence(Tag.ReferencedSOPSequence, srcSeq.size()); + for (Attributes item : srcSeq) { + destSeq.add(new Attributes(item)); + } + } + + // Open a reverse association to deliver N-EVENT-REPORT + // In dcm4che5, the SCP typically sends N-EVENT-REPORT on a new association + // to the SCU. But some implementations send it on the same association. + // For simplicity, send on the existing association using the same PC. + as.neventReport( + UID.StorageCommitmentPushModel, + UID.StorageCommitmentPushModelInstance, + 1, // eventTypeID = 1 (Storage Commitment Request Successful) + eventInfo, UID.ImplicitVRLittleEndian, + new org.dcm4che3.net.DimseRSPHandler(as.nextMessageID())); + } catch (Exception e) { + throw new IOException("Failed to send N-EVENT-REPORT", e); + } + } + } + }); + + scpDevice.setDimseRQHandler(services); + + scpExecutor = Executors.newCachedThreadPool(); + scpScheduled = Executors.newSingleThreadScheduledExecutor(); + scpDevice.setExecutor(scpExecutor); + scpDevice.setScheduledExecutor(scpScheduled); + scpDevice.bindConnections(); + waitForPort(port, 2000); + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/integration/Dcm5TlsIntegrationTest.java b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/Dcm5TlsIntegrationTest.java new file mode 100644 index 0000000000..5a078db23a --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/Dcm5TlsIntegrationTest.java @@ -0,0 +1,164 @@ +package com.mirth.connect.connectors.dimse.dicom.integration; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.FileOutputStream; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.Security; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Date; + +import javax.security.auth.x500.X500Principal; + +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.dcm4che3.data.Tag; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomConverter; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomSender; +import com.mirth.connect.donkey.model.message.RawMessage; +import com.mirth.connect.donkey.server.channel.SourceConnector; + +/** + * Integration test verifying TLS-encrypted DICOM communication through the + * production Dcm5DicomReceiver and Dcm5DicomSender code paths. Generates + * ephemeral self-signed keystores at test time. + * + *

Uses the new setTlsCipherSuites() and setKeyStoreType() interface methods + * to configure modern cipher suites (the legacy preset methods use suites + * disabled in Java 21). + */ +public class Dcm5TlsIntegrationTest extends DicomIntegrationTestBase { + + // Cipher suite supported in Java 21 with RSA keys + private static final String[] CIPHER_SUITES = { "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" }; + private static final String[] PROTOCOLS = { "TLSv1.2" }; + + @Test + public void testTlsEncryptedCStore() throws Exception { + // Generate ephemeral keystore and truststore + KeyStore ks = generateSelfSignedKeyStore("CN=DICOM-Test", "changeit"); + File keyStoreFile = tempFolder.newFile("test-keystore.jks"); + File trustStoreFile = tempFolder.newFile("test-truststore.jks"); + + try (FileOutputStream fos = new FileOutputStream(keyStoreFile)) { + ks.store(fos, "changeit".toCharArray()); + } + KeyStore ts = KeyStore.getInstance("JKS"); + ts.load(null, "changeit".toCharArray()); + ts.setCertificateEntry("test", ks.getCertificate("key")); + try (FileOutputStream fos = new FileOutputStream(trustStoreFile)) { + ts.store(fos, "changeit".toCharArray()); + } + + String ksUrl = keyStoreFile.toURI().toString(); + String tsUrl = trustStoreFile.toURI().toString(); + int port = allocatePort(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RawMessage.class); + SourceConnector mockConnector = createMockSourceConnector(captor); + + // Configure TLS receiver through the production code path + Dcm5DicomReceiver rcv = new Dcm5DicomReceiver(mockConnector, new TestConfig()); + rcv.setHostname("127.0.0.1"); + rcv.setPort(port); + rcv.setAEtitle("TLS_SCP"); + rcv.setTransferSyntax(new String[] { "1.2.840.10008.1.2", "1.2.840.10008.1.2.1" }); + rcv.setTlsCipherSuites(CIPHER_SUITES); + rcv.setTlsProtocol(PROTOCOLS); + rcv.setTlsNeedClientAuth(true); + rcv.setKeyStoreURL(ksUrl); + rcv.setKeyStorePassword("changeit"); + rcv.setKeyPassword("changeit"); + rcv.setKeyStoreType("JKS"); + rcv.setTrustStoreURL(tsUrl); + rcv.setTrustStorePassword("changeit"); + rcv.setTrustStoreType("JKS"); + rcv.initTLS(); + rcv.initTransferCapability(); + rcv.start(); + receiver = rcv; + waitForPort(port, 3000); + + // Configure TLS sender through the production code path + File dicomFile = createDicomTempFile("Tls^Test", "TLS001"); + Dcm5DicomSender snd = new Dcm5DicomSender(new TestConfig()); + snd.setRemoteHost("127.0.0.1"); + snd.setRemotePort(port); + snd.setCalledAET("TLS_SCP"); + snd.setCalling("TLS_SCU"); + snd.setTlsCipherSuites(CIPHER_SUITES); + snd.setTlsProtocol(PROTOCOLS); + snd.setKeyStoreURL(ksUrl); + snd.setKeyStorePassword("changeit"); + snd.setKeyPassword("changeit"); + snd.setKeyStoreType("JKS"); + snd.setTrustStoreURL(tsUrl); + snd.setTrustStorePassword("changeit"); + snd.setTrustStoreType("JKS"); + snd.initTLS(); + snd.addFile(dicomFile); + snd.configureTransferCapability(); + snd.start(); + sender = snd; + + CapturingDimseRspHandler handler = new CapturingDimseRspHandler(1); + sender.open(); + sender.send(handler); + assertTrue("TLS DIMSE response not received", handler.awaitResponses(5000)); + sender.close(); + + // Verify data integrity over TLS + RawMessage received = captor.getValue(); + assertNotNull("Message should be received over TLS", received); + Dcm5DicomConverter converter = new Dcm5DicomConverter(); + OieDicomObject parsed = converter.byteArrayToDicomObject(received.getRawBytes(), false); + assertEquals("Tls^Test", parsed.getString(Tag.PatientName)); + assertEquals("TLS001", parsed.getString(Tag.PatientID)); + assertEquals(Integer.valueOf(0), handler.getStatuses().get(0)); + } + + private KeyStore generateSelfSignedKeyStore(String dn, String password) throws Exception { + if (Security.getProvider("BC") == null) { + Security.addProvider(new BouncyCastleProvider()); + } + + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048, new SecureRandom()); + KeyPair kp = kpg.generateKeyPair(); + + X500Principal subject = new X500Principal(dn); + long now = System.currentTimeMillis(); + Date notBefore = new Date(now); + Date notAfter = new Date(now + 365L * 24 * 60 * 60 * 1000); + + X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + subject, BigInteger.valueOf(now), notBefore, notAfter, subject, kp.getPublic()); + + ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSA") + .setProvider("BC").build(kp.getPrivate()); + X509Certificate cert = new JcaX509CertificateConverter() + .setProvider("BC").getCertificate(builder.build(signer)); + + KeyStore keyStore = KeyStore.getInstance("JKS"); + keyStore.load(null, password.toCharArray()); + keyStore.setKeyEntry("key", kp.getPrivate(), password.toCharArray(), new Certificate[] { cert }); + return keyStore; + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/integration/DicomIntegrationTestBase.java b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/DicomIntegrationTestBase.java new file mode 100644 index 0000000000..7474e68b0c --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/DicomIntegrationTestBase.java @@ -0,0 +1,230 @@ +package com.mirth.connect.connectors.dimse.dicom.integration; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.net.ServerSocket; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.dcm4che3.data.Tag; +import org.dcm4che3.util.UIDUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; +import org.mockito.ArgumentCaptor; + +import com.mirth.connect.connectors.dimse.DICOMConfiguration; +import com.mirth.connect.connectors.dimse.DICOMDispatcher; +import com.mirth.connect.connectors.dimse.DICOMDispatcherProperties; +import com.mirth.connect.connectors.dimse.DICOMReceiver; +import com.mirth.connect.connectors.dimse.DICOMReceiverProperties; +import com.mirth.connect.connectors.dimse.dicom.DicomLibraryFactory; +import com.mirth.connect.connectors.dimse.dicom.DicomLibraryFactory.DicomLibrary; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; +import com.mirth.connect.connectors.dimse.dicom.OieDicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.OieDicomSender; +import com.mirth.connect.connectors.dimse.dicom.OieDimseRspHandler; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomConverter; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomObject; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomSender; +import com.mirth.connect.donkey.model.message.RawMessage; +import com.mirth.connect.donkey.server.channel.Channel; +import com.mirth.connect.donkey.server.channel.Connector; +import com.mirth.connect.donkey.server.channel.DispatchResult; +import com.mirth.connect.donkey.server.channel.SourceConnector; + +/** + * Shared base class for DICOM integration tests. Provides port allocation, + * temp DICOM file creation, mock setup, and receiver/sender lifecycle helpers. + */ +public abstract class DicomIntegrationTestBase { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + protected Dcm5DicomReceiver receiver; + protected Dcm5DicomSender sender; + + @Before + public void setUpFactory() { + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE5); + } + + @After + public void tearDown() { + if (sender != null) { + try { sender.close(); } catch (Exception e) { /* ignore */ } + try { sender.stop(); } catch (Exception e) { /* ignore */ } + sender = null; + } + if (receiver != null) { + try { receiver.stop(); } catch (Exception e) { /* ignore */ } + receiver = null; + } + DicomLibraryFactory.resetForTesting(null); + } + + /** + * Allocate a free port using ServerSocket(0). The tiny TOCTOU race + * between close and re-bind is acceptable for localhost integration tests. + */ + protected int allocatePort() throws IOException { + try (ServerSocket ss = new ServerSocket(0)) { + ss.setReuseAddress(true); + return ss.getLocalPort(); + } + } + + /** + * Creates a temp DICOM file with the given patient data, FMI (CT Image Storage, + * Implicit VR Little Endian), and a generated SOP Instance UID. + */ + protected File createDicomTempFile(String patientName, String patientId) throws IOException { + Dcm5DicomConverter converter = new Dcm5DicomConverter(); + Dcm5DicomObject obj = (Dcm5DicomObject) converter.createDicomObject(); + obj.putString(Tag.PatientName, "PN", patientName); + obj.putString(Tag.PatientID, "LO", patientId); + obj.putString(Tag.StudyInstanceUID, "UI", UIDUtils.createUID()); + obj.putString(Tag.SeriesInstanceUID, "UI", UIDUtils.createUID()); + obj.putString(Tag.SOPInstanceUID, "UI", UIDUtils.createUID()); + obj.putString(Tag.Modality, "CS", "CT"); + // CT Image Storage SOP Class, Implicit VR Little Endian + obj.initFileMetaInformation( + "1.2.840.10008.5.1.4.1.1.2", + UIDUtils.createUID(), + "1.2.840.10008.1.2"); + byte[] bytes = converter.dicomObjectToByteArray(obj); + File tempFile = tempFolder.newFile(patientId + ".dcm"); + Files.write(tempFile.toPath(), bytes); + return tempFile; + } + + /** + * Creates a Mockito mock of SourceConnector wired for DICOM receiver integration tests. + * The captor captures RawMessage objects passed to dispatchRawMessage(). + */ + protected SourceConnector createMockSourceConnector(ArgumentCaptor captor) throws Exception { + Channel mockChannel = mock(Channel.class); + when(mockChannel.getName()).thenReturn("testChannel"); + + SourceConnector mockConnector = mock(SourceConnector.class); + when(mockConnector.getChannel()).thenReturn(mockChannel); + when(mockConnector.getChannelId()).thenReturn("testChannelId"); + + DispatchResult mockResult = mock(DispatchResult.class); + when(mockResult.getSelectedResponse()).thenReturn(null); + when(mockConnector.dispatchRawMessage(captor.capture())).thenReturn(mockResult); + + return mockConnector; + } + + /** + * Creates and starts a Dcm5DicomReceiver on the given port with standard config. + */ + protected Dcm5DicomReceiver startDcm5Receiver(int port, SourceConnector mockConnector) throws Exception { + Dcm5DicomReceiver rcv = new Dcm5DicomReceiver(mockConnector, new TestConfig()); + rcv.setHostname("127.0.0.1"); + rcv.setPort(port); + rcv.setAEtitle("TEST_SCP"); + rcv.setTransferSyntax(new String[] { "1.2.840.10008.1.2", "1.2.840.10008.1.2.1" }); + rcv.initTransferCapability(); + rcv.start(); + waitForPort(port, 2000); + return rcv; + } + + /** + * Creates and starts a Dcm5DicomSender configured to connect to localhost:port. + */ + protected Dcm5DicomSender configureDcm5Sender(int port, File... dicomFiles) throws Exception { + Dcm5DicomSender snd = new Dcm5DicomSender(new TestConfig()); + snd.setRemoteHost("127.0.0.1"); + snd.setRemotePort(port); + snd.setCalledAET("TEST_SCP"); + snd.setCalling("TEST_SCU"); + for (File f : dicomFiles) { + snd.addFile(f); + } + snd.configureTransferCapability(); + snd.start(); + return snd; + } + + /** + * Polls until a TCP connection to localhost:port succeeds, or timeout expires. + */ + protected void waitForPort(int port, long timeoutMs) throws Exception { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + try { + new java.net.Socket("127.0.0.1", port).close(); + return; + } catch (IOException e) { + Thread.sleep(50); + } + } + throw new IOException("Port " + port + " not ready after " + timeoutMs + "ms"); + } + + /** + * DIMSE response handler that captures responses and provides a latch for synchronization. + * dcm4che5's cstore() is async — the response handler fires on a worker thread after + * send() returns. Use awaitResponses() to block until all expected responses arrive. + */ + protected static class CapturingDimseRspHandler implements OieDimseRspHandler { + private final CountDownLatch latch; + private final List statuses = Collections.synchronizedList(new ArrayList<>()); + + public CapturingDimseRspHandler(int expectedResponses) { + this.latch = new CountDownLatch(expectedResponses); + } + + @Override + public void onDimseRSP(OieDicomObject cmd, OieDicomObject data) { + if (cmd != null) { + statuses.add(cmd.getInt(Tag.Status)); + } + latch.countDown(); + } + + public boolean awaitResponses(long timeoutMs) throws InterruptedException { + return latch.await(timeoutMs, TimeUnit.MILLISECONDS); + } + + public List getStatuses() { + return statuses; + } + } + + /** + * Minimal DICOMConfiguration for testing — no ControllerFactory dependencies. + */ + protected static class TestConfig implements DICOMConfiguration { + @Override + public void configureConnectorDeploy(Connector connector) {} + + @Override + public void configureReceiver(OieDicomReceiver receiver, DICOMReceiver connector, + DICOMReceiverProperties connectorProperties) {} + + @Override + public void configureSender(OieDicomSender sender, DICOMDispatcher connector, + DICOMDispatcherProperties connectorProperties) {} + + @Override + public Map getCStoreRequestInformation(Object association) { + return new HashMap<>(); + } + } +} diff --git a/server/test/com/mirth/connect/connectors/dimse/dicom/integration/DicomPerformanceBenchmark.java b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/DicomPerformanceBenchmark.java new file mode 100644 index 0000000000..7efd74c5ba --- /dev/null +++ b/server/test/com/mirth/connect/connectors/dimse/dicom/integration/DicomPerformanceBenchmark.java @@ -0,0 +1,494 @@ +package com.mirth.connect.connectors.dimse.dicom.integration; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; + +import org.dcm4che3.data.Tag; +import org.dcm4che3.util.UIDUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.ArgumentCaptor; + +import com.mirth.connect.connectors.dimse.dicom.DicomLibraryFactory; +import com.mirth.connect.connectors.dimse.dicom.DicomLibraryFactory.DicomLibrary; +import com.mirth.connect.connectors.dimse.dicom.OieDicomConverter; +import com.mirth.connect.connectors.dimse.dicom.OieDicomObject; +import com.mirth.connect.connectors.dimse.dicom.dcm2.Dcm2DicomConverter; +import com.mirth.connect.connectors.dimse.dicom.dcm2.Dcm2DicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.dcm2.Dcm2DicomSender; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomConverter; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomObject; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomReceiver; +import com.mirth.connect.connectors.dimse.dicom.dcm5.Dcm5DicomSender; +import com.mirth.connect.donkey.model.message.RawMessage; +import com.mirth.connect.donkey.server.channel.SourceConnector; + +/** + * Performance benchmark comparing dcm4che2 and dcm4che5 backends. + * + *

Measures converter throughput (serialize/deserialize/XML), network C-STORE + * throughput, and memory usage. Results are printed as formatted comparison tables. + * + *

This is not a regression gate — it produces data for human review. Run with: + *

+ * ./gradlew :server:test --tests "*.DicomPerformanceBenchmark" --info
+ * 
+ */ +public class DicomPerformanceBenchmark extends DicomIntegrationTestBase { + + // Benchmark parameters + private static final int WARMUP_ITERATIONS = 50; + private static final int MEASURE_ITERATIONS = 200; + private static final int NETWORK_WARMUP = 5; + private static final int NETWORK_MEASURE = 50; + + private Dcm2DicomReceiver dcm2Receiver; + private Dcm2DicomSender dcm2Sender; + + @Override + public void tearDown() { + if (dcm2Sender != null) { + try { dcm2Sender.close(); } catch (Exception e) { /* ignore */ } + try { dcm2Sender.stop(); } catch (Exception e) { /* ignore */ } + dcm2Sender = null; + } + if (dcm2Receiver != null) { + try { dcm2Receiver.stop(); } catch (Exception e) { /* ignore */ } + dcm2Receiver = null; + } + super.tearDown(); + } + + @Test + public void benchmarkConverterSerialization() throws Exception { + System.out.println("\n========== CONVERTER: dicomObjectToByteArray =========="); + System.out.printf("%-12s %12s %12s %12s%n", "Backend", "Ops/sec", "Avg (ms)", "Mem (KB)"); + System.out.println("----------------------------------------------------"); + + // dcm4che2 + BenchmarkResult dcm2Result = benchmarkSerialization(new Dcm2DicomConverter(), "dcm2"); + printRow("dcm4che2", dcm2Result); + + // dcm4che5 + BenchmarkResult dcm5Result = benchmarkSerialization(new Dcm5DicomConverter(), "dcm5"); + printRow("dcm4che5", dcm5Result); + + printSpeedup(dcm2Result, dcm5Result); + } + + @Test + public void benchmarkConverterDeserialization() throws Exception { + System.out.println("\n========== CONVERTER: byteArrayToDicomObject =========="); + System.out.printf("%-12s %12s %12s %12s%n", "Backend", "Ops/sec", "Avg (ms)", "Mem (KB)"); + System.out.println("----------------------------------------------------"); + + // Prepare test data + Dcm2DicomConverter dcm2Conv = new Dcm2DicomConverter(); + byte[] dcm2Bytes = createTestBytes(dcm2Conv); + + Dcm5DicomConverter dcm5Conv = new Dcm5DicomConverter(); + byte[] dcm5Bytes = createTestBytes(dcm5Conv); + + // dcm4che2 + BenchmarkResult dcm2Result = benchmarkDeserialization(dcm2Conv, dcm2Bytes); + printRow("dcm4che2", dcm2Result); + + // dcm4che5 + BenchmarkResult dcm5Result = benchmarkDeserialization(dcm5Conv, dcm5Bytes); + printRow("dcm4che5", dcm5Result); + + printSpeedup(dcm2Result, dcm5Result); + } + + @Test + public void benchmarkConverterXml() throws Exception { + System.out.println("\n========== CONVERTER: dicomBytesToXml =========="); + System.out.printf("%-12s %12s %12s %12s%n", "Backend", "Ops/sec", "Avg (ms)", "Mem (KB)"); + System.out.println("----------------------------------------------------"); + + // Prepare base64-encoded test data + Dcm2DicomConverter dcm2Conv = new Dcm2DicomConverter(); + byte[] dcm2Raw = createTestBytes(dcm2Conv); + byte[] dcm2B64 = java.util.Base64.getEncoder().encode(dcm2Raw); + + Dcm5DicomConverter dcm5Conv = new Dcm5DicomConverter(); + byte[] dcm5Raw = createTestBytes(dcm5Conv); + byte[] dcm5B64 = java.util.Base64.getEncoder().encode(dcm5Raw); + + // dcm4che2 + BenchmarkResult dcm2Result = benchmarkXmlConversion(dcm2Conv, dcm2B64); + printRow("dcm4che2", dcm2Result); + + // dcm4che5 + BenchmarkResult dcm5Result = benchmarkXmlConversion(dcm5Conv, dcm5B64); + printRow("dcm4che5", dcm5Result); + + printSpeedup(dcm2Result, dcm5Result); + } + + @Test + public void benchmarkNetworkCStoreDcm5() throws Exception { + System.out.println("\n========== NETWORK: dcm5 sender → dcm5 receiver (C-STORE) =========="); + System.out.printf("%-12s %12s %12s %12s%n", "Phase", "Ops/sec", "Avg (ms)", "Total (ms)"); + System.out.println("----------------------------------------------------"); + + int port = allocatePort(); + ArgumentCaptor captor = ArgumentCaptor.forClass(RawMessage.class); + SourceConnector mockConnector = createMockSourceConnector(captor); + receiver = startDcm5Receiver(port, mockConnector); + + // Create test files + List files = new ArrayList<>(); + for (int i = 0; i < NETWORK_WARMUP + NETWORK_MEASURE; i++) { + files.add(createDicomTempFile("Bench^Patient" + i, "BENCH" + String.format("%04d", i))); + } + + // Warmup + for (int i = 0; i < NETWORK_WARMUP; i++) { + sendOneDcm5File(port, files.get(i)); + } + + // Measure + long[] latencies = new long[NETWORK_MEASURE]; + long start = System.nanoTime(); + for (int i = 0; i < NETWORK_MEASURE; i++) { + long opStart = System.nanoTime(); + sendOneDcm5File(port, files.get(NETWORK_WARMUP + i)); + latencies[i] = System.nanoTime() - opStart; + } + long totalNs = System.nanoTime() - start; + + printNetworkRow("Measured", latencies, totalNs); + printLatencyPercentiles("dcm5→dcm5", latencies); + } + + @Test + public void benchmarkNetworkCStoreDcm2() throws Exception { + System.out.println("\n========== NETWORK: dcm2 sender → dcm2 receiver (C-STORE) =========="); + System.out.printf("%-12s %12s %12s %12s%n", "Phase", "Ops/sec", "Avg (ms)", "Total (ms)"); + System.out.println("----------------------------------------------------"); + + int port = allocatePort(); + ArgumentCaptor captor = ArgumentCaptor.forClass(RawMessage.class); + SourceConnector mockConnector = createMockSourceConnector(captor); + + // Start dcm2 receiver + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE2); + dcm2Receiver = new Dcm2DicomReceiver(mockConnector, new TestConfig()); + dcm2Receiver.setHostname("127.0.0.1"); + dcm2Receiver.setPort(port); + dcm2Receiver.setAEtitle("BENCH_SCP"); + dcm2Receiver.setDestination(tempFolder.getRoot().getAbsolutePath()); + dcm2Receiver.setTransferSyntax(new String[] { "1.2.840.10008.1.2", "1.2.840.10008.1.2.1" }); + dcm2Receiver.initTransferCapability(); + dcm2Receiver.start(); + waitForPort(port, 2000); + + // Create test files using dcm2 converter + List files = new ArrayList<>(); + for (int i = 0; i < NETWORK_WARMUP + NETWORK_MEASURE; i++) { + files.add(createDcm2TempFile("Bench^Patient" + i, "BENCH" + String.format("%04d", i))); + } + + // Warmup + for (int i = 0; i < NETWORK_WARMUP; i++) { + sendOneDcm2File(port, files.get(i)); + } + + // Measure + long[] latencies = new long[NETWORK_MEASURE]; + long start = System.nanoTime(); + for (int i = 0; i < NETWORK_MEASURE; i++) { + long opStart = System.nanoTime(); + sendOneDcm2File(port, files.get(NETWORK_WARMUP + i)); + latencies[i] = System.nanoTime() - opStart; + } + long totalNs = System.nanoTime() - start; + + printNetworkRow("Measured", latencies, totalNs); + printLatencyPercentiles("dcm2→dcm2", latencies); + } + + @Test + public void benchmarkMemoryFootprint() throws Exception { + System.out.println("\n========== MEMORY: converter object creation =========="); + System.out.printf("%-12s %12s %12s%n", "Backend", "Per-obj (B)", "100-obj (KB)"); + System.out.println("--------------------------------------------"); + + // dcm4che2 + long dcm2Single = measureObjectMemory(() -> { + Dcm2DicomConverter c = new Dcm2DicomConverter(); + OieDicomObject obj = c.createDicomObject(); + obj.putString(Tag.PatientName, "PN", "Test^Patient"); + obj.putString(Tag.PatientID, "LO", "ID001"); + return obj; + }); + long dcm2Bulk = measureBulkMemory(() -> { + Dcm2DicomConverter c = new Dcm2DicomConverter(); + OieDicomObject obj = c.createDicomObject(); + obj.putString(Tag.PatientName, "PN", "Test^Patient"); + obj.putString(Tag.PatientID, "LO", "ID001"); + return obj; + }, 100); + System.out.printf("%-12s %12d %12.1f%n", "dcm4che2", dcm2Single, dcm2Bulk / 1024.0); + + // dcm4che5 + long dcm5Single = measureObjectMemory(() -> { + Dcm5DicomConverter c = new Dcm5DicomConverter(); + OieDicomObject obj = c.createDicomObject(); + obj.putString(Tag.PatientName, "PN", "Test^Patient"); + obj.putString(Tag.PatientID, "LO", "ID001"); + return obj; + }); + long dcm5Bulk = measureBulkMemory(() -> { + Dcm5DicomConverter c = new Dcm5DicomConverter(); + OieDicomObject obj = c.createDicomObject(); + obj.putString(Tag.PatientName, "PN", "Test^Patient"); + obj.putString(Tag.PatientID, "LO", "ID001"); + return obj; + }, 100); + System.out.printf("%-12s %12d %12.1f%n", "dcm4che5", dcm5Single, dcm5Bulk / 1024.0); + } + + private BenchmarkResult benchmarkSerialization(OieDicomConverter converter, String label) throws IOException { + // Warmup + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + OieDicomObject obj = createTestObject(converter); + converter.dicomObjectToByteArray(obj); + } + + // Measure + forceGc(); + long memBefore = usedMemory(); + long start = System.nanoTime(); + for (int i = 0; i < MEASURE_ITERATIONS; i++) { + OieDicomObject obj = createTestObject(converter); + converter.dicomObjectToByteArray(obj); + } + long elapsed = System.nanoTime() - start; + long memAfter = usedMemory(); + + return new BenchmarkResult(MEASURE_ITERATIONS, elapsed, memAfter - memBefore); + } + + private BenchmarkResult benchmarkDeserialization(OieDicomConverter converter, byte[] bytes) throws IOException { + // Warmup + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + converter.byteArrayToDicomObject(bytes, false); + } + + // Measure + forceGc(); + long memBefore = usedMemory(); + long start = System.nanoTime(); + for (int i = 0; i < MEASURE_ITERATIONS; i++) { + converter.byteArrayToDicomObject(bytes, false); + } + long elapsed = System.nanoTime() - start; + long memAfter = usedMemory(); + + return new BenchmarkResult(MEASURE_ITERATIONS, elapsed, memAfter - memBefore); + } + + private BenchmarkResult benchmarkXmlConversion(OieDicomConverter converter, byte[] b64Bytes) throws Exception { + // Warmup + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + converter.dicomBytesToXml(b64Bytes); + } + + // Measure + forceGc(); + long memBefore = usedMemory(); + long start = System.nanoTime(); + for (int i = 0; i < MEASURE_ITERATIONS; i++) { + converter.dicomBytesToXml(b64Bytes); + } + long elapsed = System.nanoTime() - start; + long memAfter = usedMemory(); + + return new BenchmarkResult(MEASURE_ITERATIONS, elapsed, memAfter - memBefore); + } + + private void sendOneDcm5File(int port, File file) throws Exception { + Dcm5DicomSender snd = new Dcm5DicomSender(new TestConfig()); + snd.setRemoteHost("127.0.0.1"); + snd.setRemotePort(port); + snd.setCalledAET("TEST_SCP"); + snd.setCalling("BENCH_SCU"); + snd.addFile(file); + snd.configureTransferCapability(); + snd.start(); + try { + CapturingDimseRspHandler handler = new CapturingDimseRspHandler(1); + snd.open(); + snd.send(handler); + handler.awaitResponses(10000); + snd.close(); + } finally { + try { snd.stop(); } catch (Exception e) { /* ignore */ } + } + } + + private void sendOneDcm2File(int port, File file) throws Exception { + DicomLibraryFactory.resetForTesting(DicomLibrary.DCM4CHE2); + Dcm2DicomSender snd = new Dcm2DicomSender(new TestConfig()); + snd.setRemoteHost("127.0.0.1"); + snd.setRemotePort(port); + snd.setCalledAET("BENCH_SCP"); + snd.setCalling("BENCH_SCU"); + snd.addFile(file); + snd.configureTransferCapability(); + snd.start(); + try { + snd.open(); + snd.send((cmd, data) -> {}); + snd.close(); + } finally { + try { snd.stop(); } catch (Exception e) { /* ignore */ } + } + } + + private File createDcm2TempFile(String patientName, String patientId) throws Exception { + Dcm2DicomConverter converter = new Dcm2DicomConverter(); + OieDicomObject obj = converter.createDicomObject(); + obj.putString(Tag.PatientName, "PN", patientName); + obj.putString(Tag.PatientID, "LO", patientId); + obj.putString(Tag.StudyInstanceUID, "UI", org.dcm4che2.util.UIDUtils.createUID()); + obj.putString(Tag.SeriesInstanceUID, "UI", org.dcm4che2.util.UIDUtils.createUID()); + obj.putString(Tag.SOPInstanceUID, "UI", org.dcm4che2.util.UIDUtils.createUID()); + obj.putString(Tag.Modality, "CS", "CT"); + obj.initFileMetaInformation( + "1.2.840.10008.5.1.4.1.1.2", + org.dcm4che2.util.UIDUtils.createUID(), + "1.2.840.10008.1.2"); + byte[] bytes = converter.dicomObjectToByteArray(obj); + File tempFile = tempFolder.newFile(patientId + ".dcm"); + Files.write(tempFile.toPath(), bytes); + return tempFile; + } + + private OieDicomObject createTestObject(OieDicomConverter converter) { + OieDicomObject obj = converter.createDicomObject(); + obj.putString(Tag.PatientName, "PN", "Benchmark^Patient"); + obj.putString(Tag.PatientID, "LO", "BENCH001"); + obj.putString(Tag.StudyInstanceUID, "UI", "1.2.3.4.5.6.7.8.9"); + obj.putString(Tag.SeriesInstanceUID, "UI", "1.2.3.4.5.6.7.8.10"); + obj.putString(Tag.SOPInstanceUID, "UI", "1.2.3.4.5.6.7.8.11"); + obj.putString(Tag.Modality, "CS", "CT"); + obj.putString(Tag.StudyDate, "DA", "20260326"); + obj.putString(Tag.StudyDescription, "LO", "Performance benchmark test study"); + obj.putString(Tag.InstitutionName, "LO", "Test Hospital"); + obj.putString(Tag.ReferringPhysicianName, "PN", "Doctor^Test"); + obj.initFileMetaInformation("1.2.840.10008.5.1.4.1.1.2", + "1.2.3.4.5.6.7.8.11", "1.2.840.10008.1.2"); + return obj; + } + + private byte[] createTestBytes(OieDicomConverter converter) throws IOException { + OieDicomObject obj = createTestObject(converter); + return converter.dicomObjectToByteArray(obj); + } + + @FunctionalInterface + private interface ObjectFactory { + Object create() throws Exception; + } + + private long measureObjectMemory(ObjectFactory factory) throws Exception { + forceGc(); + long before = usedMemory(); + Object obj = factory.create(); + long after = usedMemory(); + // Keep reference alive past measurement + if (obj.hashCode() == Integer.MIN_VALUE) System.out.print(""); + return Math.max(0, after - before); + } + + private long measureBulkMemory(ObjectFactory factory, int count) throws Exception { + forceGc(); + long before = usedMemory(); + Object[] objects = new Object[count]; + for (int i = 0; i < count; i++) { + objects[i] = factory.create(); + } + long after = usedMemory(); + // Keep references alive past measurement + if (objects[0].hashCode() == Integer.MIN_VALUE) System.out.print(""); + return Math.max(0, after - before); + } + + private static void forceGc() { + System.gc(); + System.gc(); + try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + System.gc(); + } + + private static long usedMemory() { + Runtime rt = Runtime.getRuntime(); + return rt.totalMemory() - rt.freeMemory(); + } + + private static class BenchmarkResult { + final int iterations; + final long elapsedNs; + final long memoryDeltaBytes; + + BenchmarkResult(int iterations, long elapsedNs, long memoryDeltaBytes) { + this.iterations = iterations; + this.elapsedNs = elapsedNs; + this.memoryDeltaBytes = memoryDeltaBytes; + } + + double opsPerSec() { + return iterations / (elapsedNs / 1_000_000_000.0); + } + + double avgMs() { + return (elapsedNs / 1_000_000.0) / iterations; + } + + double memoryKB() { + return Math.max(0, memoryDeltaBytes) / 1024.0; + } + } + + private static void printRow(String label, BenchmarkResult result) { + System.out.printf("%-12s %12.1f %12.3f %12.1f%n", + label, result.opsPerSec(), result.avgMs(), result.memoryKB()); + } + + private static void printSpeedup(BenchmarkResult dcm2, BenchmarkResult dcm5) { + double speedup = dcm5.opsPerSec() / dcm2.opsPerSec(); + System.out.printf("%n dcm5 vs dcm2: %.2fx %s%n", + Math.abs(speedup), + speedup >= 1.0 ? "faster" : "slower"); + } + + private static void printNetworkRow(String label, long[] latenciesNs, long totalNs) { + double avgMs = 0; + for (long l : latenciesNs) avgMs += l; + avgMs = (avgMs / latenciesNs.length) / 1_000_000.0; + double opsPerSec = latenciesNs.length / (totalNs / 1_000_000_000.0); + double totalMs = totalNs / 1_000_000.0; + System.out.printf("%-12s %12.1f %12.1f %12.0f%n", label, opsPerSec, avgMs, totalMs); + } + + private static void printLatencyPercentiles(String label, long[] latenciesNs) { + long[] sorted = latenciesNs.clone(); + java.util.Arrays.sort(sorted); + System.out.printf("%n %s latency percentiles (ms):%n", label); + System.out.printf(" p50: %.1f p90: %.1f p99: %.1f min: %.1f max: %.1f%n", + sorted[(int)(sorted.length * 0.50)] / 1_000_000.0, + sorted[(int)(sorted.length * 0.90)] / 1_000_000.0, + sorted[(int)(sorted.length * 0.99)] / 1_000_000.0, + sorted[0] / 1_000_000.0, + sorted[sorted.length - 1] / 1_000_000.0); + } +} diff --git a/server/test/com/mirth/connect/server/launcher/MirthLauncherVariantTest.java b/server/test/com/mirth/connect/server/launcher/MirthLauncherVariantTest.java new file mode 100644 index 0000000000..9362210b99 --- /dev/null +++ b/server/test/com/mirth/connect/server/launcher/MirthLauncherVariantTest.java @@ -0,0 +1,65 @@ +package com.mirth.connect.server.launcher; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Properties; + +import org.junit.Test; + +public class MirthLauncherVariantTest { + + @Test + public void testNullVariantAlwaysLoads() { + assertTrue(MirthLauncher.shouldLoadLibrary(null, new Properties())); + } + + @Test + public void testEmptyVariantAlwaysLoads() { + assertTrue(MirthLauncher.shouldLoadLibrary("", new Properties())); + } + + @Test + public void testMatchingVariantLoads() { + Properties props = new Properties(); + props.setProperty("dicom.library", "dcm4che2"); + assertTrue(MirthLauncher.shouldLoadLibrary("dicom.library:dcm4che2", props)); + } + + @Test + public void testMismatchedVariantSkips() { + Properties props = new Properties(); + props.setProperty("dicom.library", "dcm4che5"); + assertFalse(MirthLauncher.shouldLoadLibrary("dicom.library:dcm4che2", props)); + } + + @Test + public void testMissingPropertyUsesDefault() { + Properties props = new Properties(); + // dicom.library not set, default is dcm4che2 + assertTrue(MirthLauncher.shouldLoadLibrary("dicom.library:dcm4che2", props)); + assertFalse(MirthLauncher.shouldLoadLibrary("dicom.library:dcm4che5", props)); + } + + @Test + public void testWhitespaceInPropertyValueTrimmed() { + Properties props = new Properties(); + props.setProperty("dicom.library", " dcm4che2 "); + assertTrue(MirthLauncher.shouldLoadLibrary("dicom.library:dcm4che2", props)); + } + + @Test + public void testMalformedVariantNoColonAlwaysLoads() { + Properties props = new Properties(); + assertTrue(MirthLauncher.shouldLoadLibrary("noColonHere", props)); + } + + @Test + public void testUnknownPropertyWithNoDefaultAlwaysLoads() { + Properties props = new Properties(); + // "unknown.prop" has no entry in VARIANT_DEFAULTS, so default is "" + // "somevalue" != "" → false. But wait, empty default doesn't match, so it should skip. + // Actually, let's test: unknown property with no default and no value = empty default + assertFalse(MirthLauncher.shouldLoadLibrary("unknown.prop:somevalue", props)); + } +} diff --git a/server/tests/dicom-channels/DICOM_Forward.xml b/server/tests/dicom-channels/DICOM_Forward.xml new file mode 100644 index 0000000000..4271f3da6f --- /dev/null +++ b/server/tests/dicom-channels/DICOM_Forward.xml @@ -0,0 +1,221 @@ + + 5ba097b6-56f6-4802-82d6-c7a830a3aaf3 + 2 + DICOM Forward + + 3 + + 0 + sourceConnector + + + + 0.0.0.0 + 11112 + + + None + true + false + false + 1 + + + Default Resource + [Default Resource] + + + 1000 + + + + + + 50 + 5 + 5 + 60 + 10 + 0 + false + 16 + 16 + 0 + false + 1 + false + + false + 0 + 0 + true + + + + true + true + notls + + + + + + DICOM + DICOM + + + + + + + DICOM Listener + SOURCE + true + true + + + + 1 + Destination 1 + + + + false + false + 10000 + false + 0 + false + false + 1 + + false + + + Default Resource + [Default Resource] + + + 1000 + true + + 127.0.0.1 + 11113 + + + + + + 5000 + 0 + 1 + 0 + med + + false + 16 + 10 + 5 + 60 + 1000 + 16 + 50 + 0 + 0 + false + true + false + false + + + + + true + true + notls + + + + + + DICOM + DICOM + + + + + + DICOM + DICOM + + + + + + + DICOM Sender + DESTINATION + true + true + + + // Modify the message variable below to pre process data +return message; + // This script executes once after a message has been processed +// Responses returned from here will be stored as "Postprocessor" in the response map +return; + // This script executes once when the channel is deployed +// You only have access to the globalMap and globalChannelMap here to persist data +return; + // This script executes once when the channel is undeployed +// You only have access to the globalMap and globalChannelMap here to persist data +return; + + true + DEVELOPMENT + false + false + false + false + false + false + STARTED + true + + + SOURCE + STRING + mirth_source + + + TYPE + STRING + mirth_type + + + + None + + + + + Default Resource + [Default Resource] + + + + + + true + + + America/Denver + + + true + false + + 1 + + + \ No newline at end of file diff --git a/server/tests/dicom-channels/DICOM_Receiver.xml b/server/tests/dicom-channels/DICOM_Receiver.xml new file mode 100644 index 0000000000..c963fbb36e --- /dev/null +++ b/server/tests/dicom-channels/DICOM_Receiver.xml @@ -0,0 +1,327 @@ + + + c7bbd2f4-bad8-414c-ab78-b527531ba984 + 2 + Dicom Receiver + + 2 + + 0 + sourceConnector + + + + 0.0.0.0 + 11113 + + + None + true + false + false + 1 + + + Default Resource + [Default Resource] + + + 1000 + + + + + + 50 + 5 + 5 + 60 + 10 + 0 + false + 16 + 16 + 0 + false + 1 + false + + false + 0 + 0 + true + + + + true + true + notls + + + + + + + excercise api + 0 + true + + + + + + DICOM + DICOM + + + + + + + DICOM Listener + SOURCE + true + true + + + + 1 + Destination 1 + + + + false + false + 10000 + false + 0 + false + false + 1 + + false + + + Default Resource + [Default Resource] + + + 1000 + true + + none + ${message.encodedData} + + + + + DICOM + DICOM + + + + + + DICOM + DICOM + + + + + + + Channel Writer + DESTINATION + true + true + + + // Modify the message variable below to pre process data +return message; + // This script executes once after a message has been processed +// Responses returned from here will be stored as "Postprocessor" in the response map +return; + // This script executes once when the channel is deployed +// You only have access to the globalMap and globalChannelMap here to persist data +return; + // This script executes once when the channel is undeployed +// You only have access to the globalMap and globalChannelMap here to persist data +return; + + true + DEVELOPMENT + false + false + false + false + false + false + STARTED + true + + + SOURCE + STRING + mirth_source + + + TYPE + STRING + mirth_type + + + + None + + + + + Default Resource + [Default Resource] + + + + + + true + + + America/Denver + + + true + false + + 1 + + + \ No newline at end of file diff --git a/server/tests/dicom-channels/README.md b/server/tests/dicom-channels/README.md new file mode 100644 index 0000000000..28273344a9 --- /dev/null +++ b/server/tests/dicom-channels/README.md @@ -0,0 +1,51 @@ +# DICOM Manual Test Channels + +Channels and transformer script for validating the DICOM connector end-to-end +against a running Open Integration Engine server. Used to confirm the +`dicom.library` toggle (dcm4che2 vs dcm4che5) produces identical behaviour. + +## Files + +| File | Purpose | +|---|---| +| `DICOM_Forward.xml` | DICOM Listener on `:11112` that forwards to `:11113` | +| `DICOM_Receiver.xml` | DICOM Listener on `:11113` with a JavaScript transformer that exercises the DICOM user API | +| `dicom-api-test.js` | Transformer script that asserts every `OieDicomObject`, `OieDicomElement`, and `DICOMUtil` method. Emits `[DICOM_TEST]` log lines | + +Both channels are configured with `DICOM` / +`DICOM`. The admin UI defaults new +channels to HL7v2; importing these XMLs avoids having to set the data type +manually. + +## Usage + +Start the server, then import via REST API: + + curl -sk -u admin:admin -H 'X-Requested-With: XMLHttpRequest' \ + -H 'Content-Type: application/xml' \ + -X POST --data-binary @DICOM_Forward.xml \ + https://localhost:8443/api/channels + curl -sk -u admin:admin -H 'X-Requested-With: XMLHttpRequest' \ + -H 'Content-Type: application/xml' \ + -X POST --data-binary @DICOM_Receiver.xml \ + https://localhost:8443/api/channels + curl -sk -u admin:admin -H 'X-Requested-With: XMLHttpRequest' \ + -X POST https://localhost:8443/api/channels/_redeployAll + +Send the DICOM fixtures through the pipeline with DCMTK: + + storescu localhost 11112 ../test-dicom-input-1.dcm + storescu localhost 11112 ../test-dicom-input-2.dcm + storescu localhost 11112 ../test-dicom-input-3.dcm + +Watch the server log for `[DICOM_TEST]` lines. Each message produces: + + [DICOM_TEST] === START message=N === + [DICOM_TEST] PASS ... (one per assertion) + [DICOM_TEST] backend=dcm4che2 (impl=org.dcm4che2.data.BasicDicomObject) + [DICOM_TEST] === END message=N backend=dcm4che2 failures=0 result=ALL_PASS === + +To validate the dcm4che5 backend, set `dicom.library = dcm4che5` in +`server/conf/mirth.properties`, restart the server, redeploy the channels, +and rerun `storescu`. The `backend=` field in the END line should report +`dcm4che5` and every assertion should still PASS. diff --git a/server/tests/dicom-channels/dicom-api-test.js b/server/tests/dicom-channels/dicom-api-test.js new file mode 100644 index 0000000000..b636462340 --- /dev/null +++ b/server/tests/dicom-channels/dicom-api-test.js @@ -0,0 +1,129 @@ +// === OIE DICOM API Regression Script (v2: Rhino-safe) === +var results = {}; +var failures = []; +var LOG_PREFIX = '[DICOM_TEST] '; + +function nonEmpty(x) { return x != null && String(x) !== ''; } + +function check(name, cond, info) { + var status = cond ? 'PASS' : 'FAIL'; + var line = status + ' ' + name + (info != null ? ' (' + info + ')' : ''); + results[name] = line; + if (cond) { logger.info(LOG_PREFIX + line); } + else { failures.push(line); logger.warn(LOG_PREFIX + line); } +} + +logger.info(LOG_PREFIX + '=== START message=' + connectorMessage.getMessageId() + ' ==='); + +var dicomBytes = DICOMUtil.getDICOMMessage(connectorMessage); +check('rawBytesNonEmpty', dicomBytes != null && dicomBytes.length > 0, 'len=' + (dicomBytes ? dicomBytes.length : 'null')); + +var rawBytes = DICOMUtil.getDICOMRawBytes(connectorMessage); +check('rawBytesDirectAccess', rawBytes != null && rawBytes.length > 0, 'len=' + (rawBytes ? rawBytes.length : 'null')); + +var rawB64 = DICOMUtil.getDICOMRawData(connectorMessage); +check('rawDataBase64', nonEmpty(rawB64), 'b64len=' + (rawB64 ? String(rawB64).length : 'null')); + +var dicomObj = DICOMUtil.byteArrayToDicomObject(dicomBytes, false); +check('parseObject', dicomObj != null); + +var patientName = dicomObj.getString(0x00100010); +var patientID = dicomObj.getString(0x00100020); +var modality = dicomObj.getString(0x00080060); +var sopClassUID = dicomObj.getString(0x00080016); +var sopInstUID = dicomObj.getString(0x00080018); + +check('getString_patientName', nonEmpty(patientName), patientName); +check('getString_patientID', nonEmpty(patientID), patientID); +check('getString_modality', nonEmpty(modality), modality); +check('getString_sopClassUID', nonEmpty(sopClassUID), sopClassUID); +check('getString_sopInstUID', nonEmpty(sopInstUID), sopInstUID); + +var studyDate = dicomObj.getString(0x00080020, 'UNKNOWN'); +var numFrames = dicomObj.getInt(0x00280008, 1); +var bogusTag = dicomObj.getString(0x00190099, 'DEFAULT'); +var bogusInt = dicomObj.getInt(0x00190099, 42); + +check('getString_default_absent_uses_default', String(bogusTag) == 'DEFAULT', String(bogusTag)); +check('getInt_default_absent_uses_default', bogusInt == 42, String(bogusInt)); +check('getInt_numFrames_fallback', numFrames >= 1, String(numFrames)); + +var sopClassElem = dicomObj.get(0x00080016); +if (sopClassElem != null) { + check('element_tag_matches', sopClassElem.tag() == 0x00080016, '0x' + sopClassElem.tag().toString(16)); + check('element_vr_nonempty', nonEmpty(sopClassElem.vr()), String(sopClassElem.vr())); + check('element_length_gt_zero', sopClassElem.length() > 0, 'len=' + sopClassElem.length()); + check('element_valueAsString', String(sopClassElem.getValueAsString(0)) == String(sopClassUID)); +} else { + check('element_sopClass_present', false, 'null'); +} + +check('absent_element_null', dicomObj.get(0x00190099) == null); + +var pixelElem = dicomObj.get(0x7FE00010); +if (pixelElem != null) { + var pixBytes = pixelElem.getBytes(); + check('pixelData_bytes_readable', pixBytes != null, 'len=' + (pixBytes ? pixBytes.length : 'null')); + check('pixelData_hasItems_queryable', typeof pixelElem.hasItems() == 'boolean'); +} else { + results['pixelData_present'] = 'ABSENT (expected on minimal fixtures)'; + logger.info(LOG_PREFIX + 'SKIP pixelData_present (absent on fixture)'); +} + +try { + var slices = DICOMUtil.getSliceCount(connectorMessage); + check('getSliceCount_noThrow', true, String(slices)); +} catch (e) { + check('getSliceCount_noThrow', false, String(e)); +} + +check('hasFileMetaInfo', dicomObj.hasFileMetaInfo() === true); +check('bigEndian_boolean', typeof dicomObj.bigEndian() == 'boolean'); + +try { + dicomObj.putString(0x00189004, 'CS', 'OIE_TEST_VALUE'); + check('putString_roundtrip', String(dicomObj.getString(0x00189004)) == 'OIE_TEST_VALUE'); + check('remove_returnsElement', dicomObj.remove(0x00189004) != null); + check('remove_actuallyRemoves', dicomObj.getString(0x00189004) == null); +} catch (e) { + check('mutation_supported', false, String(e)); +} + +try { + var roundTripBytes = DICOMUtil.dicomObjectToByteArray(dicomObj); + check('roundtrip_nonEmpty', roundTripBytes != null && roundTripBytes.length > 0, 'len=' + (roundTripBytes ? roundTripBytes.length : 'null')); + var reparsed = DICOMUtil.byteArrayToDicomObject(roundTripBytes, false); + check('roundtrip_reparse_patientName_stable', String(reparsed.getString(0x00100010)) == String(patientName)); + check('roundtrip_reparse_modality_stable', String(reparsed.getString(0x00080060)) == String(modality)); +} catch (e) { + check('roundtrip_noThrow', false, String(e)); +} + +var backend = 'UNKNOWN'; +try { + var implClass = String(dicomObj.unwrap().getClass().getName()); + backend = implClass.indexOf('dcm4che3') >= 0 ? 'dcm4che5' + : implClass.indexOf('dcm4che2') >= 0 ? 'dcm4che2' : 'UNKNOWN'; + channelMap.put('dicomTest.backendImplClass', implClass); + logger.info(LOG_PREFIX + 'backend=' + backend + ' (impl=' + implClass + ')'); +} catch (e) { + logger.warn(LOG_PREFIX + 'backend detection failed: ' + e); +} +channelMap.put('dicomTest.backend', backend); + +for (var key in results) { + channelMap.put('dicomTest.' + key, String(results[key])); +} +channelMap.put('dicomTest.failureCount', String(failures.length)); +channelMap.put('dicomTest.summary', failures.length == 0 ? 'ALL PASS' : ('FAILURES: ' + failures.join('; '))); + +channelMap.put('patientName', String(patientName)); +channelMap.put('patientID', String(patientID)); +channelMap.put('modality', String(modality)); +channelMap.put('studyDate', String(studyDate)); +channelMap.put('numberOfFrames', String(numFrames)); + +logger.info(LOG_PREFIX + '=== END message=' + connectorMessage.getMessageId() + + ' backend=' + backend + + ' failures=' + failures.length + + (failures.length == 0 ? ' result=ALL_PASS' : (' result=FAIL fails=[' + failures.join(' | ') + ']')) + ' ==='); diff --git a/server/tests/test-dicom-input-1.dcm b/server/tests/test-dicom-input-1.dcm new file mode 100644 index 0000000000..0c1526c7d3 Binary files /dev/null and b/server/tests/test-dicom-input-1.dcm differ diff --git a/server/tests/test-dicom-input-2.dcm b/server/tests/test-dicom-input-2.dcm new file mode 100644 index 0000000000..d58a9dc401 Binary files /dev/null and b/server/tests/test-dicom-input-2.dcm differ diff --git a/server/tests/test-dicom-input-3.dcm b/server/tests/test-dicom-input-3.dcm new file mode 100644 index 0000000000..f5c86947ad Binary files /dev/null and b/server/tests/test-dicom-input-3.dcm differ diff --git a/server/tests/test-dicom-output-1.xml b/server/tests/test-dicom-output-1.xml new file mode 100644 index 0000000000..851ce249cf --- /dev/null +++ b/server/tests/test-dicom-output-1.xml @@ -0,0 +1,33 @@ + + + 146 + + 00\01 + + 1.2.840.10008.5.1.4.1.1.2 + + 1.2.3.4.5.6.7.8.9.1 + + 1.2.840.10008.1.2 + + 1.2.40.0.13.1.1 + + dcm4che-2.0 + + ISO_IR 100 + + 20230101 + + 120000 + + CT + + Doe^John + + PAT001 + + 19800101 + + M + + diff --git a/server/tests/test-dicom-output-2.xml b/server/tests/test-dicom-output-2.xml new file mode 100644 index 0000000000..c3767e55b1 --- /dev/null +++ b/server/tests/test-dicom-output-2.xml @@ -0,0 +1,29 @@ + + + 146 + + 00\01 + + 1.2.840.10008.5.1.4.1.1.4 + + 1.2.3.4.5.6.7.8.9.2 + + 1.2.840.10008.1.2 + + 1.2.40.0.13.1.1 + + dcm4che-2.0 + + ISO_IR 100 + + 20230215 + + MR + + Smith^Jane + + PAT002 + + F + + diff --git a/server/tests/test-dicom-output-3.xml b/server/tests/test-dicom-output-3.xml new file mode 100644 index 0000000000..a12ef94838 --- /dev/null +++ b/server/tests/test-dicom-output-3.xml @@ -0,0 +1,9 @@ + + + US + + Brown^Bob + + PAT003 + +