Skip to content

Commit

Permalink
Add a CQL export option to the StripGeneratedContent operation (#511)
Browse files Browse the repository at this point in the history
* Add CQL export to StripContent operation

* More cleanup / refactoring of the strip content processor

* Add suppression for errorneous warning

* Cleanup and a test

* Fix coment

* Fix parameter name

* Further clean-up

* Swap to utility functions
  • Loading branch information
JPercival authored Jan 25, 2024
1 parent 173f7c7 commit 2d81bc9
Show file tree
Hide file tree
Showing 12 changed files with 479 additions and 551 deletions.
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@
"conceptmap",
"Cqfm",
"cqloptions",
"DEPENDSON",
"Dstu",
"Fhir",
"opencds",
"pagecontent",
"plandefinition",
"qicore",
"stripcontent",
"testng"
],
"java.compile.nullAnalysis.mode": "automatic",
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package org.opencds.cqf.tooling.operations.stripcontent;

import static com.google.common.base.Preconditions.checkNotNull;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.util.List;
import java.util.Set;

import static org.opencds.cqf.tooling.utilities.converters.ResourceAndTypeConverter.convertFromR5Resource;
import static org.opencds.cqf.tooling.utilities.converters.ResourceAndTypeConverter.convertToR5Resource;

import org.hl7.fhir.instance.model.api.IAnyResource;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.r5.model.Attachment;
import org.hl7.fhir.r5.model.DomainResource;
import org.hl7.fhir.r5.model.Extension;
import org.hl7.fhir.r5.model.Library;
import org.hl7.fhir.r5.model.Measure;
import org.hl7.fhir.r5.model.Parameters;
import org.hl7.fhir.r5.model.PlanDefinition;
import org.hl7.fhir.r5.model.Questionnaire;
import org.hl7.fhir.r5.model.RelatedArtifact;
import org.hl7.fhir.r5.model.Resource;

import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.parser.DataFormatException;
import ca.uhn.fhir.parser.IParser;

