diff --git a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/transformation/custom/MapLocalObservationCodes.java b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/transformation/custom/MapLocalObservationCodes.java index ad45ff5ea..3c9bd70e4 100644 --- a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/transformation/custom/MapLocalObservationCodes.java +++ b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/transformation/custom/MapLocalObservationCodes.java @@ -23,52 +23,61 @@ public class MapLocalObservationCodes implements CustomFhirTransformation { protected final Logger logger = ApplicationContext.getImplementation(Logger.class); - private HashMap codingMap; - - public MapLocalObservationCodes() { - initMap(); - } - @Override public void transform(HealthData resource, Map args) { + var codingMap = getMapFromArgs(args); + var bundle = (Bundle) resource.getUnderlyingData(); + var msh41Identifier = + HapiHelper.getMSH4_1Identifier(bundle) != null + ? HapiHelper.getMSH4_1Identifier(bundle).getValue() + : null; + var messageId = HapiHelper.getMessageControlId(bundle); var observations = HapiHelper.resourcesInBundle(bundle, Observation.class); - for (Observation obv : observations.toList()) { - var codingList = obv.getCode().getCoding(); - - if (codingList.size() != 1) { - continue; - } - - var coding = codingList.get(0); - if (!HapiHelper.hasDefinedCoding( - coding, HapiHelper.EXTENSION_ALT_CODING, HapiHelper.LOCAL_CODE)) { - continue; - } + observations + .filter(this::hasValidCoding) + .forEach( + observation -> + processCoding(observation, codingMap, msh41Identifier, messageId)); + } - var identifier = codingMap.get(coding.getCode()); - if (identifier == null) { - logUnmappedLocalCode(bundle, coding); - continue; - } + private boolean hasValidCoding(Observation observation) { + var codingList = observation.getCode().getCoding(); + return codingList.size() == 1 && isLocalCode(codingList.get(0)); + } - var mappedCoding = getMappedCoding(identifier); + private boolean isLocalCode(Coding coding) { + return HapiHelper.hasDefinedCoding( + coding, HapiHelper.EXTENSION_ALT_CODING, HapiHelper.LOCAL_CODE); + } - // Add the mapped code as the first in the list, ahead of the existing alternate code - codingList.add(0, mappedCoding); + private void processCoding( + Observation observation, + Map codingMap, + String msh41Identifier, + String messageId) { + var originalCoding = observation.getCode().getCoding().get(0); + IdentifierCode identifier = codingMap.get(originalCoding.getCode()); + + if (identifier == null) { + logger.logWarning( + "Unmapped local code detected: '{}', from sender: '{}', message Id: '{}'", + originalCoding.getCode(), + msh41Identifier, + messageId); + return; } - } - private void logUnmappedLocalCode(Bundle bundle, Coding coding) { - var msh41Identifier = HapiHelper.getMSH4_1Identifier(bundle); - var msh41Value = msh41Identifier != null ? msh41Identifier.getValue() : null; + var mappedCoding = getMappedCoding(identifier); + observation.getCode().getCoding().add(0, mappedCoding); + } - logger.logWarning( - "Unmapped local code detected: '{}', from sender: '{}', message Id: '{}'", - coding.getCode(), - msh41Value, - HapiHelper.getMessageControlId(bundle)); + private String validateField(String field, String fieldName) { + if (field == null || field.isBlank()) { + throw new IllegalArgumentException("missing or empty required field " + fieldName); + } + return field; } private Coding getMappedCoding(IdentifierCode identifierCode) { @@ -85,92 +94,30 @@ private Coding getMappedCoding(IdentifierCode identifierCode) { return mappedCoding; } - /** - * Initializes the local-to-LOINC/PLT hash map, customized for CDPH and UCSD. Currently, the - * mapping is hardcoded for simplicity. If expanded to support additional entities, the - * implementation may be updated to allow dynamic configuration via - * transformation_definitions.json or a database-driven mapping. - */ - private void initMap() { - this.codingMap = new HashMap<>(); - // ALD - codingMap.put( - "99717-32", - new IdentifierCode( - "85269-9", - "X-linked Adrenoleukodystrophy (X- ALD) newborn screen interpretation", - HapiHelper.LOINC_CODE)); - codingMap.put( - "99717-33", - new IdentifierCode( - "85268-1", - "X-linked Adrenoleukodystrophy (X- ALD) newborn screening comment-discussion", - HapiHelper.LOINC_CODE)); - codingMap.put( - "99717-34", - new IdentifierCode( - "PLT325", - "ABCD1 gene mutation found [Identifier] in DBS by Sequencing", - HapiHelper.PLT_CODE)); - // CAH - codingMap.put( - "99717-6", - new IdentifierCode( - "53340-6", - "17-Hydroxyprogesterone [Moles/volume] in DBS", - HapiHelper.LOINC_CODE)); - // CF - codingMap.put( - "99717-35", - new IdentifierCode( - "PLT3289", - "CFTR gene mutation found [Interpretation] in DBS by Sequencing", - HapiHelper.PLT_CODE)); - codingMap.put( - "99717-36", - new IdentifierCode( - "PLT3290", - "CFTR gene variant found [Identifier] in DBS by Sequencing comments/discussion", - HapiHelper.PLT_CODE)); - // MPS I - codingMap.put( - "99717-48", - new IdentifierCode( - "PLT3258", - "IDUA gene mutations found [Identifier] in DBS by Sequencing", - HapiHelper.PLT_CODE)); - codingMap.put( - "99717-44", - new IdentifierCode( - "PLT3291", - "IDUA gene variant analysis in DBS by Sequencing comments/discussion", - HapiHelper.PLT_CODE)); - // MPS II - codingMap.put( - "99717-50", - new IdentifierCode( - "PLT3294", - "IDS gene mutations found [Identifier] in Dried Bloodspot by Molecular genetics method", - HapiHelper.PLT_CODE)); - // Pompe - codingMap.put( - "99717-47", - new IdentifierCode( - "PLT3252", - "GAA gene mutation found [Identifier] in DBS by Sequencing", - HapiHelper.PLT_CODE)); - codingMap.put( - "99717-46", - new IdentifierCode( - "PLT3292", - "GAA gene variant analysis in DBS by Sequencing comments/discussion", - HapiHelper.PLT_CODE)); - // SMA - codingMap.put( - "99717-60", - new IdentifierCode( - "PLT3293", - "SMN1 exon 7 deletion analysis in DBS by Sequencing", - HapiHelper.PLT_CODE)); + private Map getMapFromArgs(Map args) { + var codingMap = new HashMap(); + + // Suppressing the unchecked cast warning. The assignment below will throw a + // ClassCastException if it fails. + @SuppressWarnings("unchecked") + var argsCodingMap = (Map>) args.get("codingMap"); + + for (Map.Entry> entry : argsCodingMap.entrySet()) { + var localCode = entry.getKey(); + var mappedCode = getIdentifierCode(entry); + + codingMap.put(localCode, mappedCode); + } + + return codingMap; + } + + private IdentifierCode getIdentifierCode(Map.Entry> entry) { + var value = entry.getValue(); + var code = validateField(value.get("code"), "code"); + var display = validateField(value.get("display"), "display"); + var codingSystem = validateField(value.get("codingSystem"), "codingSystem"); + + return new IdentifierCode(code, display, codingSystem); } } diff --git a/etor/src/main/resources/transformation_definitions.json b/etor/src/main/resources/transformation_definitions.json index 427cf0138..d88c6261b 100644 --- a/etor/src/main/resources/transformation_definitions.json +++ b/etor/src/main/resources/transformation_definitions.json @@ -197,7 +197,75 @@ "rules": [ { "name": "MapLocalObservationCodes", - "args": {} + "args": { + "codingMap" : { + "99717-6": { + "code": "53340-6", + "display": "17-Hydroxyprogesterone [Moles/volume] in DBS", + "codingSystem": "LN" + }, + "99717-32": { + "code": "85269-9", + "display": "X-linked Adrenoleukodystrophy (X- ALD) newborn screen interpretation", + "codingSystem": "LN" + }, + "99717-33": { + "code": "85268-1", + "display": "X-linked Adrenoleukodystrophy (X- ALD) newborn screening comment-discussion", + "codingSystem": "LN" + }, + "99717-34": { + "code": "PLT325", + "display": "ABCD1 gene mutation found [Identifier] in DBS by Sequencing", + "codingSystem": "PLT" + }, + "99717-35": { + "code": "PLT3289", + "display": "CFTR gene mutation found [Interpretation] in DBS by Sequencing", + "codingSystem": "PLT" + }, + "99717-36": { + "code": "PLT3290", + "display": "CFTR gene variant found [Identifier] in DBS by Sequencing comments/discussion", + "codingSystem": "PLT" + }, + "99717-44": { + "code": "PLT3291", + "display": "IDUA gene variant analysis in DBS by Sequencing comments/discussion", + "codingSystem": "PLT" + }, + "99717-46": { + "code": "PLT3292", + "display": "GAA gene variant analysis in DBS by Sequencing comments/discussion", + "codingSystem": "PLT" + }, + "99717-47": { + "code": "PLT3252", + "display": "GAA gene mutation found [Identifier] in DBS by Sequencing", + "codingSystem": "PLT" + }, + "99717-48": { + "code": "PLT3258", + "display": "IDUA gene mutations found [Identifier] in DBS by Sequencing", + "codingSystem": "PLT" + }, + "99717-49": { + "code": "76030-6", + "display": "IDS gene full mutation analysis in Blood or Tissue by Sequencing", + "codingSystem": "LN" + }, + "99717-50": { + "code": "PLT3294", + "display": "IDS gene mutations found [Identifier] in Dried Bloodspot by Molecular genetics method", + "codingSystem": "PLT" + }, + "99717-60": { + "code": "PLT3293", + "display": "SMN1 exon 7 deletion analysis in DBS by Sequencing", + "codingSystem": "PLT" + } + } + } } ] }, diff --git a/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/transformation/custom/MapLocalObservationCodesTest.groovy b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/transformation/custom/MapLocalObservationCodesTest.groovy index 8c061582e..258303aa0 100644 --- a/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/transformation/custom/MapLocalObservationCodesTest.groovy +++ b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/transformation/custom/MapLocalObservationCodesTest.groovy @@ -31,7 +31,7 @@ class MapLocalObservationCodesTest extends Specification { def bundle = createBundleWithObservation(initialCode, initialDisplay, true) when: - transformClass.transform(new HapiFhirResource(bundle), null) + transformClass.transform(new HapiFhirResource(bundle), getArgs()) then: def transformedObservation = HapiHelper.resourceInBundle(bundle, Observation.class) @@ -69,7 +69,7 @@ class MapLocalObservationCodesTest extends Specification { when: - transformClass.transform(new HapiFhirResource(bundle), null) + transformClass.transform(new HapiFhirResource(bundle), getArgs()) then: 1 * mockLogger.logWarning(*_) @@ -87,7 +87,7 @@ class MapLocalObservationCodesTest extends Specification { def originalCodingList = HapiHelper.resourceInBundle(bundle, Observation.class).getCode().getCoding() when: - transformClass.transform(new HapiFhirResource(bundle), null) + transformClass.transform(new HapiFhirResource(bundle), getArgs()) then: def transformedObservation = HapiHelper.resourceInBundle(bundle, Observation.class) @@ -109,7 +109,7 @@ class MapLocalObservationCodesTest extends Specification { def originalCodingList = HapiHelper.resourceInBundle(bundle, Observation.class).getCode().getCoding() when: - transformClass.transform(new HapiFhirResource(bundle), null) + transformClass.transform(new HapiFhirResource(bundle), getArgs()) then: def transformedObservation = HapiHelper.resourceInBundle(bundle, Observation.class) @@ -128,7 +128,7 @@ class MapLocalObservationCodesTest extends Specification { def originalCodingList = HapiHelper.resourceInBundle(bundle, Observation.class).getCode().getCoding() when: - transformClass.transform(new HapiFhirResource(bundle), null) + transformClass.transform(new HapiFhirResource(bundle), getArgs()) then: def transformedObservation = HapiHelper.resourceInBundle(bundle, Observation.class) @@ -144,7 +144,7 @@ class MapLocalObservationCodesTest extends Specification { def originalCodingList = HapiHelper.resourceInBundle(bundle, Observation.class).getCode().getCoding() when: - transformClass.transform(new HapiFhirResource(bundle), null) + transformClass.transform(new HapiFhirResource(bundle), getArgs()) then: def transformedObservation = HapiHelper.resourceInBundle(bundle, Observation.class) @@ -160,7 +160,7 @@ class MapLocalObservationCodesTest extends Specification { def originalObservation = HapiHelper.resourceInBundle(bundle, Observation.class) when: - transformClass.transform(new HapiFhirResource(bundle), null) + transformClass.transform(new HapiFhirResource(bundle), getArgs()) then: def transformedObservation = HapiHelper.resourceInBundle(bundle, Observation.class) @@ -179,7 +179,7 @@ class MapLocalObservationCodesTest extends Specification { initialObservations.size() == 114 when: - transformClass.transform(new HapiFhirResource(bundle), null) + transformClass.transform(new HapiFhirResource(bundle), getArgs()) then: def transformedObservations = HapiHelper.resourcesInBundle(bundle, Observation.class).toList() @@ -244,6 +244,114 @@ class MapLocalObservationCodesTest extends Specification { initialAccession == transformedAccession } + def "When args are missing coding system, throws a NullPointerException"() { + given: + def exceptionMessage = "missing or empty required field codingSystem" + def bundle = createBundleWithObservation("99717-32", "Adrenoleukodystrophy deficiency newborn screening interpretation", true) + def args = [ + "codingMap": [ + "99717-32": [ + "code" : "85269-9", + "display" : "X-linked Adrenoleukodystrophy (X- ALD) newborn screen interpretation", + ] + ] + ] + + when: + transformClass.transform(new HapiFhirResource(bundle), args) + + then: + def exception = thrown(IllegalArgumentException) + exception.message.contains(exceptionMessage) + } + + def "When args codingMap is improperly structured, throws a ClassCastException"() { + given: + def bundle = createBundleWithObservation("99717-32", "Adrenoleukodystrophy deficiency newborn screening interpretation", true) + def args = [ + "codingMap": [ + "code" : "99717-32" + ] + ] + + when: + transformClass.transform(new HapiFhirResource(bundle), args) + + then: + thrown(ClassCastException) + } + + def "When args codingMap is not present in the args, throws a NullPointerException"() { + given: + def exceptionMessage = "argsCodingMap" + def bundle = createBundleWithObservation("99717-32", "Adrenoleukodystrophy deficiency newborn screening interpretation", true) + def argsMissingCodingSystem = [ + "theCodingMap": "IsNotHere" + ] + + when: + transformClass.transform(new HapiFhirResource(bundle), argsMissingCodingSystem) + + then: + def exception = thrown(NullPointerException) + exception.message.contains(exceptionMessage) + } + + def "When args is null, throws a NullPointerException"() { + given: + def exceptionMessage = "args" + def bundle = createBundleWithObservation("99717-32", "Adrenoleukodystrophy deficiency newborn screening interpretation", true) + + when: + transformClass.transform(new HapiFhirResource(bundle), null) + + then: + def exception = thrown(NullPointerException) + exception.message.contains(exceptionMessage) + } + + def "When args are missing code, throws a NullPointerException"() { + given: + def exceptionMessage = "missing or empty required field code" + def bundle = createBundleWithObservation("99717-32", "Adrenoleukodystrophy deficiency newborn screening interpretation", true) + def args = [ + "codingMap": [ + "99717-32": [ + "display" : "X-linked Adrenoleukodystrophy (X- ALD) newborn screen interpretation", + "codingSystem": "LN", + ] + ] + ] + + when: + transformClass.transform(new HapiFhirResource(bundle), args) + + then: + def exception = thrown(IllegalArgumentException) + exception.message.contains(exceptionMessage) + } + + def "When args are missing display, throws a NullPointerException"() { + given: + def exceptionMessage = "missing or empty required field display" + def bundle = createBundleWithObservation("99717-32", "Adrenoleukodystrophy deficiency newborn screening interpretation", true) + def args = [ + "codingMap": [ + "99717-32": [ + "code" : "85269-9", + "codingSystem": "LN", + ] + ] + ] + + when: + transformClass.transform(new HapiFhirResource(bundle), args) + + then: + def exception = thrown(IllegalArgumentException) + exception.message.contains(exceptionMessage) + } + Observation getObservationByCode(List observationList, String code) { return observationList.find {observation -> observation.code?.coding?.find { coding -> coding.code == code}} } @@ -326,4 +434,31 @@ class MapLocalObservationCodesTest extends Specification { return coding } + + def getArgs() { + return [ + "codingMap": [ + "99717-32": [ + "code" : "85269-9", + "display" : "X-linked Adrenoleukodystrophy (X- ALD) newborn screen interpretation", + "codingSystem": "LN" + ], + "99717-33": [ + "code" : "85268-1", + "display" : "X-linked Adrenoleukodystrophy (X- ALD) newborn screening comment-discussion", + "codingSystem": "LN" + ], + "99717-34": [ + "code" : "PLT325", + "display" : "ABCD1 gene mutation found [Identifier] in DBS by Sequencing", + "codingSystem": "PLT" + ], + "99717-48": [ + "code" : "PLT3258", + "display" : "IDUA gene mutations found [Identifier] in DBS by Sequencing", + "codingSystem": "PLT" + ] + ] + ] + } }