diff --git a/codex-process-data-transfer/pom.xml b/codex-process-data-transfer/pom.xml
index 9c0636c6..99634cd3 100644
--- a/codex-process-data-transfer/pom.xml
+++ b/codex-process-data-transfer/pom.xml
@@ -26,6 +26,11 @@
hapi-fhir-client
provided
+
+ org.springframework
+ spring-web
+ provided
+
de.hs-heilbronn.mi
@@ -37,6 +42,11 @@
dsf-fhir-validation
test
+
+ org.mockito
+ mockito-core
+ test
+
diff --git a/codex-process-data-transfer/src/main/java/de/netzwerk_universitaetsmedizin/codex/processes/data_transfer/client/fhir/AbstractFhirClient.java b/codex-process-data-transfer/src/main/java/de/netzwerk_universitaetsmedizin/codex/processes/data_transfer/client/fhir/AbstractFhirClient.java
index ea9cfc5f..7f11e8cc 100644
--- a/codex-process-data-transfer/src/main/java/de/netzwerk_universitaetsmedizin/codex/processes/data_transfer/client/fhir/AbstractFhirClient.java
+++ b/codex-process-data-transfer/src/main/java/de/netzwerk_universitaetsmedizin/codex/processes/data_transfer/client/fhir/AbstractFhirClient.java
@@ -7,16 +7,20 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.EnumSet;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
+import java.util.stream.IntStream;
import java.util.stream.Stream;
import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
@@ -31,6 +35,9 @@
import org.hl7.fhir.r4.model.Patient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.web.util.UriComponents;
+import org.springframework.web.util.UriComponents.UriTemplateVariables;
+import org.springframework.web.util.UriComponentsBuilder;
import ca.uhn.fhir.parser.DataFormatException;
import ca.uhn.fhir.rest.api.Constants;
@@ -91,6 +98,63 @@ public abstract class AbstractFhirClient implements GeccoFhirClient
private static final SimpleDateFormat DAY_FORMAT = new SimpleDateFormat("yyyy-MM-dd");
private static final SimpleDateFormat TIME_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
+ private static final class QueryParameters implements UriTemplateVariables
+ {
+ final List parameters = new ArrayList<>();
+
+ @Override
+ public Object getValue(String name)
+ {
+ return parameters.stream().filter(p -> p.getValue(name) != null).findFirst().map(p -> p.getValue(name))
+ .orElseThrow(() -> new IllegalArgumentException("No value for '" + name + "'"));
+ }
+
+ boolean add(QuerParameter param)
+ {
+ if (param != null)
+ return parameters.add(param);
+ else
+ return false;
+ }
+
+ void replace(UriComponentsBuilder urlBuilder)
+ {
+ parameters.forEach(p -> p.replace(urlBuilder));
+ }
+ }
+
+ private static final class QuerParameter
+ {
+ final String name;
+ final Map valuesByTemplateParameter = new HashMap<>();
+
+ QuerParameter(String templateParameter, String name, String... values)
+ {
+ Objects.requireNonNull(templateParameter, "templateParameter");
+ Objects.requireNonNull(name, "name");
+ Objects.requireNonNull(values, "values");
+
+ this.name = name;
+ IntStream.range(0, values.length).forEach(i ->
+ {
+ if (values[i] != null)
+ valuesByTemplateParameter.put(templateParameter + "_" + i, values[i]);
+ });
+ }
+
+ void replace(UriComponentsBuilder builder)
+ {
+ builder.replaceQueryParam(name);
+ valuesByTemplateParameter.keySet()
+ .forEach(templateParam -> builder.queryParam(name, "{" + templateParam + "}"));
+ }
+
+ String getValue(String templateParameter)
+ {
+ return valuesByTemplateParameter.get(templateParameter);
+ }
+ }
+
protected final GeccoClient geccoClient;
/**
@@ -343,71 +407,78 @@ private Function modifySearchUrl(Str
}
String resource = queryPatternMatcher.group("resource");
- String query = queryPatternMatcher.group("query");
+ UriComponentsBuilder urlBuilder = UriComponentsBuilder.fromUriString(entry.getRequest().getUrl());
+ QueryParameters queryParameters = new QueryParameters();
if (RESOURCES_WITH_PATIENT_REF.contains(resource))
{
if (patientId != null)
- query += createPatIdSearchUrlPart(patientId);
+ queryParameters.add(createPatIdSearchUrlPart(patientId));
else
- query += createPatPrefixPseudonymSearchUrlPart(pseudonym);
+ queryParameters.add(createPatPrefixPseudonymSearchUrlPart(pseudonym));
if (includePatient)
- query += createIncludeSearchUrlPart(resource);
+ queryParameters.add(createIncludeSearchUrlPart(resource));
}
else if ("Patient".equals(resource))
{
// filtering search for patient if patient id known
if (patientId != null)
return null;
-
- query += createPseudonymSearchUrlPart(pseudonym);
+ else
+ queryParameters.add(createPseudonymSearchUrlPart(pseudonym));
}
else
{
logger.warn(
"Search-Bundle contains entry with invalid serach query {}, target resource {} not supported",
- resource + query, resource);
+ entry.getRequest().getUrl(), resource);
throw new RuntimeException("Search-Bundle contains entry with invalid serach query, target resource "
+ resource + " not supported");
}
- query += createExportFromSearchUrlPart(exportFrom);
- query += createExportToSearchUrlPart(exportTo);
+ queryParameters.add(new QuerParameter("from_to", "_lastUpdated", createExportFromSearchUrlPart(exportFrom),
+ createExportToSearchUrlPart(exportTo)));
- entry.getRequest().setUrl(resource + query);
+ queryParameters.replace(urlBuilder);
+ UriComponents url = urlBuilder.encode().build().expand(queryParameters);
+ entry.getRequest().setUrl(url.toString());
return entry;
};
}
- private String createPseudonymSearchUrlPart(String pseudonym)
+ private QuerParameter createPseudonymSearchUrlPart(String pseudonym)
{
- if (pseudonym != null && !pseudonym.isBlank())
- return "&identifier=" + NAMING_SYSTEM_NUM_CODEX_DIC_PSEUDONYM + "|" + pseudonym;
+ if (pseudonym == null || pseudonym.isBlank())
+ return null;
else
- return "";
+ return new QuerParameter("pseudonym", "identifier",
+ NAMING_SYSTEM_NUM_CODEX_DIC_PSEUDONYM + "|" + pseudonym);
}
- private String createPatIdSearchUrlPart(String patientId)
+ private QuerParameter createPatIdSearchUrlPart(String patientId)
{
- if (patientId != null && !patientId.isBlank())
- return "&patient=" + patientId;
+ if (patientId == null || patientId.isBlank())
+ return null;
else
- return "";
+ return new QuerParameter("patientId", "patient", patientId);
}
- private String createPatPrefixPseudonymSearchUrlPart(String pseudonym)
+ private QuerParameter createPatPrefixPseudonymSearchUrlPart(String pseudonym)
{
- if (pseudonym != null && !pseudonym.isBlank())
- return "&patient:identifier=" + NAMING_SYSTEM_NUM_CODEX_DIC_PSEUDONYM + "|" + pseudonym;
+ if (pseudonym == null || pseudonym.isBlank())
+ return null;
else
- return "";
+ return new QuerParameter("pseudonym", "patient:identifier",
+ NAMING_SYSTEM_NUM_CODEX_DIC_PSEUDONYM + "|" + pseudonym);
}
private String createExportFromSearchUrlPart(DateWithPrecision exportFrom)
{
- if (exportFrom != null)
+ if (exportFrom == null)
+ return null;
+ else
{
String dateTime = null;
switch (exportFrom.getPrecision())
@@ -432,26 +503,24 @@ private String createExportFromSearchUrlPart(DateWithPrecision exportFrom)
"TemporalPrecisionEnum value " + exportFrom.getPrecision() + " not supported");
}
- return "&_lastUpdated=ge" + dateTime;
+ return "ge" + dateTime;
}
- else
- return "";
}
private String createExportToSearchUrlPart(Date exportTo)
{
- if (exportTo != null)
- return "&_lastUpdated=lt" + TIME_FORMAT.format(exportTo);
+ if (exportTo == null)
+ return null;
else
- return "";
+ return "lt" + TIME_FORMAT.format(exportTo);
}
- private String createIncludeSearchUrlPart(String resource)
+ private QuerParameter createIncludeSearchUrlPart(String resource)
{
- if (resource != null)
- return "&_include=" + resource + ":patient";
+ if (resource == null)
+ return null;
else
- return "";
+ return new QuerParameter("include_resource", "_include", resource + ":patient");
}
protected Stream getDomainResources(Bundle bundle)
diff --git a/codex-process-data-transfer/src/test/java/de/netzwerk_universitaetsmedizin/codex/processes/data_transfer/client/fhir/AbstractFhirClientTest.java b/codex-process-data-transfer/src/test/java/de/netzwerk_universitaetsmedizin/codex/processes/data_transfer/client/fhir/AbstractFhirClientTest.java
new file mode 100644
index 00000000..6162af0d
--- /dev/null
+++ b/codex-process-data-transfer/src/test/java/de/netzwerk_universitaetsmedizin/codex/processes/data_transfer/client/fhir/AbstractFhirClientTest.java
@@ -0,0 +1,168 @@
+package de.netzwerk_universitaetsmedizin.codex.processes.data_transfer.client.fhir;
+
+import static de.netzwerk_universitaetsmedizin.codex.processes.data_transfer.ConstantsDataTransfer.NAMING_SYSTEM_NUM_CODEX_DIC_PSEUDONYM;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.when;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Paths;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+import org.hl7.fhir.r4.model.Bundle;
+import org.junit.Test;
+import org.mockito.Mockito;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ca.uhn.fhir.context.FhirContext;
+import ca.uhn.fhir.model.api.TemporalPrecisionEnum;
+import de.netzwerk_universitaetsmedizin.codex.processes.data_transfer.client.GeccoClient;
+import de.netzwerk_universitaetsmedizin.codex.processes.data_transfer.domain.DateWithPrecision;
+
+public class AbstractFhirClientTest
+{
+ private static final Logger logger = LoggerFactory.getLogger(AbstractFhirClientTest.class);
+
+ @Test
+ public void testSetSearchBundleWithExportTo() throws Exception
+ {
+ FhirContext fhirContext = FhirContext.forR4();
+ GeccoClient geccoClient = Mockito.mock(GeccoClient.class);
+ when(geccoClient.getSearchBundleOverride())
+ .thenReturn(Paths.get("src/main/resources/fhir/Bundle/SearchBundle.xml"));
+ when(geccoClient.getFhirContext()).thenReturn(fhirContext);
+ AbstractFhirClient client = Mockito.mock(AbstractFhirClient.class,
+ Mockito.withSettings().useConstructor(geccoClient).defaultAnswer(CALLS_REAL_METHODS));
+
+ Date exportTo = new Date();
+
+ Bundle bundle = client.getSearchBundle(null, exportTo);
+ assertNotNull(bundle);
+ assertTrue(bundle.hasEntry());
+ assertNotNull(bundle.getEntry());
+ assertEquals(64, bundle.getEntry().size());
+
+ logger.debug("Search Bundle after replacement: {}", fhirContext.newJsonParser().encodeResourceToString(bundle));
+
+ SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
+ String exportToString = timeFormat.format(exportTo).replaceAll("\\+", "%2B").replaceAll(":", "%3A");
+
+ String expectedUrl = "Condition?_profile=https://www.netzwerk-universitaetsmedizin.de/fhir/StructureDefinition/cardiovascular-diseases&_include=Condition%3Apatient"
+ + "&_lastUpdated=lt" + exportToString;
+
+ long entriesWithExpectedUrl = bundle.getEntry().stream().filter(e -> e.hasRequest())
+ .filter(e -> e.getRequest().hasUrl()).filter(e -> e.getRequest().getUrl().equals(expectedUrl)).count();
+ assertEquals(1, entriesWithExpectedUrl);
+ }
+
+ @Test
+ public void testSetSearchBundleWithExportFromAndExportTo() throws Exception
+ {
+ FhirContext fhirContext = FhirContext.forR4();
+ GeccoClient geccoClient = Mockito.mock(GeccoClient.class);
+ when(geccoClient.getSearchBundleOverride())
+ .thenReturn(Paths.get("src/main/resources/fhir/Bundle/SearchBundle.xml"));
+ when(geccoClient.getFhirContext()).thenReturn(fhirContext);
+ AbstractFhirClient client = Mockito.mock(AbstractFhirClient.class,
+ Mockito.withSettings().useConstructor(geccoClient).defaultAnswer(CALLS_REAL_METHODS));
+
+ DateWithPrecision exportFrom = new DateWithPrecision(new Date(), TemporalPrecisionEnum.MILLI);
+ Date exportTo = new Date();
+
+ Bundle bundle = client.getSearchBundle(exportFrom, exportTo);
+ assertNotNull(bundle);
+ assertTrue(bundle.hasEntry());
+ assertNotNull(bundle.getEntry());
+ assertEquals(64, bundle.getEntry().size());
+
+ logger.debug("Search Bundle after replacement: {}", fhirContext.newJsonParser().encodeResourceToString(bundle));
+
+ SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
+ String exportFromString = timeFormat.format(exportFrom).replaceAll("\\+", "%2B").replaceAll(":", "%3A");
+ String exportToString = timeFormat.format(exportTo).replaceAll("\\+", "%2B").replaceAll(":", "%3A");
+
+ String expectedUrl = "Condition?_profile=https://www.netzwerk-universitaetsmedizin.de/fhir/StructureDefinition/cardiovascular-diseases&_include=Condition%3Apatient"
+ + "&_lastUpdated=lt" + exportToString + "&_lastUpdated=ge" + exportFromString;
+
+ long entriesWithExpectedUrl = bundle.getEntry().stream().filter(e -> e.hasRequest())
+ .filter(e -> e.getRequest().hasUrl()).filter(e -> e.getRequest().getUrl().equals(expectedUrl)).count();
+ assertEquals(1, entriesWithExpectedUrl);
+ }
+
+ @Test
+ public void testSetSearchBundleWithPatientIdAndExportFromAndExportTo() throws Exception
+ {
+ FhirContext fhirContext = FhirContext.forR4();
+ GeccoClient geccoClient = Mockito.mock(GeccoClient.class);
+ when(geccoClient.getSearchBundleOverride())
+ .thenReturn(Paths.get("src/main/resources/fhir/Bundle/SearchBundle.xml"));
+ when(geccoClient.getFhirContext()).thenReturn(fhirContext);
+ AbstractFhirClient client = Mockito.mock(AbstractFhirClient.class,
+ Mockito.withSettings().useConstructor(geccoClient).defaultAnswer(CALLS_REAL_METHODS));
+
+ String patientId = "some-patient-id";
+ DateWithPrecision exportFrom = new DateWithPrecision(new Date(), TemporalPrecisionEnum.MILLI);
+ Date exportTo = new Date();
+
+ Bundle bundle = client.getSearchBundleWithPatientId(patientId, exportFrom, exportTo);
+ assertNotNull(bundle);
+ assertTrue(bundle.hasEntry());
+ assertNotNull(bundle.getEntry());
+ assertEquals(63, bundle.getEntry().size());
+
+ logger.debug("Search Bundle after replacement: {}", fhirContext.newJsonParser().encodeResourceToString(bundle));
+
+ SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
+ String exportFromString = timeFormat.format(exportFrom).replaceAll("\\+", "%2B").replaceAll(":", "%3A");
+ String exportToString = timeFormat.format(exportTo).replaceAll("\\+", "%2B").replaceAll(":", "%3A");
+
+ String expectedUrl = "Condition?_profile=https://www.netzwerk-universitaetsmedizin.de/fhir/StructureDefinition/cardiovascular-diseases"
+ + "&patient=" + patientId + "&_lastUpdated=lt" + exportToString + "&_lastUpdated=ge" + exportFromString;
+
+ long entriesWithExpectedUrl = bundle.getEntry().stream().filter(e -> e.hasRequest())
+ .filter(e -> e.getRequest().hasUrl()).filter(e -> e.getRequest().getUrl().equals(expectedUrl)).count();
+ assertEquals(1, entriesWithExpectedUrl);
+ }
+
+ @Test
+ public void testSetSearchBundleWithPseudonymIdAndExportFromAndExportTo() throws Exception
+ {
+ FhirContext fhirContext = FhirContext.forR4();
+ GeccoClient geccoClient = Mockito.mock(GeccoClient.class);
+ when(geccoClient.getSearchBundleOverride())
+ .thenReturn(Paths.get("src/main/resources/fhir/Bundle/SearchBundle.xml"));
+ when(geccoClient.getFhirContext()).thenReturn(fhirContext);
+ AbstractFhirClient client = Mockito.mock(AbstractFhirClient.class,
+ Mockito.withSettings().useConstructor(geccoClient).defaultAnswer(CALLS_REAL_METHODS));
+
+ String pseudonym = "some-pseudonym";
+ DateWithPrecision exportFrom = new DateWithPrecision(new Date(), TemporalPrecisionEnum.MILLI);
+ Date exportTo = new Date();
+
+ Bundle bundle = client.getSearchBundleWithPseudonym(pseudonym, exportFrom, exportTo);
+ assertNotNull(bundle);
+ assertTrue(bundle.hasEntry());
+ assertNotNull(bundle.getEntry());
+ assertEquals(64, bundle.getEntry().size());
+
+ logger.debug("Search Bundle after replacement: {}", fhirContext.newJsonParser().encodeResourceToString(bundle));
+
+ String namingSystemString = URLEncoder.encode(NAMING_SYSTEM_NUM_CODEX_DIC_PSEUDONYM, StandardCharsets.UTF_8);
+ SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
+ String exportFromString = timeFormat.format(exportFrom).replaceAll("\\+", "%2B").replaceAll(":", "%3A");
+ String exportToString = timeFormat.format(exportTo).replaceAll("\\+", "%2B").replaceAll(":", "%3A");
+
+ String expectedUrl = "Condition?_profile=https://www.netzwerk-universitaetsmedizin.de/fhir/StructureDefinition/cardiovascular-diseases"
+ + "&patient:identifier=" + namingSystemString + "%7C" + pseudonym + "&_lastUpdated=lt" + exportToString
+ + "&_lastUpdated=ge" + exportFromString;
+
+ long entriesWithExpectedUrl = bundle.getEntry().stream().filter(e -> e.hasRequest())
+ .filter(e -> e.getRequest().hasUrl()).filter(e -> e.getRequest().getUrl().equals(expectedUrl)).count();
+ assertEquals(1, entriesWithExpectedUrl);
+ }
+}
diff --git a/pom.xml b/pom.xml
index 4cb8fa15..c32fbac6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -115,6 +115,11 @@
jackson-annotations
2.12.0
+
+ org.springframework
+ spring-web
+ 5.3.19
+
@@ -126,9 +131,13 @@
org.highmed.dsf
dsf-bpe-process-base
${dsf.version}
- test
test-jar
+
+ org.mockito
+ mockito-core
+ 4.5.1
+