diff --git a/tooling-cli/src/main/java/org/opencds/cqf/tooling/cli/OperationFactory.java b/tooling-cli/src/main/java/org/opencds/cqf/tooling/cli/OperationFactory.java index 75dd1a259..44e4c797a 100644 --- a/tooling-cli/src/main/java/org/opencds/cqf/tooling/cli/OperationFactory.java +++ b/tooling-cli/src/main/java/org/opencds/cqf/tooling/cli/OperationFactory.java @@ -1,5 +1,10 @@ package org.opencds.cqf.tooling.cli; +//import org.opencds.cqf.tooling.jsonschema.SchemaGenerator; +import org.opencds.cqf.tooling.casereporting.transformer.ErsdTransformer; +import org.opencds.cqf.tooling.dateroller.DataDateRollerOperation; +import org.opencds.cqf.tooling.terminology.*; +import org.opencds.cqf.tooling.terminology.templateToValueSetGenerator.TemplateToValueSetGenerator; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.util.HashMap; @@ -213,8 +218,10 @@ static Operation createOperation(String operationName) { return new QICoreElementsToSpreadsheet(); case "StripGeneratedContent": return new StripGeneratedContentOperation(); + case "SpreadsheetValidateVSandCS": + return new SpreadsheetValidateVSandCS(); default: throw new IllegalArgumentException("Invalid operation: " + operationName); } } -} \ No newline at end of file +} diff --git a/tooling/src/main/java/org/opencds/cqf/tooling/acceleratorkit/StructureDefinitionBindingObject.java b/tooling/src/main/java/org/opencds/cqf/tooling/acceleratorkit/StructureDefinitionBindingObject.java index 604f8bb38..f1dc96140 100644 --- a/tooling/src/main/java/org/opencds/cqf/tooling/acceleratorkit/StructureDefinitionBindingObject.java +++ b/tooling/src/main/java/org/opencds/cqf/tooling/acceleratorkit/StructureDefinitionBindingObject.java @@ -6,6 +6,8 @@ public class StructureDefinitionBindingObject extends StructureDefinitionBaseObj String bindingValueSetURL; String bindingValueSetVersion; String bindingValueSetName; + String bindingObjectExtension; + int cardinalityMin; public String getBindingStrength() {return bindingStrength;} public void setBindingStrength(String bindingStrength) {this.bindingStrength = bindingStrength;} @@ -17,4 +19,8 @@ public class StructureDefinitionBindingObject extends StructureDefinitionBaseObj public void setBindingValueSetName(String bindingValueSetName) {this.bindingValueSetName = bindingValueSetName;} public String getCodeSystemsURLs() {return codeSystemsURLs;} public void setCodeSystemsURLs(String codeSystemsURLs) {this.codeSystemsURLs = codeSystemsURLs;} + public String getBindingObjectExtension() {return bindingObjectExtension;} + public void setBindingObjectExtension(String bindingObjectExtension) {this.bindingObjectExtension = bindingObjectExtension;} + public int getCardinalityMin(){return cardinalityMin;}; + public void setCardinalityMin(int cardinalityMin){this.cardinalityMin = cardinalityMin;} } diff --git a/tooling/src/main/java/org/opencds/cqf/tooling/acceleratorkit/StructureDefinitionElementBindingVisitor.java b/tooling/src/main/java/org/opencds/cqf/tooling/acceleratorkit/StructureDefinitionElementBindingVisitor.java index 0c91f765a..7d56262e0 100644 --- a/tooling/src/main/java/org/opencds/cqf/tooling/acceleratorkit/StructureDefinitionElementBindingVisitor.java +++ b/tooling/src/main/java/org/opencds/cqf/tooling/acceleratorkit/StructureDefinitionElementBindingVisitor.java @@ -2,10 +2,7 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.FhirVersionEnum; -import org.hl7.fhir.r4.model.CanonicalType; -import org.hl7.fhir.r4.model.ElementDefinition; -import org.hl7.fhir.r4.model.StructureDefinition; -import org.hl7.fhir.r4.model.ValueSet; +import org.hl7.fhir.r4.model.*; import java.util.*; import java.util.concurrent.atomic.AtomicReference; @@ -75,6 +72,10 @@ private void getBindings(String sdName, List eds, String sdUR if (ed.hasMin() && ed.hasMax()) { String edCardinality = ed.getMin() + "..." + ed.getMax(); sdbo.setCardinality(edCardinality); + if(ed.getMin() > 0){sdbo.setCardinalityMin(ed.getMin());} + } + if(getBindingObjectExtension(ed).equalsIgnoreCase("qicore-keyelement")){ + sdbo.setBindingObjectExtension("qicore-keyelement"); } String bindingValueSet = ed.getBinding().getValueSet(); String pipeVersion = ""; @@ -129,6 +130,17 @@ else if (ed.hasExtension()) { } } + private String getBindingObjectExtension(ElementDefinition ed) { + for (Extension ext : ed.getExtension()) { + if (!ext.getUrl().isEmpty()) { + if (ext.getUrl().contains("qicore-keyelement")) { + return "qicore-keyelement"; + } + } + } + return ""; + } + private void visitExtensions(ElementDefinition ed, Map bindingObjects, String sdName, String sdURL, String sdVersion) { StructureDefinitionBindingObject sdbo = new StructureDefinitionBindingObject(); sdbo.setSdName(sdName); diff --git a/tooling/src/main/java/org/opencds/cqf/tooling/operation/ProfilesToSpreadsheet.java b/tooling/src/main/java/org/opencds/cqf/tooling/operation/ProfilesToSpreadsheet.java index fad1f0b55..41086e06b 100644 --- a/tooling/src/main/java/org/opencds/cqf/tooling/operation/ProfilesToSpreadsheet.java +++ b/tooling/src/main/java/org/opencds/cqf/tooling/operation/ProfilesToSpreadsheet.java @@ -94,7 +94,7 @@ private void createOutput(List bindingObjects) private List createHeaderNameList() { List headerNameList = new ArrayList() {{ - add("QI Core Profile"); + add(modelName + " Profile"); add("Id"); add("Conformance"); add("ValueSet"); @@ -155,7 +155,10 @@ private void addBindingObjectRowDataToCurrentSheet(XSSFSheet currentSheet, int r currentCell = currentRow.createCell(cellCount++); if ((null != bo.getBindingStrength() && bo.getBindingStrength().equalsIgnoreCase("required")) || - null != bo.getMustSupport() && bo.getMustSupport().equalsIgnoreCase("Y")) { + null != bo.getMustSupport() && bo.getMustSupport().equalsIgnoreCase("Y") || + null != bo.getBindingObjectExtension() && bo.getBindingObjectExtension().equalsIgnoreCase("qicore-keyElement") || + bo.getCardinalityMin() > 0 + ) { currentCell.setCellValue("Needed"); } } diff --git a/tooling/src/main/java/org/opencds/cqf/tooling/terminology/CodeSystemLookupDictionary.java b/tooling/src/main/java/org/opencds/cqf/tooling/terminology/CodeSystemLookupDictionary.java index 85b45df9b..d60e4b3b5 100644 --- a/tooling/src/main/java/org/opencds/cqf/tooling/terminology/CodeSystemLookupDictionary.java +++ b/tooling/src/main/java/org/opencds/cqf/tooling/terminology/CodeSystemLookupDictionary.java @@ -28,7 +28,7 @@ public static String getUrlFromOid(String oid) { case "2.16.840.1.113883.5.45": return "http://terminology.hl7.org/CodeSystem/v3-EntityNameUse"; case "2.16.840.1.113883.6.14": return "http://terminology.hl7.org/CodeSystem/HCPCS"; case "2.16.840.1.113883.6.259": return "https://www.cdc.gov/nhsn/cdaportal/terminology/codesystem/hsloc.html"; - case "2.16.840.1.113883.6.285": return "https://www.cms.gov/Medicare/Coding/HCPCSReleaseCodeSets"; + case "2.16.840.1.113883.6.285": return "http://www.nlm.nih.gov/research/umls/hcpcs"; case "2.16.840.1.113883.6.3": return "http://terminology.hl7.org/CodeSystem/icd10"; case "2.16.840.1.113883.6.4": return "http://www.cms.gov/Medicare/Coding/ICD10"; case "2.16.840.1.113883.6.90": return "http://hl7.org/fhir/sid/icd-10-cm"; @@ -91,7 +91,7 @@ public static String getUrlFromName(String name) { case "EntityNameUse": return "http://terminology.hl7.org/CodeSystem/v3-EntityNameUse"; case "HCPCS": return "http://terminology.hl7.org/CodeSystem/HCPCS"; case "HCPCS Level I: CPT": return "http://terminology.hl7.org/CodeSystem/HCPCS"; - case "HCPCS Level II": return "https://www.cms.gov/Medicare/Coding/HCPCSReleaseCodeSets"; + case "HCPCS Level II": return "https://www.cms.gov/research/umls/hcpccs"; case "HSLOC": return "https://www.cdc.gov/nhsn/cdaportal/terminology/codesystem/hsloc.html"; case "ICD10": return "http://terminology.hl7.org/CodeSystem/icd10"; case "ICD10CM": return "http://hl7.org/fhir/sid/icd-10-cm"; diff --git a/tooling/src/main/java/org/opencds/cqf/tooling/terminology/SpreadsheetValidateVSandCS.java b/tooling/src/main/java/org/opencds/cqf/tooling/terminology/SpreadsheetValidateVSandCS.java new file mode 100644 index 000000000..9817b7a55 --- /dev/null +++ b/tooling/src/main/java/org/opencds/cqf/tooling/terminology/SpreadsheetValidateVSandCS.java @@ -0,0 +1,334 @@ +package org.opencds.cqf.tooling.terminology; + +import ca.uhn.fhir.context.ConfigurationException; +import ca.uhn.fhir.context.FhirContext; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.util.CellReference; +import org.hl7.fhir.r4.model.*; +import org.hl7.fhir.r4.model.ValueSet; +import org.opencds.cqf.tooling.Operation; +import org.opencds.cqf.tooling.modelinfo.Atlas; +import org.opencds.cqf.tooling.terminology.compatators.CodeSystemComparator; +import org.opencds.cqf.tooling.terminology.compatators.ValuesetComparator; +import org.opencds.cqf.tooling.terminology.fhirservice.FhirTerminologyClient; +import org.opencds.cqf.tooling.utilities.CanonicalUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.*; + +/* +Parameters as CLI arguments: + -SpreadsheetValidateVSandCS This operation + -pts=/Users/myname/hold/QICore.xlsx The path and name of the spreadsheet to test + -hh=true boolean for if the sheet contains a header row + -uts=https://uat-cts.nlm.nih.gov/fhir/ The url of the server to test + -un= The test server usernam + -pw= The test server password + -op=/Users/myname/hold/ The output path to put the result text file + -ptig=/Users/myname/Projects/FHIR-Spec The path to the base of the resource path + -rp=4.0.1;US-Core/3.1.1;QI-Core/4.1.1;THO/3.1.0 The resourcepath - which versions and which IGs to use to validate against + Known here internally as the Source of Truth +This class takes a QICore spreadsheet as one of the arguments, and parses it. + For each row, it takes the ValueSet and CodeSystem URLs, grabs them from the test server, and compares them to the ones in the "Source of Truth". + A final report (validationReport.txt) is written to the output path. + */ +public class SpreadsheetValidateVSandCS extends Operation { + + private static final Logger logger = LoggerFactory.getLogger(SpreadsheetValidateVSandCS.class); + + private FhirContext fhirContext; + FhirTerminologyClient fhirClient = null; + private String pathToSpreadsheet; // -pathtospreadsheet (-pts) + private String urlToTestServer; // -urltotestserver (-uts) server to validate + private boolean hasHeader = true; // -hasheader (-hh) + private String pathToIG; // -pathToIG (-ptig) path to IG - files installed using "npm --registry https://packages.simplifier.net install hl7.fhir.us.qicore@4.1.1" (or other package) + private String resourcePaths; // -resourcePaths (-rp) + private Map csMap; + private Map vsMap; + private static final String newLine = System.getProperty("line.separator"); + private Set csNotPresentInIG; + private Set vsNotPresentInIG; + private Set vsNotInTestServer; + private Set csNotInTestServer; + private Set spreadSheetErrors; + private Map vsFailureReport = new HashMap<>(); + private Map csFailureReport = new HashMap<>(); + + private String getHeader(Row header, int columnIndex) { + if (header != null) { + return SpreadsheetHelper.getCellAsString(header, columnIndex).trim(); + } else { + return CellReference.convertNumToColString(columnIndex); + } + } + + @Override + public void execute(String[] args) { + fhirContext = FhirContext.forR4Cached(); + setOutputPath("src/main/resources/org/opencds/cqf/tooling/terminology/output"); // default + resourcePaths = "4.0.1;US-Core/3.1.1;QI-Core/4.1.1;THO/3.1.0"; // default + + String userName = ""; + String password = ""; + csNotPresentInIG = new HashSet<>(); + vsNotPresentInIG = new HashSet<>(); + vsNotInTestServer = new HashSet<>(); + spreadSheetErrors = new HashSet<>(); + + for (String arg : args) { + if (arg.equals("-SpreadsheetValidateVSandCS")) continue; + String[] flagAndValue = arg.split("="); + if (flagAndValue.length < 2) { + throw new IllegalArgumentException("Invalid argument: " + arg); + } + String flag = flagAndValue[0]; + String value = flagAndValue[1]; + + switch (flag.replace("-", "").toLowerCase()) { + case "pathtospreadsheet": + case "pts": + pathToSpreadsheet = value; + break; // -pathtospreadsheet (-pts) + case "hasheader": + case "hh": + hasHeader = Boolean.valueOf(value); + break; // -hasheader (-hh) + case "outputpath": + case "op": + setOutputPath(value); + break; // -outputpath (-op) + case "urlToTestServer": + case "uts": + urlToTestServer = value; + break; // -urltotestserver (-uts) + case "userName": + case "un": + userName = value; + break; // -userName (-un) + case "password": + case "pw": + password = value; + break; // -password (-pw) + case "pathToIG": + case "ptig": + pathToIG = value; + break; // -pathToIG (-ptig) + case "resourcepaths": + case "rp": + resourcePaths = value; + break; // -resourcePaths (-rp) + default: + throw new IllegalArgumentException("Unknown flag: " + flag); + } + } + + if (pathToSpreadsheet == null) { + throw new IllegalArgumentException("The path to the spreadsheet is required"); + } + Endpoint endpoint = new Endpoint().setAddress(urlToTestServer); + fhirClient = new FhirTerminologyClient(fhirContext, endpoint, userName, password); + + validateSpreadsheet(userName, password); + reportResults(); + System.out.println("Finished with the validation."); + } + + private void validateSpreadsheet(String userName, String password) { + int firstSheet = 0; + int idCellNumber = 1; + int valueSetCellNumber = 3; + int valueSetURLCellNumber = 4; + int versionCellNumber = 5; + int codeSystemURLCellNumber = 6; + + Atlas atlas = new Atlas(); + atlas.loadPaths(pathToIG, resourcePaths); + csMap = atlas.getCodeSystems(); + vsMap = atlas.getValueSets(); + + Workbook workbook = SpreadsheetHelper.getWorkbook(pathToSpreadsheet); + Sheet sheet = workbook.getSheetAt(firstSheet); + + Iterator rows = sheet.rowIterator(); + Row header = null; + LocalDateTime begin = java.time.LocalDateTime.now(); + + while (rows.hasNext()) { + Row row = rows.next(); + + if (header == null && hasHeader) { + header = row; + continue; + } + try { + String id = null; + String valueSetURL = null; + String version = null; + String codeSystemURL = null; + if (row.getCell(idCellNumber) != null) { + id = row.getCell(idCellNumber).getStringCellValue(); + } + if (row.getCell(valueSetURLCellNumber) != null) { + valueSetURL = row.getCell(valueSetURLCellNumber).getStringCellValue(); + } + if (row.getCell(versionCellNumber) != null) { + version = row.getCell(versionCellNumber).getStringCellValue(); + } + if (row.getCell(codeSystemURLCellNumber) != null) { + codeSystemURL = row.getCell(codeSystemURLCellNumber).getStringCellValue(); + } + validateRow(valueSetURL, version, codeSystemURL, fhirClient, row.getRowNum() + 1); //add one row number due to header row + } catch (NullPointerException | ConfigurationException ex) { + logger.debug(ex.getMessage()); + logger.debug(ex.toString()); + } + } + LocalDateTime end = java.time.LocalDateTime.now(); + System.out.println("Beginning Time: " + begin + " End time: " + end); + + } + + private void validateRow(String valueSetURL, String version, String codeSystemURL, FhirTerminologyClient fhirClient, int rowNumber) { + String vsServerUrl = urlToTestServer + "ValueSet/?url=" + valueSetURL; + ValueSet vsToValidate = (ValueSet) fhirClient.getResource(vsServerUrl); + if (vsToValidate != null) { + ValueSet vsSourceOfTruth = vsMap.get(vsToValidate.getId().substring(vsToValidate.getId().lastIndexOf(File.separator) + 1)); + if (vsSourceOfTruth != null) { + ValuesetComparator vsCompare = new ValuesetComparator(); + vsCompare.compareValueSets(vsToValidate, vsSourceOfTruth, vsFailureReport); + } else { + vsNotPresentInIG.add(vsToValidate.getUrl() + "|" + vsToValidate.getVersion()); + } + } else { + vsNotInTestServer.add(vsServerUrl.substring(vsServerUrl.lastIndexOf("url=") + 4) + "|" + version); + } + if (codeSystemURL == null || codeSystemURL.isEmpty()) { + spreadSheetErrors.add("Row " + rowNumber + " does not contain a codeSystem"); + } else { + if (codeSystemURL.contains(";")) { + String[] codeSystemURLs = codeSystemURL.split(";"); + Arrays.stream(codeSystemURLs).forEach(singleCodeSystemURL -> { + processCodeSystemURL(singleCodeSystemURL); + }); + } else { + processCodeSystemURL(codeSystemURL); + } + } + } + + private void processCodeSystemURL(String codeSystemURL) { + if (codeSystemNotReachable(codeSystemURL)) { + String csServerUrl = urlToTestServer + "CodeSystem/?url=" + codeSystemURL; + CodeSystem csToValidate = (CodeSystem) fhirClient.getResource(csServerUrl); + if (csToValidate != null) { + CodeSystem csSourceOfTruth = csMap.get(CanonicalUtils.getTail(csToValidate.getUrl())); + if (csSourceOfTruth == null || csSourceOfTruth.isEmpty()) { + csNotPresentInIG.add(csToValidate.getName()); + return; + } + CodeSystemComparator csCompare = new CodeSystemComparator(); + csCompare.compareCodeSystems(csToValidate, csSourceOfTruth, csFailureReport); + } else { + csNotInTestServer.add(codeSystemURL); + } + } + } + + private boolean codeSystemNotReachable(String codeSystemURL) { + if (codeSystemURL.toLowerCase().contains("snomed") || + codeSystemURL.toLowerCase().contains("rxnorm") || + codeSystemURL.toLowerCase().contains("unitsofmeasure") || + codeSystemURL.toLowerCase().contains("loinc") || + codeSystemURL.toLowerCase().contains("nucc")) { + return false; + } + return true; + } + + private void reportResults() { + StringBuilder report = new StringBuilder(); + report.append("Validation Failure Report for Server " + urlToTestServer + " compared to " + pathToIG + newLine); + report.append("\tUsing resource paths of " + resourcePaths + newLine + newLine); + handleFailureReport(report, "valueset"); + if (!vsNotInTestServer.isEmpty()) { + report.append(newLine); + report.append("\tValueSets not found in Test Server:" + newLine); + vsNotInTestServer.forEach(vsName -> { + report.append("\t\t" + vsName + newLine); + }); + } + if (!vsNotPresentInIG.isEmpty()) { + report.append(newLine); + report.append("\tValueSets not in IG:" + newLine); + vsNotPresentInIG.forEach(vsName -> { + report.append("\t\t" + vsName + newLine); + }); + } + handleFailureReport(report, "codesystem"); + if (!csNotPresentInIG.isEmpty()) { + report.append(newLine); + report.append("\tCodeSystems not in IG:" + newLine); + csNotPresentInIG.forEach(csName -> { + report.append("\t\t" + csName + newLine); + }); + } + if (!spreadSheetErrors.isEmpty()) { + report.append(newLine + newLine); + report.append("SpreadSheet Errors:" + newLine); + spreadSheetErrors.forEach(sheetError -> { + report.append("\t\t" + sheetError + newLine); + }); + } + try (BufferedWriter writer = new BufferedWriter(new FileWriter(getOutputPath() + File.separator + "validationReport.txt"))) { + writer.write(report.toString()); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private void handleFailureReport(StringBuilder report, String whichReport) { + String headerText = ""; + Map failureReport = new HashMap<>(); + StringBuilder newReport = new StringBuilder(); + if (whichReport.equalsIgnoreCase("valueset")) { + headerText = "\tValueSet Failures" + newLine; + failureReport = vsFailureReport; + } else if (whichReport.equalsIgnoreCase("codesystem")) { + headerText = newLine + "\tCodeSystem Failures" + newLine; + failureReport = csFailureReport; + } + newReport.append(headerText); + if (!failureReport.isEmpty()) { + failureReport.forEach((setName, failureSet) -> { + newReport.append("\t\t" + setName + ":" + newLine); + ((Set) failureSet).forEach((fieldWithErrors) -> { + if (((Map) fieldWithErrors).get("Concept") != null) { + newReport.append("\t\t\t" + "Concept:" + newLine); + ((Map) fieldWithErrors).forEach((field, reason) -> { + if (!(field.equals("Concept"))) { + newReport.append("\t\t\t\t" + field + ": " + newLine); + newReport.append("\t\t\t\t\t" + reason); + } + }); + + } else { + ((Map) fieldWithErrors).forEach((field, reason) -> { + newReport.append("\t\t\t" + field + ": " + newLine); + newReport.append("\t\t\t\t" + reason); + }); + } + }); + + }); + report.append(newReport); + } + } +} diff --git a/tooling/src/main/java/org/opencds/cqf/tooling/terminology/compatators/CodeSystemComparator.java b/tooling/src/main/java/org/opencds/cqf/tooling/terminology/compatators/CodeSystemComparator.java new file mode 100644 index 000000000..0f72dfabe --- /dev/null +++ b/tooling/src/main/java/org/opencds/cqf/tooling/terminology/compatators/CodeSystemComparator.java @@ -0,0 +1,159 @@ +package org.opencds.cqf.tooling.terminology.compatators; + +import org.hl7.fhir.r4.model.CodeSystem; + +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/* +A class that extends Comparator comparing 2 CodeSystems. Its origination is comparing a CodeSystem from a + terminology server that is being tested with one from the "Source of Truth" IG. + It COULD be used with any 2 CodeSystems, but the error reporting verbiage would be off. + */ +public class CodeSystemComparator extends Comparator { + public void compareCodeSystems(CodeSystem terminologyServerCodeSystem, CodeSystem sourceOfTruthCodeSystem, Map csFailureReport) { + Set> fieldsWithErrors = new HashSet<>(); + if (!terminologyServerCodeSystem.getUrl().equals(sourceOfTruthCodeSystem.getUrl())) { + Map urlFailure = new HashMap<>(); + urlFailure.put("URL", "\"" + terminologyServerCodeSystem.getUrl() + "|" + terminologyServerCodeSystem.getVersion() + "\" Does not equal IG URL \"" + sourceOfTruthCodeSystem.getUrl() + "\"" + newLine); + fieldsWithErrors.add(urlFailure); + } + if (!terminologyServerCodeSystem.getVersion().equals(sourceOfTruthCodeSystem.getVersion())) { + Map versionFailure = new HashMap<>(); + versionFailure.put("Version", "\"" + terminologyServerCodeSystem.getVersion() + "\" Does not equal IG Version \"" + sourceOfTruthCodeSystem.getVersion() + "\"" + newLine); + fieldsWithErrors.add(versionFailure); + } + if (!terminologyServerCodeSystem.getStatus().equals(sourceOfTruthCodeSystem.getStatus())) { + Map statusFailure = new HashMap<>(); + statusFailure.put("Status", "\"" + terminologyServerCodeSystem.getStatus() + "\" Does not equal IG Status \"" + sourceOfTruthCodeSystem.getStatus() + "\"" + newLine); + fieldsWithErrors.add(statusFailure); + } + if (!terminologyServerCodeSystem.getExperimental() == sourceOfTruthCodeSystem.getExperimental()) { + Map experimentalFailure = new HashMap<>(); + experimentalFailure.put("Experimental", "\"" + terminologyServerCodeSystem.getExperimental() + "\" Does not equal IG Experimental \"" + sourceOfTruthCodeSystem.getExperimental() + "\"" + newLine); + fieldsWithErrors.add(experimentalFailure); + } + if (!terminologyServerCodeSystem.getName().equals(sourceOfTruthCodeSystem.getName())) { + Map nameFailure = new HashMap<>(); + nameFailure.put("Name", "\"" + terminologyServerCodeSystem.getName() + "\" Does not equal IG Name \"" + sourceOfTruthCodeSystem.getName() + "\"" + newLine); + fieldsWithErrors.add(nameFailure); + } + if (!terminologyServerCodeSystem.getTitle().equals(sourceOfTruthCodeSystem.getTitle())) { + Map titleFailure = new HashMap<>(); + titleFailure.put("Title", "\"" + terminologyServerCodeSystem.getTitle() + "\" Does not match the IG Title \"" + sourceOfTruthCodeSystem.getTitle() + "\"" + newLine); + fieldsWithErrors.add(titleFailure); + } + if (!terminologyServerCodeSystem.getPublisher().equals(sourceOfTruthCodeSystem.getPublisher())) { + Map publisherFailure = new HashMap<>(); + publisherFailure.put("Publisher", "\"" + terminologyServerCodeSystem.getPublisher() + "\" Does not equal IG Publisher \"" + sourceOfTruthCodeSystem.getPublisher() + "\"" + newLine); + fieldsWithErrors.add(publisherFailure); + } + if (!terminologyServerCodeSystem.getContent().equals(sourceOfTruthCodeSystem.getContent())) { + Map contentFailure = new HashMap<>(); + contentFailure.put("Content", "\"" + terminologyServerCodeSystem.getContent() + "\" Does not equal IG Content \"" + sourceOfTruthCodeSystem.getContent() + "\"" + newLine); + fieldsWithErrors.add(contentFailure); + } + if (terminologyServerCodeSystem.getCount() != sourceOfTruthCodeSystem.getCount()) { + Map countFailure = new HashMap<>(); + countFailure.put("Count", "\"" + terminologyServerCodeSystem.getCount() + "\" Does not equal IG Count \"" + sourceOfTruthCodeSystem.getCount() + "\"" + newLine); + fieldsWithErrors.add(countFailure); + } + Map conceptErrors = new HashMap<>(); + if (!compareCodeSystemConcepts(terminologyServerCodeSystem.getConcept(), sourceOfTruthCodeSystem.getConcept(), conceptErrors)) { + fieldsWithErrors.add(conceptErrors); + } + compareContacts(fieldsWithErrors, terminologyServerCodeSystem.getContact(), sourceOfTruthCodeSystem.getContact()); + if (!fieldsWithErrors.isEmpty()) { + csFailureReport.put(terminologyServerCodeSystem.getUrl() + "|" + terminologyServerCodeSystem.getVersion() + " - " + terminologyServerCodeSystem.getName(), fieldsWithErrors); + } + } + + private boolean compareCodeSystemConcepts(List terminologyConcepts, List truthConcepts, Map conceptErrors) { + AtomicBoolean conceptsMatch = new AtomicBoolean(true); + if ((terminologyConcepts != null && truthConcepts != null)/* && (terminologyConcepts.size() == truthConcepts.size())*/) { + Map terminologyConceptsMap = createConceptMap(terminologyConcepts); + Map truthConceptsMap = createConceptMap(truthConcepts); + conceptErrors.put("Concept", ""); + if (terminologyConcepts.size() != truthConcepts.size()) { + conceptErrors.put("Size", "The terminology concept (" + terminologyConcepts.size() + ") and the IG concept (" + truthConcepts.size() + ") sizes do not match ." + newLine); + } + terminologyConceptsMap.forEach((conceptCode, termConcept) -> { + boolean falseFound = false; + if (truthConceptsMap.containsKey(conceptCode)){// && conceptsMatch.get()) { + CodeSystem.ConceptDefinitionComponent truthConcept = truthConceptsMap.get(conceptCode); + if (termConcept != null && truthConcept != null) { + if (!compareStrings(termConcept.getCode().trim(), truthConcept.getCode().trim())) { + falseFound = true; + conceptErrors.put("Code:", "\t \"" + termConcept.getCode() + "\" does not match the IG code \"" + truthConcept.getCode() + "\"" + newLine); + } + if (!compareStrings(termConcept.getDisplay().trim(), truthConcept.getDisplay().trim())) { + falseFound = true; + conceptErrors.put("Display:", "\"" + termConcept.getDisplay() + "\" does not match the IG display \"" + truthConcept.getDisplay() + "\"" + newLine); + } + if (!compareStrings(termConcept.getDefinition().trim(), truthConcept.getDefinition().trim())) { + falseFound = true; + conceptErrors.put("Definition", "\"" + termConcept.getDefinition() + "\" does not match the IG definition \"" + truthConcept.getDefinition() + "\"" + newLine); + } + if (falseFound) { + conceptsMatch.set(false); + } + } else { + conceptsMatch.set(false); + if (termConcept == null) { + conceptErrors.put("Concepts Null", " concept is null and IG concept is not null." + newLine); + } else { + conceptErrors.put("Concepts Null", " concept is not null and IG concept is null." + newLine); + } + + } + int termConceptSize = termConcept.getConcept().size(); + int truthConceptSize = truthConcept.getConcept().size(); + if ( termConceptSize> 0 && truthConceptSize > 0 ) { + conceptsMatch.set(compareCodeSystemConcepts(termConcept.getConcept(), truthConcept.getConcept(), conceptErrors)); + }else if(termConceptSize > 0){ + conceptsMatch.set(false); + conceptErrors.put("Concept", "The concept with code \"" + conceptCode + "\" from the terminology server has " + termConceptSize + " additional concept(s), but the IG concept has 0." + newLine); + }else if(truthConceptSize > 0){ + conceptsMatch.set(false); + conceptErrors.put("Concept", "The concept with code \"" + conceptCode + "\" from the IG has \" + truthConceptSize + \" additional concept(s), but the terminology concept has 0." + newLine); + } + } else { + conceptsMatch.set(false); + conceptErrors.put("Code", "The concept code \"" + conceptCode + "\" from the terminology server does not match the concept code \"" + truthConceptsMap.get(conceptCode) + "\" from the IG." + newLine); + } + }); + } else { + conceptsMatch.set(false); + if (terminologyConcepts == null) { + conceptErrors.put("Concepts", "The terminology concept is not present, but the IG contains one." + newLine); + } + if (truthConcepts == null) { + conceptErrors.put("Concepts", "The terminology concept is present, but the IG does not contains one." + newLine); + } + } + return conceptsMatch.get(); + } + + private Map createConceptMap(List concepts) { + Map conceptMap = new HashMap<>(); + concepts.forEach(concept -> { + conceptMap.put(concept.getCode(), concept); + }); + return conceptMap; + } + + private boolean compareStrings(String terminologyString, String truthString) { + if ((terminologyString != null && truthString != null) || (terminologyString == null && truthString == null)) { + if (terminologyString == null && truthString == null) { + return true; + } + if ((terminologyString != null) && (terminologyString.equals(truthString))) { + return true; + } else { + return false; + } + } + return false; + } + +} diff --git a/tooling/src/main/java/org/opencds/cqf/tooling/terminology/compatators/Comparator.java b/tooling/src/main/java/org/opencds/cqf/tooling/terminology/compatators/Comparator.java new file mode 100644 index 000000000..febfd3afd --- /dev/null +++ b/tooling/src/main/java/org/opencds/cqf/tooling/terminology/compatators/Comparator.java @@ -0,0 +1,148 @@ +package org.opencds.cqf.tooling.terminology.compatators; + +import org.hl7.fhir.r4.model.ContactDetail; +import org.hl7.fhir.r4.model.ContactPoint; +import org.hl7.fhir.r4.model.Period; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +/* +An abastract class that holds comparisons of common parts of ValueSets and CodeSystems with utilities to help with that. + */ +abstract class Comparator { + protected static final String newLine = System.getProperty("line.separator"); + + protected void compareContacts(Set> fieldsWithErrors, List termContacts, List truthContacts) { + Map contactFailure = new HashMap<>(); + Map> termContactMap = createContactMap(termContacts); + Map> truthContactMap = createContactMap(truthContacts); + if (termContactMap != null && truthContactMap != null) { + if (termContactMap.size() == truthContactMap.size()) { + termContactMap.forEach((termContactName, termContactPoints) -> { + Map truthContactPoints = truthContactMap.get(termContactName); + if (termContactPoints != null && truthContactPoints != null) { + Map contactPointFailure = new HashMap<>(); + if (!compareContactPoints(termContactPoints, truthContactPoints, contactPointFailure)) { + fieldsWithErrors.add(contactPointFailure); + } + } else { + if (termContactPoints != null) { + contactFailure.put("Contact", "This server's contact point has values and the matching IG contact point does not." + newLine); + fieldsWithErrors.add(contactFailure); + } else { + contactFailure.put("Contact", "This server's contact point does not have values and the matching IG contact point does." + newLine); + fieldsWithErrors.add(contactFailure); + } + } + }); + } else { + contactFailure.put("Contact", "This server's number of contacts does not match the IG's." + newLine); + fieldsWithErrors.add(contactFailure); + } + } else { + if (termContacts != null) { + contactFailure.put("Contact", "This server has contacts and the IG oes not." + newLine); + fieldsWithErrors.add(contactFailure); + } else { + contactFailure.put("Contact", "This server does not have contacts and the IG does." + newLine); + fieldsWithErrors.add(contactFailure); + } + } + } + + private Map> createContactMap(List contacts) { + // 0..* contacts + // 0..1 name + // 0..* telecom + Map> contactMap = new HashMap<>(); + contacts.forEach(contact -> { + String contactName = contact.getName(); + if (contactName != null && !contactName.isEmpty()) { // if no name, skip the contact + Map contactPoints = new HashMap<>(); + contact.getTelecom().forEach(telcom -> { + contactPoints.put(telcom.getValue(), telcom); + }); + contactMap.put(contactName, contactPoints); + } + }); + return contactMap; + } + + private boolean compareContactPoints(Map termContactPoints, Map truthContactPoints, Map contactPointFailure) { + AtomicBoolean contactPointsMatch = new AtomicBoolean(true); + termContactPoints.forEach((termCPValue, termCP) -> { + ContactPoint truthCP = truthContactPoints.get(termCPValue); + if (truthCP != null) { + if (termCP.getSystem() != null && !termCP.getSystem().equals(truthCP.getSystem())) { + contactPointsMatch.set(false); + contactPointFailure.put("ContactPoint", "The server's contact point system with the value of \"" + termCP.getSystem() + "\" does not match the IG's contact point system of \"" + truthCP.getSystem() + "\"." + newLine); + } else if (truthCP.getSystem() != null && termCP.getSystem() == null) { + contactPointsMatch.set(false); + contactPointFailure.put("ContactPoint", "The IG's contact point system with the value of \"" + truthCP.getSystem() + "\" does not match the server's null value." + newLine); + } + if (termCP.getUse() != null && !termCP.getUse().equals(truthCP.getUse())) { + contactPointsMatch.set(false); + contactPointFailure.put("ContactPoint", "The server's contact point use with the value of \"" + termCP.getUse() + "\" does not match the IG's contact point system of \"" + truthCP.getUse() + "\"." + newLine); + } else if (truthCP.getUse() != null && termCP.getUse() == null) { + contactPointsMatch.set(false); + contactPointFailure.put("ContactPoint", "The IG's contact point system with the value of \"" + truthCP.getUse() + "\" does not match the server's null value." + newLine); + } + if (termCP.getRank() != truthCP.getRank()) { + contactPointsMatch.set(false); + contactPointFailure.put("ContactPoint", "The server's contact point rank with the value of \"" + termCP.getRank() + "\" does not match the IG's contact point system of \"" + truthCP.getRank() + "\"." + newLine); + } + comparePeriods(termCP.getPeriod(), truthCP.getPeriod(), contactPointFailure); + } else { + contactPointsMatch.set(false); + contactPointFailure.put("ContactPoint", "The server's contact point with the value of \"" + termCPValue + "\" does not exist in the IG's contact points." + newLine); + } + }); + return contactPointsMatch.get(); + } + + private boolean comparePeriods(Period termPeriod, Period truthPeriod, Map contactPointFailure) { + boolean periodsMatch = true; + if (termPeriod.getStart() != null && truthPeriod.getStart() != null) { + if (!termPeriod.getStart().equals(truthPeriod.getStart())) { + contactPointFailure.put("ContactPointPeriod", "The server's contact point period with the start value of \"" + termPeriod.getStart() + "\" does not match in the IG's contact point period start of \"" + truthPeriod.getStart() + "\"." + newLine); + periodsMatch = false; + } + } else if (termPeriod.getStart() != null) { + contactPointFailure.put("ContactPointPeriod", "The server's contact point period start value of \"" + termPeriod.getStart() + "\" does not match the IG's contact point period start of \"null\"." + newLine); + periodsMatch = false; + } else { + contactPointFailure.put("ContactPointPeriod", "The IG's contact point period start value of \"" + truthPeriod.getStart() + "\" does not match the server's contact point period start of \"null\"." + newLine); + periodsMatch = false; + } + if (termPeriod.getEnd() != null && truthPeriod.getEnd() != null) { + if (!termPeriod.getEnd().equals(truthPeriod.getEnd())) { + contactPointFailure.put("ContactPointPeriod", "The server's contact point period with the end value of \"" + termPeriod.getEnd() + "\" does not match in the IG's contact point period end of \"" + truthPeriod.getEnd() + "\"." + newLine); + periodsMatch = false; + } + } else if (termPeriod.getEnd() != null) { + contactPointFailure.put("ContactPointPeriod", "The server's contact point period end value of \"" + termPeriod.getEnd() + "\" does not match the IG's contact point period end of \"null\"." + newLine); + periodsMatch = false; + } else { + contactPointFailure.put("ContactPointPeriod", "The IG's contact point period end value of \"" + truthPeriod.getEnd() + "\" does not match the server's contact point period end of \"null\"." + newLine); + periodsMatch = false; + } + if (termPeriod.getId() != null && truthPeriod.getId() != null) { + if (!termPeriod.getId().equals(truthPeriod.getId())) { + contactPointFailure.put("ContactPointPeriod", "The server's contact point period with the id value of \"" + termPeriod.getId() + "\" does not match in the IG's contact point period id of \"" + truthPeriod.getId() + "\"." + newLine); + periodsMatch = false; + } + } else if (termPeriod.getId() != null) { + contactPointFailure.put("ContactPointPeriod", "The server's contact point period id value of \"" + termPeriod.getId() + "\" does not match the IG's contact point period id of \"null\"." + newLine); + periodsMatch = false; + } else { + contactPointFailure.put("ContactPointPeriod", "The IG's contact point period id value of \"" + truthPeriod.getId() + "\" does not match the server's contact point period id of \"null\"." + newLine); + periodsMatch = false; + } + return periodsMatch; + } + +} diff --git a/tooling/src/main/java/org/opencds/cqf/tooling/terminology/compatators/ValuesetComparator.java b/tooling/src/main/java/org/opencds/cqf/tooling/terminology/compatators/ValuesetComparator.java new file mode 100644 index 000000000..271e30726 --- /dev/null +++ b/tooling/src/main/java/org/opencds/cqf/tooling/terminology/compatators/ValuesetComparator.java @@ -0,0 +1,106 @@ +package org.opencds.cqf.tooling.terminology.compatators; + +import org.hl7.fhir.r4.model.ValueSet; + +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/* +A class that extends Comparator comparing 2 ValueSets. Its origination is comparing a ValueSet from a + terminology server that is being tested with one from the "Source of Truth" IG. + It COULD be used with any 2 CodeSystems, but the error reporting verbiage would be off. + */ +public class ValuesetComparator extends Comparator { + public void compareValueSets(ValueSet terminologyServerValueSet, ValueSet sourceOfTruthValueSet, Map vsFailureReport) { + Set> fieldsWithErrors = new HashSet<>(); + if (!terminologyServerValueSet.getUrl() + .equals(sourceOfTruthValueSet.getUrl())) { + Map urlFailure = new HashMap<>(); + urlFailure.put("URL", terminologyServerValueSet.getUrl() + "|" + terminologyServerValueSet.getVersion() + " Does not equal IG URL " + sourceOfTruthValueSet.getUrl() + newLine); + fieldsWithErrors.add(urlFailure); + } + if (!terminologyServerValueSet.getVersion().equals(sourceOfTruthValueSet.getVersion())) { + Map versionFailure = new HashMap<>(); + versionFailure.put("Version", "\"" + terminologyServerValueSet.getVersion() + "\" Does not equal IG Version \"" + sourceOfTruthValueSet.getVersion() + "\"" + newLine); + fieldsWithErrors.add(versionFailure); + } + if (!terminologyServerValueSet.getStatus().equals(sourceOfTruthValueSet.getStatus())) { + Map statusFailure = new HashMap<>(); + statusFailure.put("Status", "\"" + terminologyServerValueSet.getStatus() + "\" Does not equal IG Status \"" + sourceOfTruthValueSet.getStatus() + "\"" + newLine); + fieldsWithErrors.add(statusFailure); + } + if (!terminologyServerValueSet.getExperimental() == sourceOfTruthValueSet.getExperimental()) { + Map experimentalFailure = new HashMap<>(); + experimentalFailure.put("Experimental", "\"" + terminologyServerValueSet.getExperimental() + "\" Does not equal IG Experimental \"" + sourceOfTruthValueSet.getExperimental() + "\"" + newLine); + fieldsWithErrors.add(experimentalFailure); + } + if (!terminologyServerValueSet.getName().equals(sourceOfTruthValueSet.getName())) { + Map nameFailure = new HashMap<>(); + nameFailure.put("Name", "\"" + terminologyServerValueSet.getName() + "\" Does not equal IG Experimental \"" + sourceOfTruthValueSet.getName() + "\"" + newLine); + fieldsWithErrors.add(nameFailure); + } + if (!terminologyServerValueSet.getTitle().equals(sourceOfTruthValueSet.getTitle())) { + Map titleFailure = new HashMap<>(); + titleFailure.put("Status", "\"" + terminologyServerValueSet.getTitle() + "\" Does not equal IG Experimental \"" + sourceOfTruthValueSet.getTitle() + "\"" + newLine); + fieldsWithErrors.add(titleFailure); + } + if (!terminologyServerValueSet.getPublisher().equals(sourceOfTruthValueSet.getPublisher())) { + Map publisherFailure = new HashMap<>(); + publisherFailure.put("Publisher", "\"" + terminologyServerValueSet.getPublisher() + "\" Does not equal IG Experimental \"" + sourceOfTruthValueSet.getPublisher() + "\"" + newLine); + fieldsWithErrors.add(publisherFailure); + } + compareContacts(fieldsWithErrors, terminologyServerValueSet.getContact(), sourceOfTruthValueSet.getContact()); + if (!compareComposes(terminologyServerValueSet.getCompose(), sourceOfTruthValueSet.getCompose())) { + } + if (!fieldsWithErrors.isEmpty()) { + vsFailureReport.put(terminologyServerValueSet.getUrl() + "|" + terminologyServerValueSet.getVersion() + " - " + terminologyServerValueSet.getName(), fieldsWithErrors); + } + } + + private boolean compareComposes(ValueSet.ValueSetComposeComponent terminologyServerComposeComponent, ValueSet.ValueSetComposeComponent sourceOfTruthComposeComponent) { + AtomicBoolean composesMatch = new AtomicBoolean(true); + List terminologyServerIncludes = terminologyServerComposeComponent.getInclude(); + Map terminologyServerIncludesMap = createIncludesMap(terminologyServerIncludes); + List sourceOfTruthIncludes = sourceOfTruthComposeComponent.getInclude(); + Map sourceOfTruthIncludesMap = createIncludesMap(sourceOfTruthIncludes); + if (!terminologyServerIncludesMap.isEmpty() && !sourceOfTruthIncludesMap.isEmpty()) { + if (terminologyServerIncludesMap.size() == sourceOfTruthIncludesMap.size()) { + terminologyServerIncludesMap.forEach((terminologyIncludeKey, terminologyIncludeValue) -> { + if (sourceOfTruthIncludesMap.containsKey(terminologyIncludeKey)) { + terminologyServerIncludesMap.forEach((terminologyIncludesKey, terminologyIncludesValue) -> { + Map terminologyConceptsMap = (HashMap) (terminologyServerIncludesMap.get(terminologyIncludeKey)); + Map truthConceptsMap = (HashMap) (sourceOfTruthIncludesMap.get(terminologyIncludeKey)); + if (!terminologyConceptsMap.isEmpty() && !truthConceptsMap.isEmpty() && + terminologyConceptsMap.size() == truthConceptsMap.size()) { + terminologyConceptsMap.forEach((terminologyConceptsKey, terminologyConceptsValue) -> { + if (truthConceptsMap.containsKey(terminologyConceptsKey)) { + String truthConceptsValue = (String) (truthConceptsMap.get(terminologyConceptsKey)); + if (!truthConceptsValue.equalsIgnoreCase((String) terminologyConceptsValue)) { + composesMatch.set(false); + } + + } + }); + } + + }); + } + }); + } + } + return composesMatch.get(); + } + + private Map createIncludesMap(List includes) { + HashMap includesMap = new HashMap<>(); + includes.forEach(include -> { + Map conceptMap = new HashMap<>(); + List concepts = include.getConcept(); + concepts.forEach(concept -> { + conceptMap.put(concept.getCode(), concept.getDisplay()); + }); + includesMap.put(include.getSystem(), conceptMap); + }); + return includesMap; + } +} diff --git a/tooling/src/main/java/org/opencds/cqf/tooling/terminology/fhirservice/FhirTerminologyClient.java b/tooling/src/main/java/org/opencds/cqf/tooling/terminology/fhirservice/FhirTerminologyClient.java new file mode 100644 index 000000000..441362612 --- /dev/null +++ b/tooling/src/main/java/org/opencds/cqf/tooling/terminology/fhirservice/FhirTerminologyClient.java @@ -0,0 +1,216 @@ +package org.opencds.cqf.tooling.terminology.fhirservice; + +import ca.uhn.fhir.rest.client.interceptor.BasicAuthInterceptor; +import org.hl7.fhir.CodeableConcept; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.*; +import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome; +import org.opencds.cqf.tooling.utilities.CanonicalUtils; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.rest.client.interceptor.AdditionalRequestHeadersInterceptor; +import ca.uhn.fhir.rest.client.interceptor.LoggingInterceptor; +import ca.uhn.fhir.rest.gclient.IOperationUntyped; +import ca.uhn.fhir.rest.gclient.IOperationUntypedWithInputAndPartialOutput; + +public class FhirTerminologyClient implements TerminologyService { + + private IGenericClient client; + public FhirTerminologyClient(IGenericClient client) { + if (client == null) { + throw new IllegalArgumentException("client is required"); + } + + this.client = client; + } + + private FhirContext context; + private Endpoint endpoint; + public FhirTerminologyClient(FhirContext context, Endpoint endpoint, String userName, String password) { + if (context == null) { + throw new IllegalArgumentException("context is required"); + } + this.context = context; + + if (endpoint == null) { + throw new IllegalArgumentException("endpoint is required"); + } + this.endpoint = endpoint; + + this.client = context.newRestfulGenericClient(endpoint.getAddress()); + + BasicAuthInterceptor authInterceptor = new BasicAuthInterceptor(userName, password); + client.registerInterceptor(new LoggingInterceptor()); + client.registerInterceptor(authInterceptor); + if (endpoint.hasHeader()) { + AdditionalRequestHeadersInterceptor interceptor = new AdditionalRequestHeadersInterceptor(); + for (StringType header : endpoint.getHeader()) { + String[] headerValues = header.getValue().split(":"); + if (headerValues.length == 2) { + interceptor.addHeaderValue(headerValues[0], headerValues[1]); + } + // TODO: Log malformed headers in the endpoint + } + client.registerInterceptor(interceptor); + } + } + + private RuntimeException toException(OperationOutcome outcome) { + // TODO: Improve outcome to exception processing + if (outcome.hasIssue()) { + return new RuntimeException(String.format("%s.%s", outcome.getIssueFirstRep().getCode(), outcome.getIssueFirstRep().getDetails())); + } + else { + return new RuntimeException("Errors occurred but no details were returned"); + } + } + + private boolean treatCanonicalTailAsLogicalId = false; + public boolean getTreatCanonicalTailAsLogicalId() { + return this.treatCanonicalTailAsLogicalId; + } + public FhirTerminologyClient setTreatCanonicalTailAsLogicalId(boolean treatCanonicalTailAsLogicalId) { + this.treatCanonicalTailAsLogicalId = treatCanonicalTailAsLogicalId; + return this; + } + + private Object prepareExpand(String url) { + String canonical = url; //CanonicalUtils.stripVersion(url); //baustin - not in current CanonicalUtils/nor in past git versions-- no longer needed?? + String version = CanonicalUtils.getVersion(url); + IOperationUntyped operation = null; + IOperationUntypedWithInputAndPartialOutput operationWithInput = null; + if (treatCanonicalTailAsLogicalId) { + operation = this.client.operation() + .onInstance(String.format("ValueSet/%s", CanonicalUtils.getId(canonical))) + .named("expand"); + if (version != null) { + operationWithInput = operation.withParameter(Parameters.class, "valueSetVersion", new StringType().setValue(version)); + } + } + else { + operation = this.client.operation() + .onType(ValueSet.class) + .named("expand"); + operationWithInput = operation.withParameter(Parameters.class, "url", new UriType().setValue(canonical)); + if (version != null) { + operationWithInput = operationWithInput.andParameter("valueSetVersion", new StringType().setValue(version)); + } + } + return operationWithInput != null ? operationWithInput : operation; + } + + private ValueSet processResultAsValueSet(Object result, String operation) { + if (result instanceof ValueSet) { + return (ValueSet)result; + } + else if (result instanceof OperationOutcome) { + throw toException((OperationOutcome)result); + } + else if (result == null) { + throw new RuntimeException(String.format("No result returned when invoking %s", operation)); + } + else { + throw new RuntimeException(String.format("Unexpected result type %s when invoking %s", result.getClass().getName(), operation)); + } + } + + @Override + @SuppressWarnings("unchecked") // Probably shouldn't be doing this, but it tells me I have an unchecked cast, but it won't let me check the instance of the parameterized generic... + public ValueSet expand(String url) { + Object operationObject = prepareExpand(url); + IOperationUntyped operation = operationObject instanceof IOperationUntyped ? (IOperationUntyped)operationObject : null; + IOperationUntypedWithInputAndPartialOutput operationWithInput = operationObject instanceof IOperationUntypedWithInputAndPartialOutput + ? (IOperationUntypedWithInputAndPartialOutput)operationObject : null; + + Object result = operationWithInput != null ? operationWithInput.execute() : operation.withNoParameters(Parameters.class).execute(); + return processResultAsValueSet(result, "expand"); + } + + @Override + @SuppressWarnings("unchecked") // Probably shouldn't be doing this, but it tells me I have an unchecked cast, but it won't let me check the instance of the parameterized generic... + public ValueSet expand(String url, Iterable systemVersion) { + Object operationObject = prepareExpand(url); + IOperationUntyped operation = operationObject instanceof IOperationUntyped ? (IOperationUntyped)operationObject : null; + IOperationUntypedWithInputAndPartialOutput operationWithInput = operationObject instanceof IOperationUntypedWithInputAndPartialOutput + ? (IOperationUntypedWithInputAndPartialOutput)operationObject : null; + if (systemVersion != null) { + for (String sv : systemVersion) { + if (operationWithInput == null) { + operationWithInput = operation.withParameter(Parameters.class, "system-version", new CanonicalType().setValue(sv)); + } + else { + operationWithInput = operationWithInput.andParameter("system-version", new CanonicalType().setValue(sv)); + } + } + } + + Object result = operationWithInput != null ? operationWithInput.execute() : operation.withNoParameters(Parameters.class).execute(); + return processResultAsValueSet(result, "expand"); + } + + @Override + public Parameters lookup(String code, String systemUrl) { + throw new UnsupportedOperationException("lookup(code, systemUrl)"); + } + + @Override + public Parameters lookup(Coding coding) { + throw new UnsupportedOperationException("lookup(coding)"); + } + + @Override + public Parameters validateCodeInValueSet(String url, String code, String systemUrl, String display) { + throw new UnsupportedOperationException("validateCodeInValueSet(url, code, systemUrl, display)"); + } + + @Override + public Parameters validateCodingInValueSet(String url, Coding code) { + throw new UnsupportedOperationException("validateCodingInValueSet(url, code)"); + } + + @Override + public Parameters validateCodeableConceptInValueSet(String url, CodeableConcept concept) { + throw new UnsupportedOperationException("validateCodeableConceptInValueSet(url, concept)"); + } + + @Override + public Parameters validateCodeInCodeSystem(String url, String code, String systemUrl, String display) { + throw new UnsupportedOperationException("validateCodeInCodeSystem(url, code, systemUrl, display)"); + } + + @Override + public Parameters validateCodingInCodeSystem(String url, Coding code) { + throw new UnsupportedOperationException("validateCodingInCodeSystem(url, code)"); + } + + @Override + public Parameters validateCodeableConceptInCodeSystem(String url, CodeableConcept concept) { + throw new UnsupportedOperationException("validateCodeableConceptInCodeSystem(url, concept)"); + } + + @Override + public ConceptSubsumptionOutcome subsumes(String codeA, String codeB, String systemUrl) { + throw new UnsupportedOperationException("subsumes(codeA, codeB, systemUrl)"); + } + + @Override + public ConceptSubsumptionOutcome subsumes(Coding codeA, Coding codeB) { + throw new UnsupportedOperationException("subsumes(codeA, codeB)"); + } + + @Override + public IBaseResource getResource(String url) { + try { + Bundle readBundle = this.client.search().byUrl(url).returnBundle(Bundle.class).execute(); + if (readBundle.hasEntry()) { + IBaseResource resourceToValidate = null; + resourceToValidate = readBundle.getEntry().get(0).getResource(); + return resourceToValidate; + } + }catch(Exception ex){ + ex.printStackTrace(); + }; + return null; + } +} diff --git a/tooling/src/main/java/org/opencds/cqf/tooling/terminology/fhirservice/TerminologyService.java b/tooling/src/main/java/org/opencds/cqf/tooling/terminology/fhirservice/TerminologyService.java new file mode 100644 index 000000000..d4879b90a --- /dev/null +++ b/tooling/src/main/java/org/opencds/cqf/tooling/terminology/fhirservice/TerminologyService.java @@ -0,0 +1,57 @@ +package org.opencds.cqf.tooling.terminology.fhirservice; + +import org.hl7.fhir.CodeableConcept; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.Coding; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.ValueSet; +import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome; + +/** + * This interface is based on the terminology services interface defined in the FHIRPath extension for FHIR: + * https://hl7.org/fhir/fhirpath.html#txapi + * + * However, for simplicity and coherence in the Java space, it is not a direct rendering, for example, + * parameters are defined explicitly, rather than encoded as a URL string + * + * In addition, for simplicity it is bound directly to FHIR R4, where most of the resources involved are normative. + * + * Also, for this interface, the overloads for client-provided resources are not implemented, on the grounds that + * from the perspective of a terminology client, value set and code system references are all that will be available. + * + * And lastly, in the interest of providing a simple, federated client, the interface does not use server-specific ideas, + * it is always based on the globally unique canonical url for value sets and code systems, and always provided using the + * string representation of a canonical URL, which may or may not include a pipe-separated version segment. + */ +public interface TerminologyService { + // https://hl7.org/fhir/valueset-operation-expand.html + // TODO: Consider activeOnly, as well as includeDraft and expansion parameters (see Measure Terminology Service in the QM IG) + // TODO: Consider whether to expose paging support, or make it transparent at this layer + ValueSet expand(String url); + ValueSet expand(String url, Iterable systemVersion); + + // https://hl7.org/fhir/codesystem-operation-lookup.html + // TODO: Define LookupResult class + Parameters lookup(String code, String systemUrl); + Parameters lookup(Coding coding); + + // https://hl7.org/fhir/valueset-operation-validate-code.html + // TODO: Define ValidateResult class + Parameters validateCodeInValueSet(String url, String code, String systemUrl, String display); + Parameters validateCodingInValueSet(String url, Coding code); + Parameters validateCodeableConceptInValueSet(String url, CodeableConcept concept); + + // https://hl7.org/fhir/codesystem-operation-validate-code.html + Parameters validateCodeInCodeSystem(String url, String code, String systemUrl, String display); + Parameters validateCodingInCodeSystem(String url, Coding code); + Parameters validateCodeableConceptInCodeSystem(String url, CodeableConcept concept); + + // https://hl7.org/fhir/codesystem-operation-subsumes.html + ConceptSubsumptionOutcome subsumes(String codeA, String codeB, String systemUrl); + ConceptSubsumptionOutcome subsumes(Coding codeA, Coding codeB); + + IBaseResource getResource(String url); + + // https://hl7.org/fhir/conceptmap-operation-translate.html + // TODO: Translation support +}