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 +