From 89f074255461dc0855027a015151bd6087b3a3bf Mon Sep 17 00:00:00 2001 From: Michael Folz Date: Tue, 24 Jan 2023 15:53:45 +0100 Subject: [PATCH] #45 - Enable CQL in direct broker - append "flare" to method name "DirectSpringConfig.directWebClientFlare" to clarify that it is only associated with the flare client - replace custom "internal/json" media type from mock server response in DirectBrokerClientFlareIT and replace it with standard application/json header - replace HashMap with EnumMap in DirectBrokerClient.DirectQuery - rename SITE_1_NAME to SITE_NAME_LOCAL to be consistent with SITE_ID_LOCAL (and make both constants final) - make the fhirClient a local var in the setup method in DirectBrokerClientCqlIT - don't return anything from DirectBrokerClientCqlIT.createDummyPatient - catch and wrap BaseServerResponseException when trying to read measure report from fhir server - catch BaseServerResponseException instead of just FhirClientConnectionException when creating library and measure - throw SiteNotFoundException instead of returning an empty string when the site name for an invalid id is requested - add constructor for QueryDefinitionNotFoundException without QueryMediaType parameter to be thrown in DSFQueryManager, and change the queryMediaType Parameter from String to the respective enum - move Measure.json and Library.json (as well as the 2 cql "query" files) to the direct broker package and use getResourceAsStream - remove backendquery mapping from DirectBrokerClient and move the backendquery id directly to DirectBrokerClient.DirectQuery - extract fhir connection from DirectBrokerClientCql to separate class FhirConnector --- .../QueryDefinitionNotFoundException.java | 9 +- .../broker/direct/DirectBrokerClient.java | 56 +++---- .../broker/direct/DirectBrokerClientCql.java | 149 ++---------------- .../direct/DirectBrokerClientFlare.java | 10 +- .../broker/direct/DirectSpringConfig.java | 9 +- .../query/broker/direct/FhirConnector.java | 146 +++++++++++++++++ .../query/broker/dsf/DSFQueryManager.java | 2 +- .../query/broker/direct}/Library.json | 0 .../query/broker/direct}/Measure.json | 0 .../direct/DirectBrokerClientCqlIT.java | 14 +- .../direct/DirectBrokerClientCqlTest.java | 52 ++++-- .../direct/DirectBrokerClientFlareIT.java | 5 +- .../direct/DirectBrokerClientFlareTest.java | 8 +- .../broker/direct/FhirConnectorTest.java | 114 ++++++++++++++ .../query/broker/dsf/DSFQueryManagerTest.java | 2 +- .../query/broker/direct}/gender-female.cql | 0 .../query/broker/direct}/gender-male.cql | 0 17 files changed, 373 insertions(+), 203 deletions(-) create mode 100644 src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/FhirConnector.java rename src/main/resources/{query/cql => de/numcodex/feasibility_gui_backend/query/broker/direct}/Library.json (100%) rename src/main/resources/{query/cql => de/numcodex/feasibility_gui_backend/query/broker/direct}/Measure.json (100%) create mode 100644 src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/FhirConnectorTest.java rename src/test/resources/{cql => de/numcodex/feasibility_gui_backend/query/broker/direct}/gender-female.cql (100%) rename src/test/resources/{cql => de/numcodex/feasibility_gui_backend/query/broker/direct}/gender-male.cql (100%) diff --git a/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/QueryDefinitionNotFoundException.java b/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/QueryDefinitionNotFoundException.java index 044c56f8..a004bda4 100644 --- a/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/QueryDefinitionNotFoundException.java +++ b/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/QueryDefinitionNotFoundException.java @@ -1,11 +1,18 @@ package de.numcodex.feasibility_gui_backend.query.broker; +import de.numcodex.feasibility_gui_backend.query.QueryMediaType; + /** * Indicates that a query does not contain the necessary query definition. */ public class QueryDefinitionNotFoundException extends Exception { - public QueryDefinitionNotFoundException(String queryId, String queryMediaType) { + public QueryDefinitionNotFoundException(String queryId) { + super("Query with ID '" + queryId + + "' does not contain any query definitions of a known type." ); + } + + public QueryDefinitionNotFoundException(String queryId, QueryMediaType queryMediaType) { super("Query with ID '" + queryId + "' does not contain a query definition for the mandatory type: " + queryMediaType); } diff --git a/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClient.java b/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClient.java index 3a5e618b..29dcd927 100644 --- a/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClient.java +++ b/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClient.java @@ -4,6 +4,7 @@ import de.numcodex.feasibility_gui_backend.query.QueryMediaType; import de.numcodex.feasibility_gui_backend.query.broker.BrokerClient; +import de.numcodex.feasibility_gui_backend.query.broker.QueryDefinitionNotFoundException; import de.numcodex.feasibility_gui_backend.query.broker.QueryNotFoundException; import de.numcodex.feasibility_gui_backend.query.broker.SiteNotFoundException; import de.numcodex.feasibility_gui_backend.query.collect.QueryStatus; @@ -11,7 +12,7 @@ import de.numcodex.feasibility_gui_backend.query.collect.QueryStatusUpdate; import de.numcodex.feasibility_gui_backend.query.persistence.BrokerClientType; import java.util.Collections; -import java.util.HashMap; +import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -23,13 +24,12 @@ public abstract class DirectBrokerClient implements BrokerClient { - static final String SITE_ID_LOCAL = "1"; + private static final String SITE_ID_LOCAL = "1"; - static final String SITE_1_NAME = "Local Server"; + private static final String SITE_NAME_LOCAL = "Local Server"; protected List listeners; protected Map brokerQueries; - protected Map brokerToBackendQueryIdMapping; @Value("${app.broker.direct.obfuscateResultCount:false}") protected boolean obfuscateResultCount; @@ -46,10 +46,9 @@ public void addQueryStatusListener(QueryStatusListener queryStatusListener) { @Override public String createQuery(Long backendQueryId) { - var brokerQuery = DirectQuery.create(); + var brokerQuery = DirectQuery.create(backendQueryId); var brokerQueryId = brokerQuery.getQueryId(); brokerQueries.put(brokerQueryId, brokerQuery); - brokerToBackendQueryIdMapping.put(brokerQueryId, backendQueryId); return brokerQueryId; } @@ -76,8 +75,12 @@ public List getResultSiteIds(String brokerQueryId) throws QueryNotFoundE return findQuery(brokerQueryId).hasResult() ? Collections.singletonList(SITE_ID_LOCAL) : Collections.emptyList(); } - public String getSiteName(String siteId) { - return SITE_ID_LOCAL.equals(siteId) ? SITE_1_NAME : ""; + public String getSiteName(String siteId) throws SiteNotFoundException { + if (SITE_ID_LOCAL.equals(siteId)) { + return SITE_NAME_LOCAL; + } else { + throw new SiteNotFoundException("No site with id " + siteId + " found." ); + } } @@ -109,26 +112,16 @@ protected int obfuscate(int resultCount) { } } - /** - * Updates a query status in all registered listeners. - * @param queryId the id of the query to update - * @param queryStatus the {@link QueryStatus} to publish to the listeners - */ - protected void updateQueryStatus(String queryId, QueryStatus queryStatus) { - var statusUpdate = new QueryStatusUpdate(this, queryId, SITE_ID_LOCAL, queryStatus); - var associatedBackendQueryId = brokerToBackendQueryIdMapping.get(queryId); - listeners.forEach( - l -> l.onClientUpdate(associatedBackendQueryId, statusUpdate) - ); - } - /** * Updates a query status in all registered listeners. * @param query the query to update * @param queryStatus the {@link QueryStatus} to publish to the listeners */ protected void updateQueryStatus(DirectQuery query, QueryStatus queryStatus) { - updateQueryStatus(query.getQueryId(), queryStatus); + var statusUpdate = new QueryStatusUpdate(this, query.getQueryId(), SITE_ID_LOCAL, queryStatus); + listeners.forEach( + l -> l.onClientUpdate(query.getBackendQueryId(), statusUpdate) + ); } /** @@ -138,22 +131,26 @@ public static class DirectQuery { @Getter private final String queryId; + @Getter + private final Long backendQueryId; private final Map queryDefinitions; @Setter private Integer result; - private DirectQuery(String queryId) { + private DirectQuery(String queryId, Long backendQueryId) { this.queryId = queryId; - queryDefinitions = new HashMap<>(); + this.backendQueryId = backendQueryId; + queryDefinitions = new EnumMap<>(QueryMediaType.class); } /** * Creates a new {@link DirectQuery} with a random UUID as a query ID. * + * @param backendQueryId The query id in the backend * @return The created query. */ - public static DirectQuery create() { - return new DirectQuery(UUID.randomUUID().toString()); + public static DirectQuery create(Long backendQueryId) { + return new DirectQuery(UUID.randomUUID().toString(), backendQueryId); } /** @@ -176,8 +173,11 @@ public void addQueryDefinition(QueryMediaType queryMediaType, String content) { * @return The query in its string representation or null if there is no query definition * associated with the specified mime type. */ - public String getQueryDefinition(QueryMediaType queryMediaType) { - return queryDefinitions.get(queryMediaType); + public String getQueryDefinition(QueryMediaType queryMediaType) + throws QueryDefinitionNotFoundException { + return Optional.ofNullable(queryDefinitions.get(queryMediaType)) + .orElseThrow(() -> new QueryDefinitionNotFoundException(queryId, queryMediaType) + ); } /** diff --git a/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientCql.java b/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientCql.java index 454a5cae..8c1341db 100644 --- a/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientCql.java +++ b/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientCql.java @@ -1,22 +1,12 @@ package de.numcodex.feasibility_gui_backend.query.broker.direct; -import ca.uhn.fhir.context.FhirContext; -import ca.uhn.fhir.rest.client.api.IGenericClient; -import ca.uhn.fhir.rest.client.exceptions.FhirClientConnectionException; -import ca.uhn.fhir.rest.param.DateParam; -import ca.uhn.fhir.rest.param.StringParam; import de.numcodex.feasibility_gui_backend.query.broker.BrokerClient; import de.numcodex.feasibility_gui_backend.query.broker.QueryDefinitionNotFoundException; import de.numcodex.feasibility_gui_backend.query.collect.QueryStatus; import de.numcodex.feasibility_gui_backend.query.broker.QueryNotFoundException; -import java.io.InputStream; import lombok.extern.slf4j.Slf4j; -import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.r4.model.Bundle; -import org.hl7.fhir.r4.model.Library; -import org.hl7.fhir.r4.model.Measure; import org.hl7.fhir.r4.model.MeasureReport; -import org.hl7.fhir.r4.model.Parameters; import java.io.IOException; import java.util.*; @@ -24,9 +14,6 @@ import static de.numcodex.feasibility_gui_backend.query.QueryMediaType.CQL; import static de.numcodex.feasibility_gui_backend.query.collect.QueryStatus.COMPLETED; import static de.numcodex.feasibility_gui_backend.query.collect.QueryStatus.FAILED; -import static java.nio.charset.StandardCharsets.UTF_8; -import static org.hl7.fhir.r4.model.Bundle.BundleType.TRANSACTION; -import static org.hl7.fhir.r4.model.Bundle.HTTPVerb.POST; /** * A {@link BrokerClient} to be used to directly communicate with a CQL-capable FHIR Server instance @@ -34,151 +21,41 @@ */ @Slf4j public class DirectBrokerClientCql extends DirectBrokerClient { - private final FhirContext fhirContext; - private final IGenericClient fhirClient; + private final FhirConnector fhirConnector; /** * Creates a new {@link DirectBrokerClientCql} instance that uses the given web client to * communicate with a CQL capable FHIR server instance. * - * @param fhirContext A FHIR context. - * @param fhirClient A FHIR client, configured with the correct url + * @param fhirConnector A FHIR connector. */ - public DirectBrokerClientCql(FhirContext fhirContext, IGenericClient fhirClient) { - this.fhirContext = Objects.requireNonNull(fhirContext); - this.fhirClient = Objects.requireNonNull(fhirClient); + public DirectBrokerClientCql(FhirConnector fhirConnector) { + this.fhirConnector = Objects.requireNonNull(fhirConnector); listeners = new ArrayList<>(); brokerQueries = new HashMap<>(); - brokerToBackendQueryIdMapping = new HashMap<>(); } @Override public void publishQuery(String brokerQueryId) throws QueryNotFoundException, IOException, QueryDefinitionNotFoundException { var query = findQuery(brokerQueryId); - var queryContent = Optional.ofNullable(query.getQueryDefinition(CQL)) - .orElseThrow(() -> new QueryDefinitionNotFoundException(query.getQueryId(), - CQL.getRepresentation()) - ); + var queryContent = query.getQueryDefinition(CQL); updateQueryStatus(query, QueryStatus.EXECUTING); - String measureUri; + var libraryUri = "urn:uuid" + UUID.randomUUID(); + var measureUri = "urn:uuid" + UUID.randomUUID(); + MeasureReport measureReport; try { - measureUri = createMeasureAndLibrary(queryContent); + Bundle bundle = fhirConnector.createBundle(queryContent, libraryUri, measureUri); + fhirConnector.transmitBundle(bundle); + measureReport = fhirConnector.evaluateMeasure(measureUri); } catch (IOException e) { updateQueryStatus(query, FAILED); - throw new IOException( - "An error occurred while publishing the query with ID: " + query.getQueryId() - + " while trying to create measure and library", e); + throw (e); } - var measureReport = evaluateMeasure(measureUri); + var resultCount = measureReport.getGroupFirstRep().getPopulationFirstRep().getCount(); query.setResult(obfuscateResultCount ? obfuscate(resultCount) : resultCount); updateQueryStatus(query, COMPLETED); } - - /** - * Create FHIR {@link Measure} and {@link Library} resources and transmit them in a bundled transaction. - * @param cql the plaintext cql definition - * @return the randomly generated identifier of the {@link Measure} resource - */ - private String createMeasureAndLibrary(String cql) throws IOException { - var libraryUri = "urn:uuid" + UUID.randomUUID(); - var library = appendCql(parseResource(Library.class, - getResourceFileAsString("query/cql/Library.json")).setUrl(libraryUri), cql); - var measureUri = "urn:uuid" + UUID.randomUUID(); - var measure = parseResource(Measure.class, - getResourceFileAsString("query/cql/Measure.json")) - .setUrl(measureUri) - .addLibrary(libraryUri); - var bundle = createBundle(library, measure); - - try { - fhirClient.transaction().withBundle(bundle).execute(); - } catch (FhirClientConnectionException e) { - throw new IOException(e); - } - - return measureUri; - } - - /** - * Get the {@link MeasureReport} for a previously transmitted {@link Measure} - * @param measureUri the identifier of the {@link Measure} - * @return the retrieved {@link MeasureReport} from the server - */ - private MeasureReport evaluateMeasure(String measureUri) { - return fhirClient.operation() - .onType(Measure.class) - .named("evaluate-measure") - .withSearchParameter(Parameters.class, "measure", new StringParam(measureUri)) - .andSearchParameter("periodStart", new DateParam("1900")) - .andSearchParameter("periodEnd", new DateParam("2100")) - .useHttpGet() - .returnResourceType(MeasureReport.class) - .execute(); - } - - /** - * Read file contents as String - * @param fileName name of the resource file - * @return the String contents of the file - */ - public static String getResourceFileAsString(String fileName) throws IOException { - InputStream is = getResourceFileAsInputStream(fileName); - if (is != null) { - return new String(is.readAllBytes(), UTF_8); - } else { - throw new RuntimeException("File not found in classpath: " + fileName); - } - } - - /** - * Read file contents as {@link InputStream} - * @param fileName name of the resource file - * @return an {@link InputStream} of the file - */ - public static InputStream getResourceFileAsInputStream(String fileName) { - ClassLoader classLoader = DirectBrokerClientCql.class.getClassLoader(); - return classLoader.getResourceAsStream(fileName); - } - - /** - * Parse a String as an {@link IBaseResource} implementation - * @param type the concrete {@link IBaseResource} implementation class to parse to - * @param input the {@link String} to parse - * @return the wanted {@link IBaseResource} implementation object - * @param any implementation of {@link IBaseResource} - */ - private T parseResource(Class type, String input) { - var parser = fhirContext.newJsonParser(); - return type.cast(parser.parseResource(input)); - } - - /** - * Add the CQL query to a {@link Library} - * @param library the {@link Library} to add the CQL string to - * @param cql the CQL string to add - * @return the {@link Library} with the added CQL - */ - private Library appendCql(Library library, String cql) { - library.getContentFirstRep().setContentType(CQL.getRepresentation()); - library.getContentFirstRep().setData(cql.getBytes(UTF_8)); - return library; - } - - /** - * Create a {@link Bundle} of a {@link Library} and a {@link Measure} - * @param library the {@link Library} to add to the {@link Bundle} - * @param measure the {@link Measure} to add to the {@link Bundle} - * @return the {@link Bundle}, consisting of the given {@link Library} and {@link Measure} - */ - private static Bundle createBundle(Library library, Measure measure) { - var bundle = new Bundle(); - bundle.setType(TRANSACTION); - bundle.addEntry().setResource(library).getRequest().setMethod(POST).setUrl("Library"); - bundle.addEntry().setResource(measure).getRequest().setMethod(POST).setUrl("Measure"); - return bundle; - } - } diff --git a/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientFlare.java b/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientFlare.java index ef2fdd5b..28175617 100644 --- a/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientFlare.java +++ b/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientFlare.java @@ -33,16 +33,12 @@ public DirectBrokerClientFlare(WebClient webClient) { this.webClient = Objects.requireNonNull(webClient); listeners = new ArrayList<>(); brokerQueries = new HashMap<>(); - brokerToBackendQueryIdMapping = new HashMap<>(); } @Override public void publishQuery(String brokerQueryId) throws QueryNotFoundException, QueryDefinitionNotFoundException, IOException { var query = findQuery(brokerQueryId); - var structuredQueryContent = Optional.ofNullable(query.getQueryDefinition(STRUCTURED_QUERY)) - .orElseThrow(() -> new QueryDefinitionNotFoundException(brokerQueryId, - STRUCTURED_QUERY.getRepresentation() - )); + var structuredQueryContent = query.getQueryDefinition(STRUCTURED_QUERY); try { webClient.post() @@ -54,11 +50,11 @@ public void publishQuery(String brokerQueryId) throws QueryNotFoundException, Qu .map(Integer::valueOf) .doOnError(error -> { log.error(error.getMessage(), error); - updateQueryStatus(brokerQueryId, FAILED); + updateQueryStatus(query, FAILED); }) .subscribe(val -> { query.setResult(obfuscateResultCount ? obfuscate(val) : val); - updateQueryStatus(brokerQueryId, COMPLETED); + updateQueryStatus(query, COMPLETED); }); } catch (Exception e) { throw new IOException("An error occurred while publishing the query with ID: " + brokerQueryId, e); diff --git a/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectSpringConfig.java b/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectSpringConfig.java index 51c99645..3919ec63 100644 --- a/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectSpringConfig.java +++ b/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectSpringConfig.java @@ -29,18 +29,19 @@ public class DirectSpringConfig { @Qualifier("direct") @Bean - public BrokerClient directBrokerClient(WebClient directWebClient) { + public BrokerClient directBrokerClient(WebClient directWebClientFlare) { if (useCql) { FhirContext fhirContext = FhirContext.forR4(); IGenericClient fhirClient = fhirContext.newRestfulGenericClient(cqlBaseUrl); - return new DirectBrokerClientCql(fhirContext, fhirClient); + FhirConnector fhirConnector = new FhirConnector(fhirContext, fhirClient); + return new DirectBrokerClientCql(fhirConnector); } else { - return new DirectBrokerClientFlare(directWebClient); + return new DirectBrokerClientFlare(directWebClientFlare); } } @Bean - public WebClient directWebClient() { + public WebClient directWebClientFlare() { return WebClient.create(flareBaseUrl); } diff --git a/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/FhirConnector.java b/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/FhirConnector.java new file mode 100644 index 00000000..2a34c69c --- /dev/null +++ b/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/direct/FhirConnector.java @@ -0,0 +1,146 @@ +package de.numcodex.feasibility_gui_backend.query.broker.direct; + +import static de.numcodex.feasibility_gui_backend.query.QueryMediaType.CQL; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.hl7.fhir.r4.model.Bundle.BundleType.TRANSACTION; +import static org.hl7.fhir.r4.model.Bundle.HTTPVerb.POST; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.rest.param.DateParam; +import ca.uhn.fhir.rest.param.StringParam; +import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException; +import java.io.IOException; +import java.io.InputStream; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.Library; +import org.hl7.fhir.r4.model.Measure; +import org.hl7.fhir.r4.model.MeasureReport; +import org.hl7.fhir.r4.model.Parameters; + +public class FhirConnector { + + private final FhirContext context; + + private final IGenericClient client; + + public FhirConnector(FhirContext context, IGenericClient client) { + this.context = context; + this.client = client; + } + + /** + * Submit a {@link Bundle} to the FHIR server. + * @param bundle the {@link Bundle} to submit + * @throws IOException if the communication with the FHIR server fails due to any client or server error + */ + public void transmitBundle(Bundle bundle) throws IOException { + try { + client.transaction().withBundle(bundle).execute(); + } catch (BaseServerResponseException e) { + throw new IOException("An error occurred while trying to create measure and library", e); + } + } + + /** + * Get the {@link MeasureReport} for a previously transmitted {@link Measure} + * @param measureUri the identifier of the {@link Measure} + * @return the retrieved {@link MeasureReport} from the server + * @throws IOException if the communication with the FHIR server fails due to any client or server error + */ + public MeasureReport evaluateMeasure(String measureUri) throws IOException { + try { + return client.operation() + .onType(Measure.class) + .named("evaluate-measure") + .withSearchParameter(Parameters.class, "measure", new StringParam(measureUri)) + .andSearchParameter("periodStart", new DateParam("1900")) + .andSearchParameter("periodEnd", new DateParam("2100")) + .useHttpGet() + .returnResourceType(MeasureReport.class) + .execute(); + } catch (BaseServerResponseException e) { + throw new IOException("An error occurred while trying to evaluate a measure report", e); + } + } + + /** + * Create a {@link Bundle} with predefined library and measure URI, as well as CQL String + * @param cql the plaintext cql definition + * @param libraryUri a library uri {@link String} to be included in the {@link Bundle} + * @param measureUri a measure uri {@link String} to be included in the {@link Bundle} + * @return the {@link Bundle}, consisting of a {@link Library} and {@link Measure}, containing the submitted values + */ + public Bundle createBundle(String cql, String libraryUri, String measureUri) throws IOException { + var library = appendCql(parseResource(Library.class, + getResourceFileAsString("Library.json")).setUrl(libraryUri), cql); + var measure = parseResource(Measure.class, + getResourceFileAsString("Measure.json")) + .setUrl(measureUri) + .addLibrary(libraryUri); + return bundleLibraryAndMeasure(library, measure); + } + + /** + * Parse a String as an {@link IBaseResource} implementation + * @param type the concrete {@link IBaseResource} implementation class to parse to + * @param input the {@link String} to parse + * @return the wanted {@link IBaseResource} implementation object + * @param any implementation of {@link IBaseResource} + */ + private T parseResource(Class type, String input) { + var parser = context.newJsonParser(); + return type.cast(parser.parseResource(input)); + } + + /** + * Add the CQL query to a {@link Library} + * @param library the {@link Library} to add the CQL string to + * @param cql the CQL string to add + * @return the {@link Library} with the added CQL + */ + private Library appendCql(Library library, String cql) { + library.getContentFirstRep().setContentType(CQL.getRepresentation()); + library.getContentFirstRep().setData(cql.getBytes(UTF_8)); + return library; + } + + /** + * Create a {@link Bundle} of a {@link Library} and a {@link Measure} + * @param library the {@link Library} to add to the {@link Bundle} + * @param measure the {@link Measure} to add to the {@link Bundle} + * @return the {@link Bundle}, consisting of the given {@link Library} and {@link Measure} + */ + private static Bundle bundleLibraryAndMeasure(Library library, Measure measure) { + var bundle = new Bundle(); + bundle.setType(TRANSACTION); + bundle.addEntry().setResource(library).getRequest().setMethod(POST).setUrl("Library"); + bundle.addEntry().setResource(measure).getRequest().setMethod(POST).setUrl("Measure"); + return bundle; + } + + /** + * Read file contents as String + * @param fileName name of the resource file + * @return the String contents of the file + */ + public static String getResourceFileAsString(String fileName) throws IOException { + InputStream is = getResourceFileAsInputStream(fileName); + if (is != null) { + return new String(is.readAllBytes(), UTF_8); + } else { + throw new RuntimeException("File not found in classpath: " + fileName); + } + } + + /** + * Read file contents as {@link InputStream} + * @param fileName name of the resource file + * @return an {@link InputStream} of the file + */ + private static InputStream getResourceFileAsInputStream(String fileName) { + return DirectBrokerClientCql.class.getResourceAsStream(fileName); + } + +} diff --git a/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/dsf/DSFQueryManager.java b/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/dsf/DSFQueryManager.java index d6471b70..6f7d3d97 100644 --- a/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/dsf/DSFQueryManager.java +++ b/src/main/java/de/numcodex/feasibility_gui_backend/query/broker/dsf/DSFQueryManager.java @@ -112,7 +112,7 @@ public void publishQuery(String queryId) throws QueryNotFoundException, QueryDef var queryContents = query.getContentByType(); if (queryContents.isEmpty()) { - throw new QueryDefinitionNotFoundException(queryId, "any"); + throw new QueryDefinitionNotFoundException(queryId); } if (fhirWebserviceClient == null) { diff --git a/src/main/resources/query/cql/Library.json b/src/main/resources/de/numcodex/feasibility_gui_backend/query/broker/direct/Library.json similarity index 100% rename from src/main/resources/query/cql/Library.json rename to src/main/resources/de/numcodex/feasibility_gui_backend/query/broker/direct/Library.json diff --git a/src/main/resources/query/cql/Measure.json b/src/main/resources/de/numcodex/feasibility_gui_backend/query/broker/direct/Measure.json similarity index 100% rename from src/main/resources/query/cql/Measure.json rename to src/main/resources/de/numcodex/feasibility_gui_backend/query/broker/direct/Measure.json diff --git a/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientCqlIT.java b/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientCqlIT.java index 13e6564d..70570f3c 100644 --- a/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientCqlIT.java +++ b/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientCqlIT.java @@ -54,15 +54,15 @@ class DirectBrokerClientCqlIT { DirectBrokerClientCql client; private final FhirContext fhirContext = FhirContext.forR4(); - private IGenericClient fhirClient; @BeforeAll void setUp() { blaze.start(); fhirContext.getRestfulClientFactory().setSocketTimeout(200 * 1000); - fhirClient = fhirContext.newRestfulGenericClient( + IGenericClient fhirClient = fhirContext.newRestfulGenericClient( format("http://localhost:%d/fhir", blaze.getFirstMappedPort())); - client = new DirectBrokerClientCql(fhirContext, fhirClient); + FhirConnector fhirConnector = new FhirConnector(fhirContext, fhirClient); + client = new DirectBrokerClientCql(fhirConnector); createDummyPatient(fhirClient, Enumerations.AdministrativeGender.MALE, "Curie", "Pierre"); createDummyPatient(fhirClient, Enumerations.AdministrativeGender.FEMALE, "Curie", "Marie"); @@ -78,7 +78,7 @@ void tearDown() { void testExecuteQueryMale() throws QueryNotFoundException, IOException, SiteNotFoundException, QueryDefinitionNotFoundException { var brokerQueryId = client.createQuery(TEST_BACKEND_QUERY_ID); - var cqlString = DirectBrokerClientCql.getResourceFileAsString("cql/gender-male.cql"); + var cqlString = FhirConnector.getResourceFileAsString("gender-male.cql"); client.addQueryDefinition(brokerQueryId, CQL, cqlString); var statusListener = mock(QueryStatusListener.class); @@ -96,7 +96,7 @@ void testExecuteQueryMale() void testExecuteQueryFemale() throws QueryNotFoundException, IOException, SiteNotFoundException, QueryDefinitionNotFoundException { var brokerQueryId = client.createQuery(TEST_BACKEND_QUERY_ID); - var cqlString = DirectBrokerClientCql.getResourceFileAsString("cql/gender-female.cql"); + var cqlString = FhirConnector.getResourceFileAsString("gender-female.cql"); client.addQueryDefinition(brokerQueryId, CQL, cqlString); var statusListener = mock(QueryStatusListener.class); @@ -110,7 +110,7 @@ void testExecuteQueryFemale() assertEquals(1, client.getResultFeasibility(brokerQueryId, "1")); } - private Bundle createDummyPatient(IGenericClient client, AdministrativeGender gender, String lastName, String firstName) { + private void createDummyPatient(IGenericClient client, AdministrativeGender gender, String lastName, String firstName) { String patientIdentifier = Integer.toString(ThreadLocalRandom.current().nextInt(99999)); Patient patient = new Patient(); patient.addIdentifier() @@ -134,7 +134,7 @@ private Bundle createDummyPatient(IGenericClient client, AdministrativeGender ge .setIfNoneExist("identifier=http://acme.org/mrns|" + patientIdentifier) .setMethod(Bundle.HTTPVerb.POST); - return client.transaction().withBundle(bundle).execute(); + client.transaction().withBundle(bundle).execute(); } } diff --git a/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientCqlTest.java b/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientCqlTest.java index 202af561..51894b39 100644 --- a/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientCqlTest.java +++ b/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientCqlTest.java @@ -4,14 +4,19 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; -import ca.uhn.fhir.context.FhirContext; -import ca.uhn.fhir.rest.client.api.IGenericClient; +import de.numcodex.feasibility_gui_backend.query.QueryMediaType; import de.numcodex.feasibility_gui_backend.query.broker.QueryDefinitionNotFoundException; import de.numcodex.feasibility_gui_backend.query.broker.QueryNotFoundException; import de.numcodex.feasibility_gui_backend.query.broker.SiteNotFoundException; +import java.io.IOException; +import org.hl7.fhir.r4.model.Bundle; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -22,12 +27,8 @@ class DirectBrokerClientCqlTest { private static final Long TEST_BACKEND_QUERY_ID = 1L; @SuppressWarnings("unused") - @Mock - FhirContext fhirContext; - - @SuppressWarnings("unused") - @Mock - IGenericClient fhirClient; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + FhirConnector fhirConnector; @InjectMocks DirectBrokerClientCql client; @@ -43,12 +44,39 @@ void testPublishExistingQueryWithoutStructuredQueryDefinition() { assertThrows(QueryDefinitionNotFoundException.class, () -> client.publishQuery(queryId)); } + @Test + void testPublishExistingQueryWithIOExceptionInCreateBundle() throws IOException { + when(fhirConnector.createBundle(any(String.class), any(String.class), any(String.class))).thenThrow(IOException.class); + var queryId = client.createQuery(TEST_BACKEND_QUERY_ID); + var cqlString = FhirConnector.getResourceFileAsString("gender-male.cql"); + assertDoesNotThrow(() -> client.addQueryDefinition(queryId, QueryMediaType.CQL, cqlString)); + assertThrows(IOException.class, () -> client.publishQuery(queryId)); + } + + @Test + void testPublishExistingQueryWithIOExceptionInTransmitBundle() throws IOException { + doThrow(IOException.class).when(fhirConnector).transmitBundle(any(Bundle.class)); + var queryId = client.createQuery(TEST_BACKEND_QUERY_ID); + var cqlString = FhirConnector.getResourceFileAsString("gender-male.cql"); + assertDoesNotThrow(() -> client.addQueryDefinition(queryId, QueryMediaType.CQL, cqlString)); + assertThrows(IOException.class, () -> client.publishQuery(queryId)); + } + + @Test + void testPublishExistingQueryWithIOExceptionInEvaluateMeasure() throws IOException { + when(fhirConnector.evaluateMeasure(any(String.class))).thenThrow(IOException.class); + var queryId = client.createQuery(TEST_BACKEND_QUERY_ID); + var cqlString = FhirConnector.getResourceFileAsString("gender-male.cql"); + assertDoesNotThrow(() -> client.addQueryDefinition(queryId, QueryMediaType.CQL, cqlString)); + assertThrows(IOException.class, () -> client.publishQuery(queryId)); + } + @Test void testGetSiteName() { - assertEquals("Local Server", client.getSiteName("1")); - assertTrue(client.getSiteName("foo").isEmpty()); - assertTrue(client.getSiteName("something-else").isEmpty()); - assertTrue(client.getSiteName("FHIR Server").isEmpty()); + assertDoesNotThrow(() -> assertEquals("Local Server", client.getSiteName("1"))); + assertThrows(SiteNotFoundException.class, () -> client.getSiteName("foo")); + assertThrows(SiteNotFoundException.class, () -> client.getSiteName("something-else")); + assertThrows(SiteNotFoundException.class, () -> client.getSiteName("FHIR Server")); } @Test diff --git a/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientFlareIT.java b/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientFlareIT.java index 4b7f607c..00bdfc51 100644 --- a/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientFlareIT.java +++ b/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientFlareIT.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; import org.springframework.web.reactive.function.client.WebClient; import java.io.IOException; @@ -54,7 +55,7 @@ void testPublishQuery() var brokerQueryId = client.createQuery(TEST_BACKEND_QUERY_ID); client.addQueryDefinition(brokerQueryId, STRUCTURED_QUERY, "foo"); - mockWebServer.enqueue(new MockResponse().setBody("123").setHeader(CONTENT_TYPE, "internal/json")); + mockWebServer.enqueue(new MockResponse().setBody("123").setHeader(CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); var statusListener = mock(QueryStatusListener.class); client.addQueryStatusListener(statusListener); @@ -96,7 +97,7 @@ void testPublishQueryUnexpectedResponseBody() var brokerQueryId = client.createQuery(TEST_BACKEND_QUERY_ID); client.addQueryDefinition(brokerQueryId, STRUCTURED_QUERY, "foo"); - mockWebServer.enqueue(new MockResponse().setBody("not-a-number").setHeader(CONTENT_TYPE, "internal/json")); + mockWebServer.enqueue(new MockResponse().setBody("not-a-number").setHeader(CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); var statusListener = mock(QueryStatusListener.class); client.addQueryStatusListener(statusListener); diff --git a/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientFlareTest.java b/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientFlareTest.java index 764b84a4..234be549 100644 --- a/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientFlareTest.java +++ b/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/DirectBrokerClientFlareTest.java @@ -37,10 +37,10 @@ void testPublishExistingQueryWithoutStructuredQueryDefinition() { @Test void testGetSiteName() { - assertEquals("Local Server", client.getSiteName("1")); - assertTrue(client.getSiteName("foo").isEmpty()); - assertTrue(client.getSiteName("something-else").isEmpty()); - assertTrue(client.getSiteName("CQL Server").isEmpty()); + assertDoesNotThrow(() -> assertEquals("Local Server", client.getSiteName("1"))); + assertThrows(SiteNotFoundException.class, () -> client.getSiteName("foo")); + assertThrows(SiteNotFoundException.class, () -> client.getSiteName("something-else")); + assertThrows(SiteNotFoundException.class, () -> client.getSiteName("CQL Server")); } @Test diff --git a/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/FhirConnectorTest.java b/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/FhirConnectorTest.java new file mode 100644 index 00000000..19460118 --- /dev/null +++ b/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/direct/FhirConnectorTest.java @@ -0,0 +1,114 @@ +package de.numcodex.feasibility_gui_backend.query.broker.direct; + +import static java.lang.String.format; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import java.io.IOException; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.MeasureReport; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.PullPolicy; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@TestInstance(Lifecycle.PER_CLASS) +@ExtendWith(MockitoExtension.class) +class FhirConnectorTest { + + private final FhirContext fhirContext = FhirContext.forR4(); + + private FhirConnector fhirConnector; + + private final GenericContainer blaze = new GenericContainer<>( + DockerImageName.parse("samply/blaze:0.18")) + .withImagePullPolicy(PullPolicy.alwaysPull()) + .withExposedPorts(8080) + .waitingFor(Wait.forHttp("/health").forStatusCodeMatching(c -> c >= 200 && c <= 500)) + .withStartupAttempts(3); + + @BeforeAll + void setUp() { + blaze.start(); + fhirContext.getRestfulClientFactory().setSocketTimeout(200 * 1000); + IGenericClient fhirClient = fhirContext.newRestfulGenericClient( + format("http://localhost:%d/fhir", blaze.getFirstMappedPort())); + fhirConnector = new FhirConnector(fhirContext, fhirClient); + } + + @AfterAll + void tearDown() { + blaze.stop(); + } + + @Test + void testCreateBundle() throws IOException { + var cqlString = FhirConnector.getResourceFileAsString("gender-male.cql"); + assertThat( + fhirConnector.createBundle(cqlString, "urn:uuid:foo:bar", "urn:uuid:bar:foo")).isInstanceOf( + Bundle.class); + } + + @Test + void testTransmitBundle() throws IOException { + var cqlString = FhirConnector.getResourceFileAsString("gender-male.cql"); + Bundle bundle = fhirConnector.createBundle(cqlString, "urn:uuid:foo:bar", "urn:uuid:bar:foo"); + assertDoesNotThrow(() -> fhirConnector.transmitBundle(bundle)); + } + + @Test + void testTransmitBundleWithoutBundle() { + assertThrows(NullPointerException.class, () -> fhirConnector.transmitBundle(null)); + } + + @Test + void testEvaluateMeasure() throws IOException { + var cqlString = FhirConnector.getResourceFileAsString("gender-male.cql"); + var libraryUri = "urn:uuid:library:1"; + var measureUri = "urn:uuid:measure:1"; + + var bundle = fhirConnector.createBundle(cqlString, libraryUri, measureUri); + assertDoesNotThrow(() -> fhirConnector.transmitBundle(bundle)); + assertThat(fhirConnector.evaluateMeasure(measureUri)).isInstanceOf(MeasureReport.class); + } + + @Test + void testEvaluateMeasureWithoutMeasureUri() { + assertThrows(IOException.class, () -> fhirConnector.evaluateMeasure(null)); + } + + @Test + void testEvaluateMeasureWithInvalidMeasureUri() { + assertThrows(IOException.class, () -> fhirConnector.evaluateMeasure("foobar")); + } + + @Test + void getResourceFileAsString() throws IOException { + assertDoesNotThrow(() -> FhirConnector.getResourceFileAsString("gender-male.cql")); + assertThat(FhirConnector.getResourceFileAsString("gender-male.cql")).isInstanceOf(String.class); + assertThat(FhirConnector.getResourceFileAsString("gender-male.cql")).contains( + "Patient.gender = 'male'"); + } + + @Test + void getResourceFileAsStringFileNotFoundThrows() { + String nonExistingFilename = "does-not-exist"; + RuntimeException runtimeException = assertThrows(RuntimeException.class, + () -> FhirConnector.getResourceFileAsString(nonExistingFilename)); + assertEquals("File not found in classpath: " + nonExistingFilename, + runtimeException.getMessage()); + } +} diff --git a/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/dsf/DSFQueryManagerTest.java b/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/dsf/DSFQueryManagerTest.java index 845e1b8e..194a1eef 100644 --- a/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/dsf/DSFQueryManagerTest.java +++ b/src/test/java/de/numcodex/feasibility_gui_backend/query/broker/dsf/DSFQueryManagerTest.java @@ -99,7 +99,7 @@ public void testPublishQuery_QueryHasNoQueryDefinitionYet() { var queryId = queryHandler.createQuery(); QueryDefinitionNotFoundException e = assertThrows(QueryDefinitionNotFoundException.class, () -> queryHandler.publishQuery(queryId)); - assertEquals("Query with ID '" + queryId + "' does not contain a query definition for the mandatory type: any", e.getMessage()); + assertEquals("Query with ID '" + queryId + "' does not contain any query definitions of a known type.", e.getMessage()); } @Test diff --git a/src/test/resources/cql/gender-female.cql b/src/test/resources/de/numcodex/feasibility_gui_backend/query/broker/direct/gender-female.cql similarity index 100% rename from src/test/resources/cql/gender-female.cql rename to src/test/resources/de/numcodex/feasibility_gui_backend/query/broker/direct/gender-female.cql diff --git a/src/test/resources/cql/gender-male.cql b/src/test/resources/de/numcodex/feasibility_gui_backend/query/broker/direct/gender-male.cql similarity index 100% rename from src/test/resources/cql/gender-male.cql rename to src/test/resources/de/numcodex/feasibility_gui_backend/query/broker/direct/gender-male.cql