/**
* This class is used to strip autogenerated content from FHIR resources. This includes narrative,
* extensions added by the tooling, related artifacts that are auto detected from the CQL,
* contained resources added by the tooling, and ELM generated from the CQL.
*
* This class converts the resource to its R5 equivalent, strips the content, and then converts
* back to the original FHIR version.
*
* The T parameter is used to specify the version of the Resource base class to use for the operation
* and conversions.
*/
abstract class BaseContentStripper<T extends IAnyResource> implements ContentStripper {
protected abstract FhirContext context();

public void stripFile(File inputFile, File outputFile, ContentStripperOptions options) {
var resource = parseResource(inputFile);

var upgraded = convertToR5Resource(context(), resource);
stripResource(upgraded, outputFile, options);

@SuppressWarnings("unchecked")
var downgraded = (T) convertFromR5Resource(context(), upgraded);
writeResource(outputFile, downgraded);
}

protected void writeContent(File f, String content) {
if (!f.getParentFile().exists()) {
f.getParentFile().mkdirs();
}

try (var writer = new BufferedWriter(new FileWriter(f))) {
writer.write(content);
} catch (IOException e) {
throw new RuntimeException(e);
}
}

protected IParser parserForFile(File file) {
if (file.getName().endsWith(".json")) {
return context().newJsonParser();
} else if (file.getName().endsWith(".xml")) {
return context().newXmlParser();
} else {
throw new IllegalArgumentException(String.format("unsupported file type: %s", file.getName()));
}
}

protected IBaseResource parseResource(File file) {
var parser = parserForFile(file);
try (var reader = new FileReader(file)) {
return parser.parseResource(reader);
}
catch (IOException | DataFormatException e) {
throw new RuntimeException(String.format("Error parsing file %s", file.getName()), e);
}
}

protected void writeResource(File file, IBaseResource resource) {
var parser = parserForFile(file).setPrettyPrint(true);
var output = parser.encodeResourceToString(resource);
writeContent(file, output);
}

// Output file is required because the CQL export functionality requires knowledge of the library
// file location to correctly set the Library.content.url property.
private Resource stripResource(IBaseResource resource, File outputFile, ContentStripperOptions options) {
switch (resource.fhirType()) {
case "Library":
return stripLibrary((Library) resource, outputFile, options);
case "Measure":
return stripMeasure((Measure) resource, options);
case "PlanDefinition":
return stripPlanDefinition((PlanDefinition) resource, options);
case "Questionnaire":
return stripQuestionnaire((Questionnaire) resource, options);
default:
return stripResource((DomainResource) resource, options);
}
}

private boolean isCqlOptionsParameters(Resource resource) {
if (!(resource instanceof Parameters)) {
return false;
}

var parameters = (Parameters) resource;
return "options".equals(parameters.getId());
}

private void filterContained(List<Resource> contained) {
contained.removeIf(this::isCqlOptionsParameters);
}

private void filterExtensions(List<Extension> extensions, Set<String> strippedExtensions) {
extensions.removeIf(x -> strippedExtensions.contains(x.getUrl()));
}

private void filterContent(List<Attachment> attachments, Set<String> strippedContentTypes) {
attachments.removeIf(x -> strippedContentTypes.contains(x.getContentType()));
}

private void filterRelatedArtifacts(List<RelatedArtifact> artifacts) {
artifacts.removeIf(x -> RelatedArtifact.RelatedArtifactType.DEPENDSON.equals(x.getType()));
}

// Strip library includes functionality to export the cql file,
// so it requires knowledge of the target directory for the Library.
private Library stripLibrary(Library library, File libraryFile, ContentStripperOptions options) {
stripResource(library, options);
library.setParameter(null);
library.setDataRequirement(null);
filterRelatedArtifacts(library.getRelatedArtifact());
filterContent(library.getContent(), options.strippedContentTypes());
exportCql(library.getContent(), library.getName(), libraryFile, options.cqlExportDirectory());
return library;
}

private Measure stripMeasure(Measure measure, ContentStripperOptions options) {
stripResource(measure, options);
filterRelatedArtifacts(measure.getRelatedArtifact());
return measure;
}

private PlanDefinition stripPlanDefinition(PlanDefinition planDefinition, ContentStripperOptions options) {
stripResource(planDefinition, options);
filterRelatedArtifacts(planDefinition.getRelatedArtifact());
return planDefinition;
}

private Questionnaire stripQuestionnaire(Questionnaire questionnaire, ContentStripperOptions options) {
stripResource(questionnaire, options);
filterRelatedArtifacts(questionnaire.getRelatedArtifact());
return questionnaire;
}

private DomainResource stripResource(DomainResource resource, ContentStripperOptions options) {
resource.setText(null);
filterExtensions(resource.getExtension(), options.strippedExtensionUrls());
filterContained(resource.getContained());
return resource;
}

private void exportCql(Attachment content, String libraryName, File libraryFile, File cqlExportDirectory) {
checkNotNull(libraryName, "libraryName must be provided");
if (content.getData() == null || cqlExportDirectory == null) {
return;
}

// CQL content is encoded as base64, so we need to decode it
// to get back to the original CQL.
var base64 = content.getDataElement().getValueAsString();
var cql = new String(java.util.Base64.getDecoder().decode(base64));

var cqlFileName = libraryName + ".cql";
var cqlFile = cqlExportDirectory.toPath().resolve(cqlFileName).toFile();

content.setUrl(libraryFile.toPath().relativize(cqlFile.toPath()).toString());
content.setDataElement(null);
writeContent(cqlFile, cql);
}

private void exportCql(List<Attachment> content, String libraryName, File libraryOutputFile, File cqlExportDirectory) {
for (Attachment attachment : content) {
if (ContentStripperOptions.CQL_CONTENT_TYPE.equals(attachment.getContentType())) {
exportCql(attachment, libraryName, libraryOutputFile, cqlExportDirectory);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.opencds.cqf.tooling.operations.stripcontent;

import java.io.File;

// Intentionally package-private. This is a package-internal API for ContentStripper
interface ContentStripper {
void stripFile(File inputPath, File outputPath, ContentStripperOptions options);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.opencds.cqf.tooling.operations.stripcontent;

import org.hl7.fhir.dstu3.model.Resource;
import ca.uhn.fhir.context.FhirContext;

class ContentStripperDstu3 extends BaseContentStripper<Resource> {
@Override
protected FhirContext context() {
return FhirContext.forDstu3Cached();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package org.opencds.cqf.tooling.operations.stripcontent;

import java.io.File;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;

// Intentionally package-private. This is a package-internal API for ContentStripper
class ContentStripperOptions {
static final String CQL_CONTENT_TYPE = "text/cql";
static final String ELM_JSON_CONTENT_TYPE = "application/elm+json";
static final String ELM_XML_CONTENT_TYPE = "application/elm+xml";

static final Set<String> DEFAULT_STRIPPED_CONTENT_TYPES = new HashSet<>(
Arrays.asList(ELM_JSON_CONTENT_TYPE, ELM_XML_CONTENT_TYPE));

static final Set<String> DEFAULT_STRIPPED_EXTENSION_URLS = new HashSet<>(
Arrays.asList("http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-parameter",
"http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-dataRequirement",
"http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-logicDefinition",
"http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-softwaresystem",
"http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-directReferenceCode",
"http://hl7.org/fhir/StructureDefinition/cqf-cqlOptions"));

private ContentStripperOptions() {
// Intentionally empty, forces use of the static factory
}

public static ContentStripperOptions defaultOptions() {
return new ContentStripperOptions();
}

private File cqlExportDirectory;
public File cqlExportDirectory() {
return cqlExportDirectory;
}
public ContentStripperOptions cqlExportDirectory(File cqlExportDirectory) {
this.cqlExportDirectory = cqlExportDirectory;
return this;
}

private Set<String> strippedContentTypes = DEFAULT_STRIPPED_CONTENT_TYPES;
public Set<String> strippedContentTypes() {
return this.strippedContentTypes;
}

public ContentStripperOptions strippedContentTypes(Set<String> strippedContentTypes) {
this.strippedContentTypes = strippedContentTypes;
return this;
}

private Set<String> strippedExtensionUrls = DEFAULT_STRIPPED_EXTENSION_URLS;
public Set<String> strippedExtensionUrls() {
return this.strippedExtensionUrls;
}

public ContentStripperOptions strippedExtensionUrls(Set<String> strippedExtensionUrls) {
this.strippedExtensionUrls = strippedExtensionUrls;
return this;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.opencds.cqf.tooling.operations.stripcontent;

import org.hl7.fhir.r4.model.Resource;
import ca.uhn.fhir.context.FhirContext;

class ContentStripperR4 extends BaseContentStripper<Resource> {
@Override
protected FhirContext context() {
return FhirContext.forR4Cached();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.opencds.cqf.tooling.operations.stripcontent;

import org.hl7.fhir.r5.model.Resource;

import ca.uhn.fhir.context.FhirContext;

class ContentStripperR5 extends BaseContentStripper<Resource> {
@Override
protected FhirContext context() {
return FhirContext.forR5Cached();
}
}
Loading

0 comments on commit 2d81bc9

Please sign in to comment.