diff --git a/backend/README.md b/backend/README.md index 29305b3..a5b190e 100644 --- a/backend/README.md +++ b/backend/README.md @@ -19,8 +19,8 @@ Default profile is `dev`, which enables: DIMSE status (current): -- Implemented: C-ECHO, C-STORE -- Not implemented yet: C-FIND, C-MOVE +- Implemented: C-ECHO, C-STORE, C-FIND (Study Root) +- Not implemented yet: C-MOVE DIMSE C-STORE currently uses a strict curated list of accepted transfer capabilities (SOP class + transfer syntax combinations) under `app.dimse.cstore.accepted-transfer-capabilities`. @@ -34,6 +34,29 @@ You can change transfer capabilities at runtime via API: - `DELETE /api/system/config/dimse/transfer-capabilities/{SOPClassUID}` - `POST /api/system/config/dimse/transfer-capabilities/replace` +C-FIND (Study Root) is always enabled when DIMSE server is enabled (`app.dimse.cstore.enabled=true`). +Supported query levels are `STUDY`, `SERIES`, and `IMAGE`. + +Query behavior: + +- Standard C-FIND keys are matched (`StudyInstanceUID`, `SeriesInstanceUID`, `SOPInstanceUID`, + `PatientID`, `PatientName`, `Modality`, `StudyDescription`, `SeriesDescription`). +- Old Dicoogle-like free-text and `keyword:value` filters are accepted on the same endpoint. +- Mixed queries are supported (for example: free text + strict DICOM keys together). +- Broad queries without UIDs are supported by scanning indexed instances and filtering in-memory. +- Response size is capped by `app.dimse.cfind.max-results` (default `1000`). +- Optional query negotiation supported: `FUZZY` (PN fuzzy matching) and `DATETIME` + (DT matching/ranges). +- Sequence/nested matching keys are supported for sequence items in the query dataset. +- C-CANCEL stops response emission and now also short-circuits plugin scanning. +- Invalid C-FIND identifiers (missing/unsupported level, invalid range syntax, DT ranges without + DATETIME negotiation) are rejected with `IdentifierDoesNotMatchSOPClass`. + +`keyword:value` filters: + +- Any DICOM keyword present in the object can be used (case-insensitive), for example + `StudyDate`, `AccessionNumber`, `PatientID`, `Modality`, `StudyDescription`. + Config source options: - `app.dimse.cstore.config.source=yaml` (single-node runtime updates) @@ -124,3 +147,19 @@ curl -i -u developer:developer \ ``` Expected: `404` with `application/problem+json`. + +### 8) C-FIND examples (findscu) + +Standard Study Root by UID: + +```bash +findscu -v -S -k QueryRetrieveLevel=STUDY -k StudyInstanceUID= \ + -aet TESTSCU -aec DICOOGLE localhost 11112 +``` + +Mixed free-text + keyword filter on same request (old Dicoogle style): + +```bash +findscu -v -S -k QueryRetrieveLevel=STUDY -k "PatientName=brain modality:MR" \ + -aet TESTSCU -aec DICOOGLE localhost 11112 +``` diff --git a/backend/dicoogle-next-protocol-dimse/pom.xml b/backend/dicoogle-next-protocol-dimse/pom.xml index 356fbdf..30ed743 100644 --- a/backend/dicoogle-next-protocol-dimse/pom.xml +++ b/backend/dicoogle-next-protocol-dimse/pom.xml @@ -20,6 +20,11 @@ dicoogle-next-core ${project.version} + + org.dicoogle + dicoogle-next-sdk + ${project.version} + org.dcm4che dcm4che-core diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java new file mode 100644 index 0000000..33fca01 --- /dev/null +++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java @@ -0,0 +1,278 @@ +package org.dicoogle.protocol.dimse; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; +import java.util.regex.Pattern; +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.Tag; +import org.dcm4che3.data.VR; +import org.dcm4che3.net.QueryOption; +import org.dcm4che3.net.Status; +import org.dcm4che3.net.service.DicomServiceException; +import org.dicoogle.sdk.query.DimseFindAccessPolicy; +import org.dicoogle.sdk.query.DimseFindServicePlugin; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class CFindService { + + private static final String STUDY_ROOT_MODEL_UID = "1.2.840.10008.5.1.4.1.2.2.1"; + private static final Pattern KEYWORD_PATTERN = Pattern.compile("[A-Za-z0-9_.-]+:[^\\s]+"); + private static final Logger LOGGER = LoggerFactory.getLogger(CFindService.class); + + private final List plugins; + private final List accessPolicies; + private final Set supportedLevels; + private final int maxResults; + private final MeterRegistry meterRegistry; + + public CFindService( + List plugins, + List accessPolicies, + DimseCFindProperties properties, + MeterRegistry meterRegistry) { + this.plugins = List.copyOf(plugins); + this.accessPolicies = List.copyOf(accessPolicies); + this.supportedLevels = + properties.getSupportedQueryLevels().stream() + .map(level -> level.toUpperCase(Locale.ROOT)) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + this.maxResults = properties.getMaxResults(); + this.meterRegistry = meterRegistry; + } + + public List find( + String affectedSopClassUid, + Attributes keys, + String callingAet, + String calledAet, + int associationSerialNo, + java.util.Set queryOptions, + BooleanSupplier cancelRequested) + throws DicomServiceException { + long startNs = System.nanoTime(); + increment("dicoogle.cfind.requests", null); + + if (!STUDY_ROOT_MODEL_UID.equals(affectedSopClassUid)) { + increment("dicoogle.cfind.failure", "unsupported-sop-class"); + throw new DicomServiceException( + Status.SOPclassNotSupported, "Only Study Root C-FIND is supported"); + } + + String queryLevel = keys.getString(Tag.QueryRetrieveLevel); + if (queryLevel == null || queryLevel.isBlank()) { + increment("dicoogle.cfind.failure", "missing-query-level"); + throw new DicomServiceException( + Status.IdentifierDoesNotMatchSOPClass, "Missing QueryRetrieveLevel"); + } + + String normalizedLevel = queryLevel.trim().toUpperCase(Locale.ROOT); + if (!supportedLevels.contains(normalizedLevel)) { + increment("dicoogle.cfind.failure", "unsupported-query-level"); + throw new DicomServiceException( + Status.IdentifierDoesNotMatchSOPClass, + "Unsupported QueryRetrieveLevel: " + normalizedLevel); + } + + DimseFindServicePlugin.QueryRetrieveLevel level; + try { + level = DimseFindServicePlugin.QueryRetrieveLevel.valueOf(normalizedLevel); + } catch (IllegalArgumentException ex) { + increment("dicoogle.cfind.failure", "invalid-query-level"); + throw new DicomServiceException( + Status.IdentifierDoesNotMatchSOPClass, "Invalid QueryRetrieveLevel"); + } + + validateIdentifier(keys, queryOptions); + + if (plugins.isEmpty()) { + increment("dicoogle.cfind.failure", "no-query-plugin"); + throw new DicomServiceException( + Status.UnableToProcess, "No DIMSE query plugin is configured"); + } + + String rawPatientName = keys.getString(Tag.PatientName, null); + String freeText = extractFreeText(rawPatientName); + Map keywordFilters = extractKeywordFilters(rawPatientName); + + Attributes normalizedKeys = new Attributes(keys); + if (freeText == null || freeText.isBlank()) { + normalizedKeys.setNull(Tag.PatientName, org.dcm4che3.data.VR.PN); + } else { + normalizedKeys.setString(Tag.PatientName, org.dcm4che3.data.VR.PN, freeText); + } + + DimseFindServicePlugin.FindRequest request = + new DimseFindServicePlugin.FindRequest( + DimseFindServicePlugin.InformationModel.STUDY_ROOT, + level, + callingAet, + calledAet, + associationSerialNo, + normalizedKeys, + freeText, + keywordFilters, + queryOptions.contains(QueryOption.FUZZY), + queryOptions.contains(QueryOption.DATETIME), + cancelRequested == null ? () -> false : cancelRequested); + + for (DimseFindAccessPolicy policy : accessPolicies) { + DimseFindAccessPolicy.Decision decision = policy.evaluate(request); + if (!decision.allowed()) { + increment("dicoogle.cfind.failure", "policy-denied"); + throw new DicomServiceException(Status.UnableToProcess, decision.reason()); + } + } + + LOGGER.info( + "C-FIND request association={} aet={} level={} keys={} freeText={} keywordFilters={}", + associationSerialNo, + callingAet, + normalizedLevel, + requestedKeyTags(normalizedKeys), + freeText, + keywordFilters.keySet()); + + for (DimseFindServicePlugin plugin : plugins) { + try { + List matches = plugin.find(request); + if (matches != null) { + List limited = + matches.size() <= maxResults ? matches : matches.subList(0, maxResults); + increment("dicoogle.cfind.success", null); + meterRegistry + .counter("dicoogle.cfind.matches", "level", normalizedLevel) + .increment(limited.size()); + recordLatency("success", normalizedLevel, startNs); + return limited; + } + } catch (IOException ex) { + increment("dicoogle.cfind.failure", "plugin-io"); + recordLatency("failure", normalizedLevel, startNs); + throw new DicomServiceException(Status.UnableToProcess, ex.getMessage()); + } + } + + increment("dicoogle.cfind.success", null); + recordLatency("success", normalizedLevel, startNs); + return List.of(); + } + + private String requestedKeyTags(Attributes keys) { + StringBuilder out = new StringBuilder(); + for (int tag : keys.tags()) { + String value = keys.getString(tag, null); + boolean hasValue = value != null && !value.isBlank(); + if (!out.isEmpty()) { + out.append(','); + } + out.append(String.format("%08X%s", tag, hasValue ? "=*" : "")); + } + return out.toString(); + } + + private String extractFreeText(String direct) { + if (direct == null || direct.isBlank()) { + return null; + } + + StringBuilder out = new StringBuilder(); + for (String token : direct.trim().split("\\s+")) { + if (!KEYWORD_PATTERN.matcher(token).matches()) { + if (!out.isEmpty()) { + out.append(' '); + } + out.append(token); + } + } + return out.isEmpty() ? null : out.toString(); + } + + private Map extractKeywordFilters(String raw) { + if (raw == null || raw.isBlank()) { + return Map.of(); + } + + Map out = new LinkedHashMap<>(); + for (String token : raw.trim().split("\\s+")) { + if (KEYWORD_PATTERN.matcher(token).matches()) { + int index = token.indexOf(':'); + if (index > 0 && index < token.length() - 1) { + out.put(token.substring(0, index), token.substring(index + 1)); + } + } + } + return out; + } + + private void increment(String meterName, String reason) { + if (reason == null) { + meterRegistry.counter(meterName).increment(); + } else { + meterRegistry.counter(meterName, "reason", reason).increment(); + } + } + + private void validateIdentifier(Attributes keys, Set queryOptions) + throws DicomServiceException { + for (int tag : keys.tags()) { + if (tag == Tag.QueryRetrieveLevel) { + continue; + } + + String value = keys.getString(tag, null); + if (value == null || value.isBlank()) { + continue; + } + + VR vr = keys.getVR(tag); + if (vr == VR.DT && containsRange(value) && !queryOptions.contains(QueryOption.DATETIME)) { + increment("dicoogle.cfind.failure", "datetime-negotiation-required"); + throw new DicomServiceException( + Status.IdentifierDoesNotMatchSOPClass, + "DT range matching requires DATETIME query negotiation"); + } + + if ((vr == VR.DA || vr == VR.TM || vr == VR.DT) && hasInvalidRangeSyntax(value)) { + increment("dicoogle.cfind.failure", "invalid-range-syntax"); + throw new DicomServiceException( + Status.IdentifierDoesNotMatchSOPClass, + String.format("Invalid range syntax for query key: %08X", tag)); + } + } + } + + private boolean containsRange(String value) { + return value.indexOf('-') >= 0; + } + + private boolean hasInvalidRangeSyntax(String value) { + int firstDash = value.indexOf('-'); + if (firstDash < 0) { + return false; + } + if (value.indexOf('-', firstDash + 1) >= 0) { + return true; + } + String start = value.substring(0, firstDash).trim(); + String end = value.substring(firstDash + 1).trim(); + return start.isEmpty() && end.isEmpty(); + } + + private void recordLatency(String outcome, String level, long startNs) { + Timer.builder("dicoogle.cfind.latency") + .tag("outcome", outcome) + .tag("level", level) + .register(meterRegistry) + .record(System.nanoTime() - startNs, TimeUnit.NANOSECONDS); + } +} diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindProperties.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindProperties.java new file mode 100644 index 0000000..58a47fd --- /dev/null +++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindProperties.java @@ -0,0 +1,30 @@ +package org.dicoogle.protocol.dimse; + +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.dimse.cfind") +public class DimseCFindProperties { + + private List supportedQueryLevels = List.of("STUDY", "SERIES", "IMAGE"); + private int maxResults = 1000; + + public List getSupportedQueryLevels() { + return supportedQueryLevels; + } + + public void setSupportedQueryLevels(List supportedQueryLevels) { + this.supportedQueryLevels = + supportedQueryLevels == null || supportedQueryLevels.isEmpty() + ? List.of("STUDY", "SERIES", "IMAGE") + : supportedQueryLevels; + } + + public int getMaxResults() { + return maxResults; + } + + public void setMaxResults(int maxResults) { + this.maxResults = Math.max(1, maxResults); + } +} diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java new file mode 100644 index 0000000..32a549c --- /dev/null +++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java @@ -0,0 +1,52 @@ +package org.dicoogle.protocol.dimse; + +import java.util.Set; +import org.dcm4che3.data.Attributes; +import org.dcm4che3.net.Association; +import org.dcm4che3.net.QueryOption; +import org.dcm4che3.net.pdu.PresentationContext; +import org.dcm4che3.net.service.BasicCFindSCP; +import org.dcm4che3.net.service.DicomServiceException; +import org.dcm4che3.net.service.QueryTask; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class DimseCFindSCP extends BasicCFindSCP { + + private static final Logger LOGGER = LoggerFactory.getLogger(DimseCFindSCP.class); + + private final CFindService cFindService; + + DimseCFindSCP(CFindService cFindService) { + super("1.2.840.10008.5.1.4.1.2.2.1"); + this.cFindService = cFindService; + } + + @Override + protected QueryTask calculateMatches( + Association as, PresentationContext pc, Attributes rq, Attributes keys) + throws DicomServiceException { + LOGGER.info( + "C-FIND request association={} callingAET={} calledAET={}", + as.getSerialNo(), + as.getCallingAET(), + as.getCalledAET()); + + Set queryOptions = + as.getQueryOptionsFor(rq.getString(org.dcm4che3.data.Tag.AffectedSOPClassUID)); + return new DimseListQueryTask( + as, + pc, + rq, + keys, + cancelRequested -> + cFindService.find( + rq.getString(org.dcm4che3.data.Tag.AffectedSOPClassUID), + keys, + as.getCallingAET(), + as.getCalledAET(), + as.getSerialNo(), + queryOptions, + cancelRequested)); + } +} diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreConfig.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreConfig.java index 8103251..2f1e122 100644 --- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreConfig.java +++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreConfig.java @@ -5,6 +5,7 @@ import org.dicoogle.core.storage.StorageRouter; import org.dicoogle.sdk.query.DimseAssociationAccessPolicy; import org.dicoogle.sdk.query.DimseAssociationEventListener; +import org.dicoogle.sdk.query.DimseFindAccessPolicy; import org.dicoogle.sdk.query.StorageIngestEventListener; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -12,7 +13,7 @@ import org.springframework.context.annotation.Configuration; @Configuration -@EnableConfigurationProperties(DimseCStoreProperties.class) +@EnableConfigurationProperties({DimseCStoreProperties.class, DimseCFindProperties.class}) public class DimseCStoreConfig { @Bean @@ -23,10 +24,20 @@ CStoreService cStoreService( return new CStoreService(storageRouter, storageIngestEventListeners, meterRegistry); } + @Bean + CFindService cFindService( + List queryPlugins, + List cfindAccessPolicies, + DimseCFindProperties properties, + MeterRegistry meterRegistry) { + return new CFindService(queryPlugins, cfindAccessPolicies, properties, meterRegistry); + } + @Bean @ConditionalOnProperty(prefix = "app.dimse.cstore", name = "enabled", havingValue = "true") DimseCStoreServer dimseCStoreServer( CStoreService cStoreService, + CFindService cFindService, DimseCStoreProperties properties, List associationEventListeners, List associationAccessPolicies, @@ -34,6 +45,7 @@ DimseCStoreServer dimseCStoreServer( String primaryStorageScheme) { return new DimseCStoreServer( cStoreService, + cFindService, properties, primaryStorageScheme, associationEventListeners, diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java index 9c9feff..c7d164d 100644 --- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java +++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.security.GeneralSecurityException; import java.time.Instant; +import java.util.EnumSet; import java.util.List; import java.util.Objects; import java.util.concurrent.ExecutorService; @@ -18,6 +19,7 @@ import org.dcm4che3.net.Connection; import org.dcm4che3.net.Device; import org.dcm4che3.net.PDVInputStream; +import org.dcm4che3.net.QueryOption; import org.dcm4che3.net.TransferCapability; import org.dcm4che3.net.pdu.AAssociateRJ; import org.dcm4che3.net.pdu.PresentationContext; @@ -35,7 +37,10 @@ public class DimseCStoreServer implements SmartLifecycle { + private static final String STUDY_ROOT_FIND_UID = "1.2.840.10008.5.1.4.1.2.2.1"; + private final CStoreService cStoreService; + private final CFindService cFindService; private final DimseCStoreProperties properties; private final String primaryStorageScheme; private final List associationEventListeners; @@ -47,17 +52,22 @@ public class DimseCStoreServer implements SmartLifecycle { private ScheduledExecutorService scheduler; public DimseCStoreServer( - CStoreService cStoreService, DimseCStoreProperties properties, String primaryStorageScheme) { - this(cStoreService, properties, primaryStorageScheme, List.of(), List.of()); + CStoreService cStoreService, + CFindService cFindService, + DimseCStoreProperties properties, + String primaryStorageScheme) { + this(cStoreService, cFindService, properties, primaryStorageScheme, List.of(), List.of()); } public DimseCStoreServer( CStoreService cStoreService, + CFindService cFindService, DimseCStoreProperties properties, String primaryStorageScheme, List associationEventListeners, List associationAccessPolicies) { this.cStoreService = Objects.requireNonNull(cStoreService); + this.cFindService = Objects.requireNonNull(cFindService); this.properties = Objects.requireNonNull(properties); this.primaryStorageScheme = primaryStorageScheme == null || primaryStorageScheme.isBlank() @@ -96,6 +106,7 @@ public synchronized void start() { DicomServiceRegistry services = new DicomServiceRegistry(); services.addDicomService(new BasicCEchoSCP()); + services.addDicomService(new DimseCFindSCP(cFindService)); services.addDicomService( new BasicCStoreSCP("*") { @Override @@ -221,6 +232,16 @@ private void configureTransferCapabilities( TransferCapability.Role.SCP, UID.ImplicitVRLittleEndian)); } + + TransferCapability cfindTc = + new TransferCapability( + "cfind-study-root-scp", + STUDY_ROOT_FIND_UID, + TransferCapability.Role.SCP, + UID.ImplicitVRLittleEndian, + UID.ExplicitVRLittleEndian); + cfindTc.setQueryOptions(EnumSet.of(QueryOption.DATETIME, QueryOption.FUZZY)); + ae.addTransferCapability(cfindTc); } public synchronized void applyAcceptedTransferCapabilities( diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseListQueryTask.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseListQueryTask.java new file mode 100644 index 0000000..8bfc961 --- /dev/null +++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseListQueryTask.java @@ -0,0 +1,56 @@ +package org.dicoogle.protocol.dimse; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.function.BooleanSupplier; +import org.dcm4che3.data.Attributes; +import org.dcm4che3.net.Association; +import org.dcm4che3.net.pdu.PresentationContext; +import org.dcm4che3.net.service.BasicQueryTask; +import org.dcm4che3.net.service.DicomServiceException; + +class DimseListQueryTask extends BasicQueryTask { + @FunctionalInterface + interface MatchProvider { + List load(BooleanSupplier cancelRequested) throws DicomServiceException; + } + + private final MatchProvider matchProvider; + private Iterator iterator; + private boolean initialized; + + DimseListQueryTask( + Association as, + PresentationContext pc, + Attributes rq, + Attributes keys, + MatchProvider matchProvider) { + super(as, pc, rq, keys); + this.matchProvider = matchProvider; + } + + @Override + protected boolean hasMoreMatches() throws DicomServiceException { + ensureInitialized(); + return iterator.hasNext(); + } + + @Override + protected Attributes nextMatch() { + return iterator.next(); + } + + private void ensureInitialized() throws DicomServiceException { + if (initialized) { + return; + } + initialized = true; + if (canceled) { + iterator = List.of().iterator(); + return; + } + List matches = matchProvider.load(() -> canceled); + iterator = new ArrayList<>(matches).iterator(); + } +} diff --git a/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java new file mode 100644 index 0000000..7f5876f --- /dev/null +++ b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java @@ -0,0 +1,212 @@ +package org.dicoogle.protocol.dimse; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.io.IOException; +import java.util.List; +import java.util.Set; +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.Tag; +import org.dcm4che3.data.VR; +import org.dcm4che3.net.QueryOption; +import org.dcm4che3.net.Status; +import org.dcm4che3.net.service.DicomServiceException; +import org.dicoogle.sdk.PluginMetadata; +import org.dicoogle.sdk.query.DimseFindAccessPolicy; +import org.dicoogle.sdk.query.DimseFindServicePlugin; +import org.junit.jupiter.api.Test; + +class CFindServiceTest { + + @Test + void failsWhenQueryLevelMissing() { + CFindService service = + new CFindService( + List.of(new DummyFindPlugin()), + List.of(), + new DimseCFindProperties(), + new SimpleMeterRegistry()); + Attributes keys = new Attributes(); + + DicomServiceException ex = + assertThrows( + DicomServiceException.class, + () -> service.find("1.2.840.10008.5.1.4.1.2.2.1", keys, "A", "B", 10, Set.of(), null)); + assertEquals(Status.IdentifierDoesNotMatchSOPClass, ex.getStatus()); + } + + @Test + void supportsFreeTextAndLevel() throws Exception { + CFindService service = + new CFindService( + List.of(new DummyFindPlugin()), + List.of(), + new DimseCFindProperties(), + new SimpleMeterRegistry()); + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + keys.setString(Tag.StudyInstanceUID, VR.UI, "1.2.3"); + keys.setString(Tag.AccessionNumber, VR.SH, "brain"); + + List matches = + service.find("1.2.840.10008.5.1.4.1.2.2.1", keys, "CALLING", "CALLED", 77, Set.of(), null); + + assertEquals(1, matches.size()); + assertEquals("1.2.3", matches.get(0).getString(Tag.StudyInstanceUID)); + } + + @Test + void rejectsDateTimeRangeWithoutNegotiation() { + CFindService service = + new CFindService( + List.of(new DummyFindPlugin()), + List.of(), + new DimseCFindProperties(), + new SimpleMeterRegistry()); + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + keys.setString(Tag.AcquisitionDateTime, VR.DT, "20240101000000-20241231235959"); + + DicomServiceException ex = + assertThrows( + DicomServiceException.class, + () -> + service.find( + "1.2.840.10008.5.1.4.1.2.2.1", keys, "CALLING", "CALLED", 90, Set.of(), null)); + assertEquals(Status.IdentifierDoesNotMatchSOPClass, ex.getStatus()); + } + + @Test + void rejectsInvalidRangeSyntax() { + CFindService service = + new CFindService( + List.of(new DummyFindPlugin()), + List.of(), + new DimseCFindProperties(), + new SimpleMeterRegistry()); + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + keys.setString(Tag.StudyDate, VR.DA, "-"); + + DicomServiceException ex = + assertThrows( + DicomServiceException.class, + () -> + service.find( + "1.2.840.10008.5.1.4.1.2.2.1", keys, "CALLING", "CALLED", 91, Set.of(), null)); + assertEquals(Status.IdentifierDoesNotMatchSOPClass, ex.getStatus()); + } + + @Test + void acceptsDateTimeRangeWithNegotiation() throws Exception { + CFindService service = + new CFindService( + List.of(new DummyFindPlugin()), + List.of(), + new DimseCFindProperties(), + new SimpleMeterRegistry()); + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + keys.setString(Tag.AcquisitionDateTime, VR.DT, "20240101000000-20241231235959"); + + List matches = + service.find( + "1.2.840.10008.5.1.4.1.2.2.1", + keys, + "CALLING", + "CALLED", + 92, + Set.of(QueryOption.DATETIME), + null); + assertEquals(1, matches.size()); + } + + @Test + void appliesConfiguredMaxResults() throws Exception { + DimseCFindProperties properties = new DimseCFindProperties(); + properties.setMaxResults(1); + CFindService service = + new CFindService( + List.of(new MultiResultFindPlugin()), + List.of(), + properties, + new SimpleMeterRegistry()); + + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + + List matches = + service.find("1.2.840.10008.5.1.4.1.2.2.1", keys, "CALLING", "CALLED", 88, Set.of(), null); + assertEquals(1, matches.size()); + } + + @Test + void deniesQueryWhenPolicyRejects() { + DimseFindAccessPolicy denyPolicy = + new DimseFindAccessPolicy() { + @Override + public org.dicoogle.sdk.PluginMetadata metadata() { + return new org.dicoogle.sdk.PluginMetadata( + "deny-cfind", "Deny C-FIND", "1.0.0", "query-index"); + } + + @Override + public Decision evaluate(DimseFindServicePlugin.FindRequest request) { + return Decision.deny("denied by policy"); + } + }; + CFindService service = + new CFindService( + List.of(new DummyFindPlugin()), + List.of(denyPolicy), + new DimseCFindProperties(), + new SimpleMeterRegistry()); + + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + + DicomServiceException ex = + assertThrows( + DicomServiceException.class, + () -> + service.find( + "1.2.840.10008.5.1.4.1.2.2.1", keys, "CALLING", "CALLED", 99, Set.of(), null)); + assertEquals(Status.UnableToProcess, ex.getStatus()); + } + + private static final class DummyFindPlugin implements DimseFindServicePlugin { + + @Override + public PluginMetadata metadata() { + return new PluginMetadata("dummy-find", "Dummy Find", "1.0.0", "query-index"); + } + + @Override + public List find(FindRequest request) throws IOException { + Attributes a = new Attributes(); + a.setString( + Tag.StudyInstanceUID, VR.UI, request.keys().getString(Tag.StudyInstanceUID, "1.2.3")); + a.setString(Tag.QueryRetrieveLevel, VR.CS, request.level().name()); + return List.of(a); + } + } + + private static final class MultiResultFindPlugin implements DimseFindServicePlugin { + + @Override + public PluginMetadata metadata() { + return new PluginMetadata("multi-find", "Multi Find", "1.0.0", "query-index"); + } + + @Override + public List find(FindRequest request) throws IOException { + Attributes a1 = new Attributes(); + a1.setString(Tag.StudyInstanceUID, VR.UI, "1.2.3"); + Attributes a2 = new Attributes(); + a2.setString(Tag.StudyInstanceUID, VR.UI, "1.2.4"); + return List.of(a1, a2); + } + } +} diff --git a/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseCFindSCPIntegrationTest.java b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseCFindSCPIntegrationTest.java new file mode 100644 index 0000000..182391a --- /dev/null +++ b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseCFindSCPIntegrationTest.java @@ -0,0 +1,370 @@ +package org.dicoogle.protocol.dimse; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.ServerSocket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.function.BooleanSupplier; +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.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.DimseRSP; +import org.dcm4che3.net.IncompatibleConnectionException; +import org.dcm4che3.net.QueryOption; +import org.dcm4che3.net.Status; +import org.dcm4che3.net.TransferCapability; +import org.dcm4che3.net.pdu.AAssociateRQ; +import org.dicoogle.core.storage.StorageRouter; +import org.dicoogle.sdk.PluginMetadata; +import org.dicoogle.sdk.query.DimseFindServicePlugin; +import org.dicoogle.sdk.storage.StoredObject; +import org.dicoogle.sdk.storage.WritableStoragePlugin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class DimseCFindSCPIntegrationTest { + + private DimseCStoreServer server; + private Device scuDevice; + private Association association; + + @AfterEach + void tearDown() throws Exception { + if (association != null) { + association.release(); + association.waitForSocketClose(); + association = null; + } + if (scuDevice != null) { + scuDevice.waitForNoOpenConnections(); + scuDevice.unbindConnections(); + scuDevice = null; + } + if (server != null) { + server.stop(); + server = null; + } + } + + @Test + void negotiatedFuzzyMatchingReturnsResult() throws Exception { + Path root = Files.createTempDirectory("cfind-integ-fuzzy"); + int port = randomPort(); + startServer(root, port); + + storeDicom( + root, createDicom("P1", "FELIX^ALMEIDA", "A1", "20240101", "101010", "20240101101010")); + + association = openScu(port, EnumSet.of(QueryOption.FUZZY)); + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + keys.setString(Tag.PatientName, VR.PN, "felixal"); + + DimseRSP rsp = + association.cfind("1.2.840.10008.5.1.4.1.2.2.1", 0, keys, UID.ImplicitVRLittleEndian, 0); + + int pending = 0; + boolean successSeen = false; + while (rsp.next()) { + int status = rsp.getCommand().getInt(Tag.Status, -1); + if (Status.isPending(status)) { + pending++; + } else { + successSeen = status == Status.Success; + } + } + + assertTrue(successSeen); + assertTrue(pending >= 1); + } + + @Test + void missingDateTimeNegotiationReturnsIdentifierError() throws Exception { + Path root = Files.createTempDirectory("cfind-integ-datetime"); + int port = randomPort(); + startServer(root, port); + + storeDicom(root, createDicom("P1", "ANA", "A1", "20240101", "101010", "20240101101010")); + + association = openScu(port, EnumSet.noneOf(QueryOption.class)); + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + keys.setString(Tag.AcquisitionDateTime, VR.DT, "20240101000000-20241231235959"); + + DimseRSP rsp = + association.cfind("1.2.840.10008.5.1.4.1.2.2.1", 0, keys, UID.ImplicitVRLittleEndian, 0); + assertTrue(rsp.next()); + int status = rsp.getCommand().getInt(Tag.Status, -1); + assertEquals(Status.IdentifierDoesNotMatchSOPClass, status); + } + + @Test + void negotiatedDateTimeRangeReturnsPendingMatch() throws Exception { + Path root = Files.createTempDirectory("cfind-integ-datetime-ok"); + int port = randomPort(); + startServer(root, port); + + storeDicom(root, createDicom("P1", "ANA", "A1", "20240101", "101010", "20240101101010")); + + association = openScu(port, EnumSet.of(QueryOption.DATETIME)); + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + keys.setString(Tag.AcquisitionDateTime, VR.DT, "20240101000000-20241231235959"); + + DimseRSP rsp = + association.cfind("1.2.840.10008.5.1.4.1.2.2.1", 0, keys, UID.ImplicitVRLittleEndian, 0); + + int pending = 0; + int finalStatus = -1; + while (rsp.next()) { + int status = rsp.getCommand().getInt(Tag.Status, -1); + if (Status.isPending(status)) { + pending++; + } else { + finalStatus = status; + } + } + + assertTrue(pending >= 1); + assertEquals(Status.Success, finalStatus); + } + + private void startServer(Path root, int port) { + WritableStoragePlugin storage = new InMemoryStoragePlugin(root); + CStoreService cStoreService = new CStoreService(new StorageRouter(List.of(storage))); + CFindService cFindService = + new CFindService( + List.of(new IntegrationFindPlugin(root)), + List.of(), + new DimseCFindProperties(), + new SimpleMeterRegistry()); + + DimseCStoreProperties properties = new DimseCStoreProperties(); + properties.setEnabled(true); + properties.setAeTitle("DICOOGLE"); + properties.setBindAddress("127.0.0.1"); + properties.setPort(port); + properties.setStorageScheme("mem"); + + server = new DimseCStoreServer(cStoreService, cFindService, properties, "mem"); + server.start(); + } + + private Association openScu(int port, EnumSet options) + throws IOException, + InterruptedException, + GeneralSecurityException, + IncompatibleConnectionException { + scuDevice = new Device("cfind-scu-device"); + scuDevice.setExecutor(java.util.concurrent.Executors.newCachedThreadPool()); + scuDevice.setScheduledExecutor( + java.util.concurrent.Executors.newSingleThreadScheduledExecutor()); + Connection localConn = new Connection(); + localConn.setHostname("127.0.0.1"); + ApplicationEntity scu = new ApplicationEntity("TESTSCU"); + scu.setAssociationInitiator(true); + scu.setAssociationAcceptor(false); + TransferCapability tc = + new TransferCapability( + "cfind-scu", + "1.2.840.10008.5.1.4.1.2.2.1", + TransferCapability.Role.SCU, + UID.ImplicitVRLittleEndian, + UID.ExplicitVRLittleEndian); + tc.setQueryOptions(EnumSet.of(QueryOption.DATETIME, QueryOption.FUZZY)); + scu.addTransferCapability(tc); + + scuDevice.addConnection(localConn); + scuDevice.addApplicationEntity(scu); + scu.addConnection(localConn); + + Connection remote = new Connection(); + remote.setHostname("127.0.0.1"); + remote.setPort(port); + + AAssociateRQ rq = new AAssociateRQ(); + rq.setCallingAET("TESTSCU"); + rq.setCalledAET("DICOOGLE"); + rq.addPresentationContext( + new org.dcm4che3.net.pdu.PresentationContext( + 1, + "1.2.840.10008.5.1.4.1.2.2.1", + UID.ImplicitVRLittleEndian, + UID.ExplicitVRLittleEndian)); + rq.addExtendedNegotiation( + new org.dcm4che3.net.pdu.ExtendedNegotiation( + "1.2.840.10008.5.1.4.1.2.2.1", QueryOption.toExtendedNegotiationInformation(options))); + + return scu.connect(localConn, remote, rq); + } + + private static int randomPort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private static byte[] createDicom( + String patientId, + String patientName, + String accession, + String studyDate, + String studyTime, + String acquisitionDateTime) + throws IOException { + Attributes fmi = new Attributes(); + fmi.setString(Tag.TransferSyntaxUID, VR.UI, UID.ExplicitVRLittleEndian); + fmi.setString(Tag.MediaStorageSOPClassUID, VR.UI, UID.SecondaryCaptureImageStorage); + fmi.setString(Tag.MediaStorageSOPInstanceUID, VR.UI, "1.2.3.4.5.6.7"); + fmi.setString(Tag.ImplementationClassUID, VR.UI, "1.2.826.0.1.3680043.2.1125.99"); + + Attributes attrs = new Attributes(); + attrs.setString(Tag.SOPClassUID, VR.UI, UID.SecondaryCaptureImageStorage); + attrs.setString(Tag.SOPInstanceUID, VR.UI, "1.2.3.4.5.6.7"); + attrs.setString(Tag.StudyInstanceUID, VR.UI, "1.2.3"); + attrs.setString(Tag.SeriesInstanceUID, VR.UI, "1.2.3.1"); + attrs.setString(Tag.PatientID, VR.LO, patientId); + attrs.setString(Tag.PatientName, VR.PN, patientName); + attrs.setString(Tag.AccessionNumber, VR.SH, accession); + attrs.setString(Tag.StudyDate, VR.DA, studyDate); + attrs.setString(Tag.StudyTime, VR.TM, studyTime); + attrs.setString(Tag.AcquisitionDateTime, VR.DT, acquisitionDateTime); + + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + try (DicomOutputStream dos = new DicomOutputStream(out, UID.ExplicitVRLittleEndian)) { + dos.writeDataset(fmi, attrs); + } + return out.toByteArray(); + } + + private static void storeDicom(Path root, byte[] payload) throws IOException { + WritableStoragePlugin plugin = new InMemoryStoragePlugin(root); + StoredObject stored = plugin.store(new ByteArrayInputStream(payload), "application/dicom"); + assertNotNull(stored); + assertFalse(stored.location().toString().isBlank()); + } + + private record InMemoryStoragePlugin(Path root) implements WritableStoragePlugin { + + @Override + public String scheme() { + return "mem"; + } + + @Override + public org.dicoogle.sdk.PluginMetadata metadata() { + return new org.dicoogle.sdk.PluginMetadata("mem", "Mem Storage", "1.0.0", "storage"); + } + + @Override + public StoredObject store(java.io.InputStream data, String contentType) throws IOException { + byte[] bytes = data.readAllBytes(); + String patient = "UNKNOWN"; + String study = "UNKNOWN"; + String series = "UNKNOWN"; + String sop = "UNKNOWN"; + try (org.dcm4che3.io.DicomInputStream dis = + new org.dcm4che3.io.DicomInputStream(new ByteArrayInputStream(bytes))) { + Attributes attrs = dis.readDataset(); + patient = attrs.getString(Tag.PatientID, patient); + study = attrs.getString(Tag.StudyInstanceUID, study); + series = attrs.getString(Tag.SeriesInstanceUID, series); + sop = attrs.getString(Tag.SOPInstanceUID, sop); + } + Path path = root.resolve(patient).resolve(study).resolve(series); + Files.createDirectories(path); + Path file = path.resolve(sop + ".dcm"); + Files.write(file, bytes); + return new StoredObject(file.toUri(), bytes.length, contentType); + } + } + + private record IntegrationFindPlugin(Path root) implements DimseFindServicePlugin { + + @Override + public PluginMetadata metadata() { + return new PluginMetadata("integration-find", "Integration Find", "1.0.0", "query-index"); + } + + @Override + public List find(FindRequest request) throws IOException { + List out = new ArrayList<>(); + BooleanSupplier cancelRequested = request.cancelRequested(); + List files = + Files.walk(root) + .filter( + path -> + Files.isRegularFile(path) && path.getFileName().toString().endsWith(".dcm")) + .toList(); + + for (Path file : files) { + if (cancelRequested != null && cancelRequested.getAsBoolean()) { + break; + } + try (DicomInputStream dis = new DicomInputStream(Files.newInputStream(file))) { + Attributes attrs = dis.readDataset(); + if (!matches(attrs, request)) { + continue; + } + out.add(attrs); + } + } + return out; + } + + private boolean matches(Attributes attrs, FindRequest request) { + String expectedName = request.keys().getString(Tag.PatientName, null); + if (expectedName != null && !expectedName.isBlank()) { + String actualName = attrs.getString(Tag.PatientName, ""); + if (request.fuzzyMatchingEnabled()) { + String lhs = actualName.toLowerCase().replaceAll("[^a-z0-9]", ""); + String rhs = expectedName.toLowerCase().replaceAll("[^a-z0-9]", ""); + if (!lhs.contains(rhs)) { + return false; + } + } else if (!actualName.equalsIgnoreCase(expectedName)) { + return false; + } + } + + String expectedDateTime = request.keys().getString(Tag.AcquisitionDateTime, null); + if (expectedDateTime != null && !expectedDateTime.isBlank()) { + if (!request.dateTimeMatchingEnabled()) { + return false; + } + String actualDateTime = attrs.getString(Tag.AcquisitionDateTime, ""); + int dash = expectedDateTime.indexOf('-'); + if (dash < 0) { + return actualDateTime.equals(expectedDateTime); + } + String start = expectedDateTime.substring(0, dash).trim(); + String end = expectedDateTime.substring(dash + 1).trim(); + if (!start.isBlank() && actualDateTime.compareTo(start) < 0) { + return false; + } + if (!end.isBlank() && actualDateTime.compareTo(end) > 0) { + return false; + } + } + return true; + } + } +} diff --git a/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseListQueryTaskTest.java b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseListQueryTaskTest.java new file mode 100644 index 0000000..7c9cd1c --- /dev/null +++ b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseListQueryTaskTest.java @@ -0,0 +1,71 @@ +package org.dicoogle.protocol.dimse; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.lang.reflect.Field; +import java.util.List; +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.Tag; +import org.dcm4che3.data.VR; +import org.junit.jupiter.api.Test; + +class DimseListQueryTaskTest { + + @Test + void onCancelRequestMarksTaskAsCanceled() throws Exception { + Attributes rq = new Attributes(); + rq.setInt(Tag.MessageID, VR.US, 7); + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + + Attributes match = new Attributes(); + match.setString(Tag.StudyInstanceUID, VR.UI, "1.2.3"); + + DimseListQueryTask task = + new DimseListQueryTask(null, null, rq, keys, ignored -> List.of(match)); + + task.onCancelRQ(null); + + Field canceledField = task.getClass().getSuperclass().getDeclaredField("canceled"); + canceledField.setAccessible(true); + assertEquals(true, canceledField.getBoolean(task)); + } + + @Test + void returnsProvidedMatchesInOrder() throws Exception { + Attributes rq = new Attributes(); + rq.setInt(Tag.MessageID, VR.US, 9); + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + + Attributes first = new Attributes(); + first.setString(Tag.StudyInstanceUID, VR.UI, "1.2.3"); + Attributes second = new Attributes(); + second.setString(Tag.StudyInstanceUID, VR.UI, "1.2.4"); + + DimseListQueryTask task = + new DimseListQueryTask(null, null, rq, keys, ignored -> List.of(first, second)); + + assertEquals(true, invokeHasMore(task)); + assertEquals("1.2.3", invokeNext(task).getString(Tag.StudyInstanceUID)); + assertEquals(true, invokeHasMore(task)); + assertEquals("1.2.4", invokeNext(task).getString(Tag.StudyInstanceUID)); + assertFalse(invokeHasMore(task)); + } + + private boolean invokeHasMore(DimseListQueryTask task) throws Exception { + var method = DimseListQueryTask.class.getDeclaredMethod("hasMoreMatches"); + method.setAccessible(true); + return (boolean) method.invoke(task); + } + + private Attributes invokeNext(DimseListQueryTask task) throws Exception { + var method = DimseListQueryTask.class.getDeclaredMethod("nextMatch"); + method.setAccessible(true); + Attributes out = (Attributes) method.invoke(task); + assertNotNull(out); + return out; + } +} diff --git a/backend/dicoogle-next-sdk/pom.xml b/backend/dicoogle-next-sdk/pom.xml index de4a0b1..350219d 100644 --- a/backend/dicoogle-next-sdk/pom.xml +++ b/backend/dicoogle-next-sdk/pom.xml @@ -13,4 +13,12 @@ dicoogle-next-sdk dicoogle-next-sdk Plugin contracts for Dicoogle Next + + + + org.dcm4che + dcm4che-core + ${dcm4che.version} + + diff --git a/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindAccessPolicy.java b/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindAccessPolicy.java new file mode 100644 index 0000000..dba36d3 --- /dev/null +++ b/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindAccessPolicy.java @@ -0,0 +1,18 @@ +package org.dicoogle.sdk.query; + +public interface DimseFindAccessPolicy extends QueryIndexPlugin { + + default Decision evaluate(DimseFindServicePlugin.FindRequest request) { + return Decision.allow(); + } + + record Decision(boolean allowed, String reason) { + public static Decision allow() { + return new Decision(true, ""); + } + + public static Decision deny(String reason) { + return new Decision(false, reason == null ? "c-find request denied" : reason); + } + } +} diff --git a/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindServicePlugin.java b/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindServicePlugin.java new file mode 100644 index 0000000..eaa3703 --- /dev/null +++ b/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindServicePlugin.java @@ -0,0 +1,34 @@ +package org.dicoogle.sdk.query; + +import java.io.IOException; +import java.util.List; +import java.util.function.BooleanSupplier; +import org.dcm4che3.data.Attributes; + +public interface DimseFindServicePlugin extends QueryIndexPlugin { + + enum InformationModel { + STUDY_ROOT + } + + enum QueryRetrieveLevel { + STUDY, + SERIES, + IMAGE + } + + record FindRequest( + InformationModel informationModel, + QueryRetrieveLevel level, + String callingAet, + String calledAet, + int associationSerialNo, + Attributes keys, + String freeText, + java.util.Map keywordFilters, + boolean fuzzyMatchingEnabled, + boolean dateTimeMatchingEnabled, + BooleanSupplier cancelRequested) {} + + List find(FindRequest request) throws IOException; +} diff --git a/backend/dicoogle-next/pom.xml b/backend/dicoogle-next/pom.xml index 8808e0d..58d63b3 100644 --- a/backend/dicoogle-next/pom.xml +++ b/backend/dicoogle-next/pom.xml @@ -61,6 +61,11 @@ dicoogle-next-storage-file-rw ${project.version} + + org.dicoogle + dicoogle-next-query-file + ${project.version} + org.dicoogle dicoogle-next-protocol-dimse diff --git a/backend/dicoogle-next/src/main/resources/application-dev.yml b/backend/dicoogle-next/src/main/resources/application-dev.yml index 6b0f5d8..ce6033d 100644 --- a/backend/dicoogle-next/src/main/resources/application-dev.yml +++ b/backend/dicoogle-next/src/main/resources/application-dev.yml @@ -20,6 +20,7 @@ springdoc: app: dimse: + cfind: cstore: enabled: true storage: diff --git a/backend/dicoogle-next/src/main/resources/application-test.yml b/backend/dicoogle-next/src/main/resources/application-test.yml index eac74ba..a3cf627 100644 --- a/backend/dicoogle-next/src/main/resources/application-test.yml +++ b/backend/dicoogle-next/src/main/resources/application-test.yml @@ -16,6 +16,7 @@ springdoc: app: dimse: + cfind: cstore: enabled: false security: diff --git a/backend/dicoogle-next/src/main/resources/application.yml b/backend/dicoogle-next/src/main/resources/application.yml index cac6cea..f6c87c2 100644 --- a/backend/dicoogle-next/src/main/resources/application.yml +++ b/backend/dicoogle-next/src/main/resources/application.yml @@ -37,6 +37,12 @@ app: docs-enabled: false allowed-origins: [] dimse: + cfind: + max-results: 1000 + supported-query-levels: + - STUDY + - SERIES + - IMAGE cstore: enabled: false ae-title: DICOOGLE diff --git a/backend/plugins/dicoogle-next-query-file/pom.xml b/backend/plugins/dicoogle-next-query-file/pom.xml new file mode 100644 index 0000000..08180c4 --- /dev/null +++ b/backend/plugins/dicoogle-next-query-file/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + + org.dicoogle + dicoogle-next-backend-parent + 0.0.1-SNAPSHOT + ../../pom.xml + + + dicoogle-next-query-file + dicoogle-next-query-file + Filesystem query/index plugin for Dicoogle Next + + + + org.dicoogle + dicoogle-next-sdk + ${project.version} + + + org.dicoogle + dicoogle-next-storage-file-rw + ${project.version} + + + org.dcm4che + dcm4che-core + ${dcm4che.version} + + + org.springframework + spring-context + + + org.springframework.boot + spring-boot-autoconfigure + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileQueryConfig.java b/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileQueryConfig.java new file mode 100644 index 0000000..3ca3708 --- /dev/null +++ b/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileQueryConfig.java @@ -0,0 +1,17 @@ +package org.dicoogle.query.file; + +import org.dicoogle.storage.filerw.FileReadWriteStoragePlugin; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class FileQueryConfig { + + @Bean + @ConditionalOnBean(FileReadWriteStoragePlugin.class) + FileReadWriteDimseFindPlugin fileReadWriteDimseFindPlugin( + FileReadWriteStoragePlugin storagePlugin) { + return new FileReadWriteDimseFindPlugin(storagePlugin); + } +} diff --git a/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java b/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java new file mode 100644 index 0000000..cd3a53a --- /dev/null +++ b/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java @@ -0,0 +1,406 @@ +package org.dicoogle.query.file; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.ElementDictionary; +import org.dcm4che3.data.Sequence; +import org.dcm4che3.data.Tag; +import org.dcm4che3.data.VR; +import org.dcm4che3.io.DicomInputStream; +import org.dicoogle.sdk.PluginMetadata; +import org.dicoogle.sdk.query.DimseFindServicePlugin; +import org.dicoogle.storage.filerw.FileReadWriteStoragePlugin; + +public class FileReadWriteDimseFindPlugin implements DimseFindServicePlugin { + + private static final PluginMetadata METADATA = + new PluginMetadata("query-file-rw", "Filesystem DIMSE C-FIND Query", "0.1.0", "query-index"); + private static final Pattern KEYWORD_PATTERN = Pattern.compile("[A-Za-z0-9_.-]+:[^\\s]+"); + + private final FileReadWriteStoragePlugin storagePlugin; + + public FileReadWriteDimseFindPlugin(FileReadWriteStoragePlugin storagePlugin) { + this.storagePlugin = Objects.requireNonNull(storagePlugin); + } + + @Override + public PluginMetadata metadata() { + return METADATA; + } + + @Override + public List find(FindRequest request) throws IOException { + String studyUid = request.keys().getString(Tag.StudyInstanceUID, null); + String seriesUid = request.keys().getString(Tag.SeriesInstanceUID, null); + String sopUid = request.keys().getString(Tag.SOPInstanceUID, null); + + List uris; + if (request.level() == QueryRetrieveLevel.IMAGE + && hasText(studyUid) + && hasText(seriesUid) + && hasText(sopUid)) { + uris = + storagePlugin.locateInstance(studyUid, seriesUid, sopUid).map(List::of).orElse(List.of()); + } else if (request.level() == QueryRetrieveLevel.SERIES + && hasText(studyUid) + && hasText(seriesUid)) { + uris = storagePlugin.listSeriesInstances(studyUid, seriesUid); + } else if (request.level() == QueryRetrieveLevel.STUDY && hasText(studyUid)) { + uris = storagePlugin.listStudyInstances(studyUid); + } else { + uris = storagePlugin.listAllInstances(); + } + + Set seen = new LinkedHashSet<>(); + List out = new ArrayList<>(); + String freeText = + request.freeText() == null ? null : request.freeText().toLowerCase(Locale.ROOT); + Map filters = request.keywordFilters(); + + for (java.net.URI uri : uris) { + if (!seen.add(uri.toString())) { + continue; + } + if (request.cancelRequested() != null && request.cancelRequested().getAsBoolean()) { + break; + } + Attributes attrs = readDataset(uri); + if (!matchesFreeText(attrs, freeText)) { + continue; + } + if (!matchesKeywordFilters(attrs, filters, request)) { + continue; + } + if (!matchesDicomKeys(attrs, request.keys(), request)) { + continue; + } + out.add(filterByLevel(attrs, request.level())); + } + + return out; + } + + private Attributes readDataset(java.net.URI location) throws IOException { + try (var stream = storagePlugin.openForRead(location); + DicomInputStream dis = new DicomInputStream(stream)) { + return dis.readDataset(); + } + } + + private boolean matchesFreeText(Attributes attrs, String freeText) { + if (freeText == null || freeText.isBlank()) { + return true; + } + String patientName = attrs.getString(Tag.PatientName, ""); + String patientId = attrs.getString(Tag.PatientID, ""); + String accession = attrs.getString(Tag.AccessionNumber, ""); + String studyDesc = attrs.getString(Tag.StudyDescription, ""); + String seriesDesc = attrs.getString(Tag.SeriesDescription, ""); + + String haystack = + (patientName + " " + patientId + " " + accession + " " + studyDesc + " " + seriesDesc) + .toLowerCase(Locale.ROOT); + return haystack.contains(freeText); + } + + private boolean matchesKeywordFilters( + Attributes attrs, Map filters, FindRequest request) { + if (filters == null || filters.isEmpty()) { + return true; + } + + for (Map.Entry entry : filters.entrySet()) { + int tag = resolveKeywordToTag(entry.getKey(), attrs); + if (tag == -1) { + return false; + } + VR vr = attrs.getVR(tag); + if (!matchesTagValue(attrs, entry.getValue(), tag, vr == null ? VR.LO : vr, request)) { + return false; + } + } + return true; + } + + private int resolveKeywordToTag(String keyword, Attributes attrs) { + int direct = ElementDictionary.tagForKeyword(keyword, null); + if (direct >= 0) { + return direct; + } + + String normalized = normalizeKeyword(keyword); + for (int tag : attrs.tags()) { + String candidate = ElementDictionary.keywordOf(tag, null); + if (candidate != null && normalizeKeyword(candidate).equals(normalized)) { + return tag; + } + } + return -1; + } + + private String normalizeKeyword(String keyword) { + StringBuilder out = new StringBuilder(keyword.length()); + for (char c : keyword.toCharArray()) { + if (Character.isLetterOrDigit(c)) { + out.append(Character.toLowerCase(c)); + } + } + return out.toString(); + } + + private boolean matchesDicomKeys(Attributes attrs, Attributes keys, FindRequest request) { + for (int tag : keys.tags()) { + if (tag == Tag.QueryRetrieveLevel) { + continue; + } + VR vr = keys.getVR(tag); + if (vr == VR.SQ) { + if (!matchesSequence(attrs, keys, tag, request)) { + return false; + } + continue; + } + + String expected = keys.getString(tag, null); + if (!hasText(expected)) { + continue; + } + if (!matchesTagValue(attrs, expected, tag, vr, request)) { + return false; + } + } + return true; + } + + private boolean matchesTagValue( + Attributes attrs, String expected, int tag, VR vr, FindRequest request) { + String actual = attrs.getString(tag, ""); + if (vr == VR.UI) { + return matchesUidValue(actual, expected); + } + if (vr == VR.DA) { + return matchesDateValue(actual, expected); + } + if (vr == VR.TM) { + return matchesRangeValue(actual, expected); + } + if (vr == VR.DT) { + return request.dateTimeMatchingEnabled() && matchesRangeValue(actual, expected); + } + boolean fuzzy = vr == VR.PN && request.fuzzyMatchingEnabled(); + return matchesStringValue(actual, expected, fuzzy); + } + + private boolean matchesUidValue(String actual, String expected) { + for (String candidate : splitMultiValue(expected)) { + if (actual.equals(candidate)) { + return true; + } + } + return false; + } + + private boolean matchesStringValue(String actual, String expected, boolean fuzzy) { + for (String candidate : splitMultiValue(expected)) { + if (fuzzy) { + if (normalizeForFuzzy(actual).contains(normalizeForFuzzy(candidate))) { + return true; + } + continue; + } + + if (candidate.indexOf('*') >= 0 || candidate.indexOf('?') >= 0) { + String regex = wildcardToRegex(candidate); + if (Pattern.compile(regex, Pattern.CASE_INSENSITIVE | Pattern.DOTALL) + .matcher(actual) + .matches()) { + return true; + } + continue; + } + + if (actual.equalsIgnoreCase(candidate)) { + return true; + } + } + return false; + } + + private List splitMultiValue(String expected) { + String[] values = expected.split("\\\\"); + List out = new ArrayList<>(values.length); + for (String value : values) { + if (hasText(value)) { + out.add(value.trim()); + } + } + return out; + } + + private String normalizeForFuzzy(String value) { + return value == null ? "" : value.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + } + + private String wildcardToRegex(String wildcard) { + StringBuilder regex = new StringBuilder("^"); + for (char c : wildcard.toCharArray()) { + if (c == '*') { + regex.append(".*"); + } else if (c == '?') { + regex.append('.'); + } else if ("\\.^$|()[]{}+".indexOf(c) >= 0) { + regex.append('\\').append(c); + } else { + regex.append(c); + } + } + regex.append('$'); + return regex.toString(); + } + + private boolean matchesDateValue(String actual, String expected) { + return matchesRangeValue(actual, expected); + } + + private boolean matchesRangeValue(String actual, String expected) { + int dash = expected.indexOf('-'); + if (dash < 0) { + return actual.equals(expected); + } + + String start = expected.substring(0, dash).trim(); + String end = expected.substring(dash + 1).trim(); + + if (hasText(start) && actual.compareTo(start) < 0) { + return false; + } + if (hasText(end) && actual.compareTo(end) > 0) { + return false; + } + return true; + } + + private boolean matchesSequence(Attributes attrs, Attributes keys, int tag, FindRequest request) { + Sequence expected = keys.getSequence(tag); + if (expected == null || expected.isEmpty()) { + return true; + } + Sequence actual = attrs.getSequence(tag); + if (actual == null || actual.isEmpty()) { + return false; + } + + for (Attributes expectedItem : expected) { + boolean matchedOne = false; + for (Attributes actualItem : actual) { + if (matchesDicomKeys(actualItem, expectedItem, request)) { + matchedOne = true; + break; + } + } + if (!matchedOne) { + return false; + } + } + return true; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private Attributes filterByLevel(Attributes src, QueryRetrieveLevel level) { + Attributes out = new Attributes(); + copyIfPresent(src, out, Tag.QueryRetrieveLevel, VR.CS, level.name()); + copyIfPresent(src, out, Tag.StudyInstanceUID, VR.UI, null); + copyIfPresent(src, out, Tag.SeriesInstanceUID, VR.UI, null); + copyIfPresent(src, out, Tag.SOPInstanceUID, VR.UI, null); + copyIfPresent(src, out, Tag.SOPClassUID, VR.UI, null); + copyIfPresent(src, out, Tag.PatientID, VR.LO, null); + copyIfPresent(src, out, Tag.PatientName, VR.PN, null); + copyIfPresent(src, out, Tag.PatientSex, VR.CS, null); + copyIfPresent(src, out, Tag.PatientBirthDate, VR.DA, null); + copyIfPresent(src, out, Tag.StudyDate, VR.DA, null); + copyIfPresent(src, out, Tag.StudyTime, VR.TM, null); + copyIfPresent(src, out, Tag.AccessionNumber, VR.SH, null); + copyIfPresent(src, out, Tag.StudyID, VR.SH, null); + copyIfPresent(src, out, Tag.StudyDescription, VR.LO, null); + copyIfPresent(src, out, Tag.Modality, VR.CS, null); + copyIfPresent(src, out, Tag.ModalitiesInStudy, VR.CS, null); + copyIfPresent(src, out, Tag.InstitutionName, VR.LO, null); + copyIfPresent(src, out, Tag.SeriesDescription, VR.LO, null); + copyIfPresent(src, out, Tag.SeriesDate, VR.DA, null); + copyIfPresent(src, out, Tag.SeriesTime, VR.TM, null); + copyIfPresent(src, out, Tag.SeriesNumber, VR.IS, null); + copyIfPresent(src, out, Tag.OperatorsName, VR.PN, null); + copyIfPresent(src, out, Tag.RequestingPhysician, VR.PN, null); + copyIfPresent(src, out, Tag.ProtocolName, VR.LO, null); + copyIfPresent(src, out, Tag.BodyPartThickness, VR.DS, null); + + if (level == QueryRetrieveLevel.STUDY) { + Attributes studyOnly = new Attributes(); + studyOnly.addAll(out); + studyOnly.setNull(Tag.SeriesInstanceUID, VR.UI); + studyOnly.setNull(Tag.SOPInstanceUID, VR.UI); + return studyOnly; + } + if (level == QueryRetrieveLevel.SERIES) { + Attributes seriesOnly = new Attributes(); + seriesOnly.addAll(out); + seriesOnly.setNull(Tag.SOPInstanceUID, VR.UI); + return seriesOnly; + } + return out; + } + + private void copyIfPresent(Attributes src, Attributes dst, int tag, VR vr, String defaultValue) { + String value = src.getString(tag, null); + if (value != null && !value.isBlank()) { + dst.setString(tag, vr, value); + return; + } + if (defaultValue != null) { + dst.setString(tag, vr, defaultValue); + } + } + + public static String extractFreeText(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + StringBuilder out = new StringBuilder(); + for (String token : raw.trim().split("\\s+")) { + if (!KEYWORD_PATTERN.matcher(token).matches()) { + if (!out.isEmpty()) { + out.append(' '); + } + out.append(token); + } + } + return out.isEmpty() ? null : out.toString(); + } + + public static java.util.Map extractKeywordFilters(String raw) { + if (raw == null || raw.isBlank()) { + return java.util.Map.of(); + } + java.util.Map out = new java.util.LinkedHashMap<>(); + for (String token : raw.trim().split("\\s+")) { + if (KEYWORD_PATTERN.matcher(token).matches()) { + int index = token.indexOf(':'); + if (index > 0 && index < token.length() - 1) { + out.put(token.substring(0, index), token.substring(index + 1)); + } + } + } + return out; + } +} diff --git a/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java b/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java new file mode 100644 index 0000000..211a4d2 --- /dev/null +++ b/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java @@ -0,0 +1,366 @@ +package org.dicoogle.query.file; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.Sequence; +import org.dcm4che3.data.Tag; +import org.dcm4che3.data.UID; +import org.dcm4che3.data.VR; +import org.dcm4che3.io.DicomOutputStream; +import org.dicoogle.sdk.query.DimseFindServicePlugin; +import org.dicoogle.storage.filerw.FileReadWriteStoragePlugin; +import org.junit.jupiter.api.Test; + +class FileReadWriteDimseFindPluginTest { + + @Test + void supportsTextAndKeywordFilters() throws Exception { + Path root = Files.createTempDirectory("query-file-test"); + FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file"); + storage.store( + new java.io.ByteArrayInputStream(createDicom("P1", "FELIX", "MR", "A1", "20240101")), + "application/dicom"); + + FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage); + + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + keys.setString(Tag.PatientName, VR.PN, "felix"); + keys.setString(Tag.Modality, VR.CS, "MR"); + + var request = + new DimseFindServicePlugin.FindRequest( + DimseFindServicePlugin.InformationModel.STUDY_ROOT, + DimseFindServicePlugin.QueryRetrieveLevel.STUDY, + "CALLING", + "CALLED", + 1, + keys, + "felix", + java.util.Map.of("modality", "MR"), + false, + false, + () -> false); + + assertFalse(plugin.find(request).isEmpty()); + } + + @Test + void parserKeepsTextAndKeywordsSeparated() { + String raw = "brain patientid:123 modality:MR"; + assertTrue(FileReadWriteDimseFindPlugin.extractKeywordFilters(raw).containsKey("patientid")); + assertTrue(FileReadWriteDimseFindPlugin.extractFreeText(raw).contains("brain")); + } + + @Test + void supportsArbitraryDicomKeywordFilter() throws Exception { + Path root = Files.createTempDirectory("query-file-test-any-keyword"); + FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file"); + storage.store( + new java.io.ByteArrayInputStream(createDicom("P1", "FELIX", "MR", "A1", "20240101")), + "application/dicom"); + + FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage); + + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + + var request = + new DimseFindServicePlugin.FindRequest( + DimseFindServicePlugin.InformationModel.STUDY_ROOT, + DimseFindServicePlugin.QueryRetrieveLevel.STUDY, + "CALLING", + "CALLED", + 10, + keys, + null, + java.util.Map.of("StudyDate", "20240101"), + false, + false, + () -> false); + + assertEquals(1, plugin.find(request).size()); + } + + @Test + void supportsWildcardAndMultiValueMatching() throws Exception { + Path root = Files.createTempDirectory("query-file-test-wildcards"); + FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file"); + storage.store( + new java.io.ByteArrayInputStream( + createDicom("P1", "FELIX^ALMEIDA", "MR", "A100", "20240115")), + "application/dicom"); + storage.store( + new java.io.ByteArrayInputStream(createDicom("P2", "JOAO", "CT", "A200", "20250310")), + "application/dicom"); + + FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage); + + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + keys.setString(Tag.PatientName, VR.PN, "F*"); + keys.setString(Tag.Modality, VR.CS, "MR\\US"); + + var request = + new DimseFindServicePlugin.FindRequest( + DimseFindServicePlugin.InformationModel.STUDY_ROOT, + DimseFindServicePlugin.QueryRetrieveLevel.STUDY, + "CALLING", + "CALLED", + 2, + keys, + null, + java.util.Map.of(), + false, + false, + () -> false); + + assertEquals(1, plugin.find(request).size()); + } + + @Test + void supportsDateRangeMatching() throws Exception { + Path root = Files.createTempDirectory("query-file-test-dates"); + FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file"); + storage.store( + new java.io.ByteArrayInputStream(createDicom("P1", "ANA", "MR", "A1", "20240115")), + "application/dicom"); + storage.store( + new java.io.ByteArrayInputStream(createDicom("P2", "BEA", "MR", "A2", "20250310")), + "application/dicom"); + + FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage); + + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + keys.setString(Tag.StudyDate, VR.DA, "20240101-20241231"); + + var request = + new DimseFindServicePlugin.FindRequest( + DimseFindServicePlugin.InformationModel.STUDY_ROOT, + DimseFindServicePlugin.QueryRetrieveLevel.STUDY, + "CALLING", + "CALLED", + 3, + keys, + null, + java.util.Map.of(), + false, + false, + () -> false); + + assertEquals(1, plugin.find(request).size()); + } + + @Test + void supportsTimeAndDateTimeRangeMatchingWithNegotiation() throws Exception { + Path root = Files.createTempDirectory("query-file-test-datetime"); + FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file"); + storage.store( + new java.io.ByteArrayInputStream( + createDicom("P1", "ANA", "MR", "A1", "20240115", "103000", "20240115103000")), + "application/dicom"); + storage.store( + new java.io.ByteArrayInputStream( + createDicom("P2", "BEA", "MR", "A2", "20240115", "153000", "20240115153000")), + "application/dicom"); + + FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage); + + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + keys.setString(Tag.StudyTime, VR.TM, "100000-120000"); + keys.setString(Tag.AcquisitionDateTime, VR.DT, "20240115100000-20240115120000"); + + var requestNoDateTimeNegotiation = + new DimseFindServicePlugin.FindRequest( + DimseFindServicePlugin.InformationModel.STUDY_ROOT, + DimseFindServicePlugin.QueryRetrieveLevel.STUDY, + "CALLING", + "CALLED", + 4, + keys, + null, + java.util.Map.of(), + false, + false, + () -> false); + assertEquals(0, plugin.find(requestNoDateTimeNegotiation).size()); + + var requestWithDateTimeNegotiation = + new DimseFindServicePlugin.FindRequest( + DimseFindServicePlugin.InformationModel.STUDY_ROOT, + DimseFindServicePlugin.QueryRetrieveLevel.STUDY, + "CALLING", + "CALLED", + 5, + keys, + null, + java.util.Map.of(), + false, + true, + () -> false); + assertEquals(1, plugin.find(requestWithDateTimeNegotiation).size()); + } + + @Test + void supportsFuzzyPersonNameWhenNegotiated() throws Exception { + Path root = Files.createTempDirectory("query-file-test-fuzzy"); + FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file"); + storage.store( + new java.io.ByteArrayInputStream( + createDicom("P1", "FELIX^ALMEIDA", "MR", "A1", "20240101", "101010", "20240101101010")), + "application/dicom"); + + FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage); + + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + keys.setString(Tag.PatientName, VR.PN, "felixal"); + + var requestWithoutFuzzy = + new DimseFindServicePlugin.FindRequest( + DimseFindServicePlugin.InformationModel.STUDY_ROOT, + DimseFindServicePlugin.QueryRetrieveLevel.STUDY, + "CALLING", + "CALLED", + 6, + keys, + null, + java.util.Map.of(), + false, + false, + () -> false); + assertEquals(0, plugin.find(requestWithoutFuzzy).size()); + + var requestWithFuzzy = + new DimseFindServicePlugin.FindRequest( + DimseFindServicePlugin.InformationModel.STUDY_ROOT, + DimseFindServicePlugin.QueryRetrieveLevel.STUDY, + "CALLING", + "CALLED", + 7, + keys, + null, + java.util.Map.of(), + true, + false, + () -> false); + assertEquals(1, plugin.find(requestWithFuzzy).size()); + } + + @Test + void supportsNestedSequenceMatching() throws Exception { + Path root = Files.createTempDirectory("query-file-test-nested"); + FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file"); + storage.store( + new java.io.ByteArrayInputStream( + createDicom("P1", "NESTED", "MR", "A1", "20240101", "101010", "20240101101010")), + "application/dicom"); + + FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage); + + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + Sequence seq = keys.newSequence(Tag.RequestAttributesSequence, 1); + Attributes item = new Attributes(); + item.setString(Tag.AccessionNumber, VR.SH, "A1"); + seq.add(item); + + var request = + new DimseFindServicePlugin.FindRequest( + DimseFindServicePlugin.InformationModel.STUDY_ROOT, + DimseFindServicePlugin.QueryRetrieveLevel.STUDY, + "CALLING", + "CALLED", + 8, + keys, + null, + java.util.Map.of(), + false, + false, + () -> false); + assertEquals(1, plugin.find(request).size()); + } + + @Test + void stopsEarlyWhenCancelRequested() throws Exception { + Path root = Files.createTempDirectory("query-file-test-cancel"); + FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file"); + storage.store( + new java.io.ByteArrayInputStream( + createDicom("P1", "CANCEL", "MR", "A1", "20240101", "101010", "20240101101010")), + "application/dicom"); + + FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage); + Attributes keys = new Attributes(); + keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY"); + + var request = + new DimseFindServicePlugin.FindRequest( + DimseFindServicePlugin.InformationModel.STUDY_ROOT, + DimseFindServicePlugin.QueryRetrieveLevel.STUDY, + "CALLING", + "CALLED", + 9, + keys, + null, + java.util.Map.of(), + false, + false, + () -> true); + + assertEquals(0, plugin.find(request).size()); + } + + private byte[] createDicom( + String patientId, String patientName, String modality, String accession, String studyDate) + throws Exception { + return createDicom( + patientId, patientName, modality, accession, studyDate, "101010", "20240101101010"); + } + + private byte[] createDicom( + String patientId, + String patientName, + String modality, + String accession, + String studyDate, + String studyTime, + String acquisitionDateTime) + throws Exception { + Attributes fmi = new Attributes(); + fmi.setString(Tag.TransferSyntaxUID, VR.UI, UID.ExplicitVRLittleEndian); + fmi.setString(Tag.MediaStorageSOPClassUID, VR.UI, UID.SecondaryCaptureImageStorage); + fmi.setString(Tag.MediaStorageSOPInstanceUID, VR.UI, "1.2.3.4.5"); + fmi.setString(Tag.ImplementationClassUID, VR.UI, "1.2.826.0.1.3680043.2.1125.99"); + + Attributes attrs = new Attributes(); + attrs.setString(Tag.SOPClassUID, VR.UI, UID.SecondaryCaptureImageStorage); + attrs.setString(Tag.SOPInstanceUID, VR.UI, "1.2.3.4.5"); + attrs.setString(Tag.StudyInstanceUID, VR.UI, "1.2.3"); + attrs.setString(Tag.SeriesInstanceUID, VR.UI, "1.2.3.1"); + attrs.setString(Tag.PatientID, VR.LO, patientId); + attrs.setString(Tag.PatientName, VR.PN, patientName); + attrs.setString(Tag.Modality, VR.CS, modality); + attrs.setString(Tag.AccessionNumber, VR.SH, accession); + attrs.setString(Tag.StudyDate, VR.DA, studyDate); + attrs.setString(Tag.StudyTime, VR.TM, studyTime); + attrs.setString(Tag.AcquisitionDateTime, VR.DT, acquisitionDateTime); + Sequence requestSequence = attrs.newSequence(Tag.RequestAttributesSequence, 1); + Attributes requestItem = new Attributes(); + requestItem.setString(Tag.AccessionNumber, VR.SH, accession); + requestSequence.add(requestItem); + + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + try (DicomOutputStream dos = new DicomOutputStream(out, UID.ExplicitVRLittleEndian)) { + dos.writeDataset(fmi, attrs); + } + return out.toByteArray(); + } +} diff --git a/backend/dicoogle-next-storage-file-ro/pom.xml b/backend/plugins/dicoogle-next-storage-file-ro/pom.xml similarity index 94% rename from backend/dicoogle-next-storage-file-ro/pom.xml rename to backend/plugins/dicoogle-next-storage-file-ro/pom.xml index 27da56c..7c21347 100644 --- a/backend/dicoogle-next-storage-file-ro/pom.xml +++ b/backend/plugins/dicoogle-next-storage-file-ro/pom.xml @@ -7,7 +7,7 @@ org.dicoogle dicoogle-next-backend-parent 0.0.1-SNAPSHOT - ../pom.xml + ../../pom.xml dicoogle-next-storage-file-ro diff --git a/backend/dicoogle-next-storage-file-ro/src/main/java/org/dicoogle/storage/filero/FileReadOnlyStoragePlugin.java b/backend/plugins/dicoogle-next-storage-file-ro/src/main/java/org/dicoogle/storage/filero/FileReadOnlyStoragePlugin.java similarity index 100% rename from backend/dicoogle-next-storage-file-ro/src/main/java/org/dicoogle/storage/filero/FileReadOnlyStoragePlugin.java rename to backend/plugins/dicoogle-next-storage-file-ro/src/main/java/org/dicoogle/storage/filero/FileReadOnlyStoragePlugin.java diff --git a/backend/dicoogle-next-storage-file-rw/pom.xml b/backend/plugins/dicoogle-next-storage-file-rw/pom.xml similarity index 96% rename from backend/dicoogle-next-storage-file-rw/pom.xml rename to backend/plugins/dicoogle-next-storage-file-rw/pom.xml index bad12bf..0f94bd7 100644 --- a/backend/dicoogle-next-storage-file-rw/pom.xml +++ b/backend/plugins/dicoogle-next-storage-file-rw/pom.xml @@ -7,7 +7,7 @@ org.dicoogle dicoogle-next-backend-parent 0.0.1-SNAPSHOT - ../pom.xml + ../../pom.xml dicoogle-next-storage-file-rw diff --git a/backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageConfig.java b/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageConfig.java similarity index 100% rename from backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageConfig.java rename to backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageConfig.java diff --git a/backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java b/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java similarity index 96% rename from backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java rename to backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java index 58b50c7..cd33186 100644 --- a/backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java +++ b/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java @@ -139,6 +139,16 @@ public List listStudyInstances(String studyInstanceUid) throws IOException return results; } + public List listAllInstances() throws IOException { + List results = new ArrayList<>(); + if (!Files.exists(rootDirectory)) { + return results; + } + + collectDicomFiles(rootDirectory, results); + return results; + } + @Override public List listSeriesInstances(String studyInstanceUid, String seriesInstanceUid) throws IOException { diff --git a/backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageProperties.java b/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageProperties.java similarity index 100% rename from backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageProperties.java rename to backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageProperties.java diff --git a/backend/dicoogle-next-storage-file-rw/src/test/java/org/dicoogle/storage/filerw/FileReadWriteStoragePluginTest.java b/backend/plugins/dicoogle-next-storage-file-rw/src/test/java/org/dicoogle/storage/filerw/FileReadWriteStoragePluginTest.java similarity index 100% rename from backend/dicoogle-next-storage-file-rw/src/test/java/org/dicoogle/storage/filerw/FileReadWriteStoragePluginTest.java rename to backend/plugins/dicoogle-next-storage-file-rw/src/test/java/org/dicoogle/storage/filerw/FileReadWriteStoragePluginTest.java diff --git a/backend/pom.xml b/backend/pom.xml index 095ed3b..5cf8b13 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -24,8 +24,9 @@ dicoogle-next-core dicoogle-next-protocol-dimse dicoogle-next-protocol-dicomweb - dicoogle-next-storage-file-ro - dicoogle-next-storage-file-rw + plugins/dicoogle-next-storage-file-ro + plugins/dicoogle-next-storage-file-rw + plugins/dicoogle-next-query-file @@ -99,10 +100,10 @@ - spotless-check + spotless-apply validate - check + apply