Skip to content

Commit

Permalink
Add common getExpansion() function (#763)
Browse files Browse the repository at this point in the history
* Add common getExpansion() function

* Mock out server calls

* Specify charset

* Add TerminologyServerClient Bean to config

* Refactor - make client non VSAC specific & add statuc variables

* getExpansion changes

* Refactor - take credentials from properties, cleanup, add IT

* Fix issue with authoritative source url and system-version parameter

Update test file to reflect actual value set

* Remove addition of ValueSet level system-version param & update test to reflect. Privatise getters/setters.

---------

Co-authored-by: Adam Stevenson <stevenson_adam@yahoo.com>
  • Loading branch information
Chris0296 and sliver007 authored Mar 22, 2024
1 parent 631cece commit f6af4a4
Show file tree
Hide file tree
Showing 10 changed files with 508 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,10 @@ public KnowledgeArtifactProcessor knowledgeArtifactProcessor() {
return new KnowledgeArtifactProcessor();
}

@Bean
@Conditional(OnR4Condition.class)
public TerminologyServerClient terminologyServerClient() {
return new TerminologyServerClient(crRulerProperties().getVsacUsername(), crRulerProperties().getVsacApiKey());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,16 @@ public void setEnabled(boolean enabled) {
this.enabled = enabled;
}

private String vsacUsername;

public String getVsacUsername() { return vsacUsername; }

public void setVsacUsername(String vsacUsername) { this.vsacUsername = vsacUsername; }

private String vsacApiKey;

public String getVsacApiKey() { return vsacApiKey; }

public void setVsacApiKey(String vsacApiKey) { this.vsacApiKey = vsacApiKey; }

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.opencds.cqf.ruler.cr;
import static java.util.Comparator.comparing;

import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
Expand Down Expand Up @@ -62,6 +63,7 @@
import org.opencds.cqf.ruler.cr.r4.helper.ResourceClassMapHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Configurable;

import ca.uhn.fhir.context.FhirContext;
Expand All @@ -84,14 +86,20 @@
@Configurable
// TODO: This belongs in the Evaluator. Only included in Ruler at dev time for shorter cycle.
public class KnowledgeArtifactProcessor {
@Autowired
private TerminologyServerClient terminologyServerClient;
private Logger myLog = LoggerFactory.getLogger(KnowledgeArtifactProcessor.class);
public static final String CPG_INFERENCEEXPRESSION = "http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-inferenceExpression";
public static final String CPG_ASSERTIONEXPRESSION = "http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-assertionExpression";
public static final String CPG_FEATUREEXPRESSION = "http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-featureExpression";
public static final String releaseLabelUrl = "http://hl7.org/fhir/StructureDefinition/artifact-releaseLabel";
public static final String releaseDescriptionUrl = "http://hl7.org/fhir/StructureDefinition/artifact-releaseDescription";
public static final String authoritativeSourceUrl = "http://hl7.org/fhir/StructureDefinition/valueset-authoritativeSource";
public static final String expansionParametersUrl = "http://hl7.org/fhir/us/ecr/StructureDefinition/us-ph-expansion-parameters-extension";
public static final String valueSetPriorityUrl = "http://aphl.org/fhir/vsm/StructureDefinition/vsm-valueset-priority";
public static final String valueSetConditionUrl = "http://aphl.org/fhir/vsm/StructureDefinition/vsm-valueset-condition";
public static final String vsmValueSetTagCodeSystemUrl = "http://aphl.org/fhir/vsm/CodeSystem/vsm-valueset-tag";
public static final String vsmValueSetTagVSMAuthoredCode = "vsm-authored";
public static final String valueSetPriorityCode = "priority";
public static final String valueSetConditionCode = "focus";
public final List<String> preservedExtensionUrls = List.of(
Expand Down Expand Up @@ -1025,6 +1033,7 @@ private UsageContext getOrCreateUsageContext(List<UsageContext> usageContexts, S
return n;
});
}

void recursivePackage(
MetadataResource resource,
Bundle bundle,
Expand Down Expand Up @@ -1057,6 +1066,7 @@ void recursivePackage(
.forEach(component -> recursivePackage((MetadataResource)component, bundle, hapiFhirRepository, capability, include, artifactVersion, checkArtifactVersion, forceArtifactVersion));
}
}

private List<RelatedArtifact> combineComponentsAndDependencies(KnowledgeArtifactAdapter<MetadataResource> adapter) {
return Stream.concat(adapter.getComponents().stream(), adapter.getDependencies().stream()).collect(Collectors.toList());
}
Expand Down Expand Up @@ -1250,14 +1260,99 @@ private void doesValueSetNeedExpansion(ValueSet vset, IFhirResourceDaoValueSet<V
vset.setExpansion(null);
ValueSetExpansionOptions options = new ValueSetExpansionOptions();
options.setIncludeHierarchy(true);

ValueSet e = dao.expand(vset,options);
// we need to do this because dao.expand sets the expansion to a subclass and then that breaks the FhirPatch
// `copy` creates the superclass again
vset.setExpansion(e.getExpansion().copy());
return;
}
}

public void expandValueSet(ValueSet valueSet, Parameters expansionParameters) {
// Gather the Terminology Service from the valueSet's authoritativeSourceUrl.
Extension authoritativeSource = valueSet.getExtensionByUrl(authoritativeSourceUrl);
String authoritativeSourceUrl = authoritativeSource != null && authoritativeSource.hasValue()
? authoritativeSource.getValue().primitiveValue()
: valueSet.getUrl();

// TODO: Given the authoritativeSourceUrl, lookup Tx Service connection configuration - is this possible? Problem is we can't reliably infer Tx Service from authSource
// terminologyServerClient.setUsername(config.getUsername(authoritativeSourceUrl));
// terminologyServerClient.setApiKey(config.getApiKey(authoritativeSourceUrl));

ValueSet expandedValueSet;
if (isVSMAuthoredValueSet(valueSet) && hasSimpleCompose(valueSet)) {
// Perform naive expansion independent of terminology servers. Copy all codes from compose into expansion.
ValueSet.ValueSetExpansionComponent expansion = new ValueSet.ValueSetExpansionComponent();
expansion.setTimestamp(Date.from(Instant.now()));

ArrayList<ValueSet.ValueSetExpansionParameterComponent> expansionParams = new ArrayList<>();
ValueSet.ValueSetExpansionParameterComponent parameterNaive = new ValueSet.ValueSetExpansionParameterComponent();
parameterNaive.setName("naive");
parameterNaive.setValue(new BooleanType(true));
expansionParams.add(parameterNaive);
expansion.setParameter(expansionParams);

for (ValueSet.ConceptSetComponent csc : valueSet.getCompose().getInclude()) {
for (ValueSet.ConceptReferenceComponent crc : csc.getConcept()) {
expansion.addContains()
.setCode(crc.getCode())
.setSystem(csc.getSystem())
.setVersion(csc.getVersion())
.setDisplay(crc.getDisplay());
}
}
valueSet.setExpansion(expansion);
} else {
try {
expandedValueSet = terminologyServerClient.expand(valueSet, authoritativeSourceUrl, expansionParameters);
valueSet.setExpansion(expandedValueSet.getExpansion());
} catch (Exception ex) {
myLog.warn("Terminology Server expansion failed: {}", valueSet.getIdElement().getValue(), ex);
}
}
}

public boolean isVSMAuthoredValueSet(ValueSet valueSet) {
return valueSet.hasMeta()
&& valueSet.getMeta().hasTag()
&& valueSet.getMeta().getTag(vsmValueSetTagCodeSystemUrl, vsmValueSetTagVSMAuthoredCode) != null;
}

public boolean hasSimpleCompose(ValueSet valueSet) {
if (valueSet.hasCompose()) {
if (valueSet.getCompose().hasExclude()) {
return false;
}
for (ValueSet.ConceptSetComponent csc : valueSet.getCompose().getInclude()) {
if (csc.hasValueSet()) {
// Cannot expand a compose that references a value set
return false;
}

if (!csc.hasSystem()) {
// Cannot expand a compose that does not have a system
return false;
}

if (csc.hasFilter()) {
// Cannot expand a compose that has a filter
return false;
}

if (!csc.hasConcept()) {
// Cannot expand a compose that does not enumerate concepts
return false;
}
}

// If all includes are simple, the compose can be expanded
return true;
}

return false;
}

private class diffCache {
private final Map<String,Parameters> diffs = new HashMap<String,Parameters>();
private final Map<String,MetadataResource> resources = new HashMap<String,MetadataResource>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package org.opencds.cqf.ruler.cr;

import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.fhir.rest.client.interceptor.AdditionalRequestHeadersInterceptor;
import org.apache.commons.lang3.StringUtils;
import org.hl7.fhir.r4.model.Parameters;
import org.hl7.fhir.r4.model.ValueSet;
import org.jetbrains.annotations.NotNull;
import org.opencds.cqf.cql.evaluator.fhir.util.Canonicals;

import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class TerminologyServerClient {

private final FhirContext ctx;

private String username;

private String getUsername() {
return username;
}

private void setUsername(String username) {
this.username = username;
}

private String apiKey;

private String getApiKey() {
return apiKey;
}

private void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public TerminologyServerClient() {
ctx = FhirContext.forR4();
}

public TerminologyServerClient(String username, String apiKey) {
this();
setUsername(username);
setApiKey(apiKey);
}

public ValueSet expand(ValueSet valueSet, String authoritativeSource, Parameters expansionParameters) {
IGenericClient fhirClient = ctx.newRestfulGenericClient(getAuthoritativeSourceBase(authoritativeSource));
fhirClient.registerInterceptor(getAuthInterceptor(getUsername(), getApiKey()));

// Invoke by Value Set ID
return fhirClient
.operation()
.onInstance(valueSet.getId())
.named("$expand")
.withParameters(expansionParameters)
.returnResourceType(ValueSet.class)
.execute();
}

private AdditionalRequestHeadersInterceptor getAuthInterceptor(String username, String apiKey) {
String authString = StringUtils.join("Basic ", Base64.getEncoder()
.encodeToString(StringUtils.join(username, ":", apiKey).getBytes(StandardCharsets.UTF_8)));
AdditionalRequestHeadersInterceptor authInterceptor = new AdditionalRequestHeadersInterceptor();
authInterceptor.addHeaderValue("Authorization", authString);
return authInterceptor;
}

// Strips resource and id from the authoritative source URL, these are not needed as the client constructs the URL.
// Converts http URLs to https
private String getAuthoritativeSourceBase(String authoritativeSource) {
authoritativeSource = authoritativeSource.substring(0, authoritativeSource.indexOf(Canonicals.getResourceType(authoritativeSource)));
if (authoritativeSource.startsWith("http://")) {
authoritativeSource = authoritativeSource.replaceFirst("http://", "https://");
}
return authoritativeSource;
}
}
2 changes: 2 additions & 0 deletions plugin/cr/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ hapi:
fhir:
rulercr:
enabled: true
vsac-username:
vsac-api-key:
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package org.opencds.cqf.ruler.cr;

import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.parser.IParser;
import org.hl7.fhir.r4.model.Parameters;
import org.hl7.fhir.r4.model.ValueSet;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.opencds.cqf.ruler.test.RestIntegrationTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Lazy;

import java.io.IOException;

import static org.junit.jupiter.api.Assertions.*;

@Lazy
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
classes = {KnowledgeArtifactProcessorIT.class, CrConfig.class},
properties = {"hapi.fhir.fhir_version=r4", "hapi.fhir.security.basic_auth.enabled=false"})
public class KnowledgeArtifactProcessorIT extends RestIntegrationTest {

@Autowired
KnowledgeArtifactProcessor processor;

@Autowired
TerminologyServerClient client;

@Disabled
@Test
void testGetExpansionVSAC() throws IOException {
// given
FhirContext ctx = FhirContext.forR4();

String input = new String(this.getClass().getResourceAsStream("r4/valueset/valueset-2.16.840.1.113762.1.4.1116.89.json").readAllBytes());
IParser parser = ctx.newJsonParser();
ValueSet valueSet = parser.parseResource(ValueSet.class, input);
String codeSystemVersion = valueSet.getCompose().getInclude().get(0).getVersion();

Parameters expansionParameters = new Parameters();

// when
processor.expandValueSet(valueSet, expansionParameters);

// then
assertNotNull(valueSet.getExpansion());
assertEquals(16, valueSet.getExpansion().getTotal());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package org.opencds.cqf.ruler.cr;

import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.parser.IParser;
import org.hl7.fhir.r4.model.Parameters;
import org.hl7.fhir.r4.model.ValueSet;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;

import java.io.IOException;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

@ExtendWith(MockitoExtension.class)
public class KnowledgeArtifactProcessorTest {

@InjectMocks
KnowledgeArtifactProcessor processor;

@Mock
TerminologyServerClient client;

@Test
void testGetExpansionVSAC() throws IOException {
// given
FhirContext ctx = FhirContext.forR4();

String input = new String(this.getClass().getResourceAsStream("r4/valueset/valueset-2.16.840.1.113762.1.4.1116.89.json").readAllBytes());
IParser parser = ctx.newJsonParser();
ValueSet valueSet = parser.parseResource(ValueSet.class, input);

Parameters expansionParameters = new Parameters();
expansionParameters.addParameter("system-version", "http://snomed.info/sct|http://snomed.info/sct/731000124108/version/20230901");

// when
String expandedValueSetString = new String(this.getClass().getResourceAsStream("r4/test/valueset-expanded.json").readAllBytes());
ValueSet expandedValueSet = parser.parseResource(ValueSet.class, expandedValueSetString);
Mockito.when(client.expand(Mockito.eq(valueSet), Mockito.eq(valueSet.getUrl()), Mockito.eq(expansionParameters))).thenReturn(expandedValueSet);

processor.expandValueSet(valueSet, expansionParameters);

// then
assertNotNull(valueSet.getExpansion());
assertEquals(16, valueSet.getExpansion().getTotal());
}

@Test
void testGetExpansionNaive() throws IOException {
FhirContext ctx = FhirContext.forR4();

String input = new String(this.getClass().getResourceAsStream("r4/valueset/valueset-vsm-authored.json").readAllBytes());
IParser parser = ctx.newJsonParser();
ValueSet valueSet = parser.parseResource(ValueSet.class, input);
Parameters expansionParameters = new Parameters();
expansionParameters.addParameter("system-version", "http://snomed.info/sct|http://snomed.info/sct/731000124108/version/20230901");

// when
processor.expandValueSet(valueSet, expansionParameters);

// then
assertNotNull(valueSet.getExpansion());
assertNotNull(valueSet.getExpansion().getParameter().get(0));
assertEquals("naive", valueSet.getExpansion().getParameter().get(0).getName());
assertTrue(valueSet.getExpansion().getParameter().get(0).getValueBooleanType().booleanValue());
assertEquals(1, valueSet.getExpansion().getContains().size());
assertEquals("ANC.A.DE13", valueSet.getExpansion().getContains().get(0).getCode());
assertEquals("Co-habitants", valueSet.getExpansion().getContains().get(0).getDisplay());
}
}
Loading

0 comments on commit f6af4a4

Please sign in to comment.