From 81f5d3c284982ccdb7f24f36fc69fdd8fac2a919 Mon Sep 17 00:00:00 2001 From: Michael Kalish Date: Thu, 3 Oct 2024 17:54:36 -0400 Subject: [PATCH] 16052: enable FHIR transforms to change bundle with custom FHIR functions (#16053) * 16052: enable FHIR transforms to change bundle with custom FHIR functions * 16052: enable FHIR transforms to change bundle with custom FHIR functions * fixup! Merge branch 'platform/kalish/16052-custom-fhir-function-for-transforms' of github.com:CDCgov/prime-reportstream into platform/kalish/16052-custom-fhir-function-for-transforms * Fix Version generation and killFunc in build.gradle * fixup! 16052: enable FHIR transforms to change bundle with custom FHIR functions * fixup! 16052: enable FHIR transforms to change bundle with custom FHIR functions * fixup! Fix Version generation and killFunc in build.gradle --- prime-router/build.gradle.kts | 34 ++++--- .../docs/universal-pipeline/translate.md | 3 +- .../fhir/fhir-to-fhir-transform.json | 3 + .../kotlin/config/validation/Validations.kt | 3 + .../translation/hl7/FhirTransformer.kt | 75 ++++++++++++++-- .../fhirTransform/FhirTransformSchema.kt | 5 +- .../hl7/utils/CustomFHIRFunctions.kt | 30 +++++++ .../translation/hl7/FhirTransformerTests.kt | 88 +++++++++++++++++++ .../hl7/utils/CustomFHIRFunctionsTests.kt | 27 ++++++ 9 files changed, 247 insertions(+), 21 deletions(-) diff --git a/prime-router/build.gradle.kts b/prime-router/build.gradle.kts index 65ca5665f3f..5705ce5bd18 100644 --- a/prime-router/build.gradle.kts +++ b/prime-router/build.gradle.kts @@ -271,9 +271,6 @@ sourceSets.create("testIntegration") { runtimeClasspath += sourceSets["main"].output } -// Add generated version object -sourceSets["main"].java.srcDir("$buildDir/generated-src/version") - val compileTestIntegrationKotlin: KotlinCompile by tasks compileTestIntegrationKotlin.kotlinOptions.jvmTarget = appJvmTarget @@ -281,6 +278,10 @@ val testIntegrationImplementation: Configuration by configurations.getting { extendsFrom(configurations["testImplementation"]) } +tasks.withType { + mustRunAfter("generateVersionObject") +} + configurations["testIntegrationRuntimeOnly"].extendsFrom(configurations["runtimeOnly"]) tasks.register("testIntegration") { @@ -353,7 +354,7 @@ tasks.withType().configureEach { } tasks.processResources { - dependsOn("generateVersionObject") + mustRunAfter("generateVersionObject") // Set the proper build values in the build.properties file filesMatching("build.properties") { val dateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd") @@ -427,7 +428,7 @@ tasks.register("primeCLI") { // Use arguments passed by another task in the project.extra["cliArgs"] property. doFirst { if (project.extra.has("cliArgs") && project.extra["cliArgs"] is List<*>) { - args = (project.extra["cliArgs"] as List<*>).filterIsInstance(String::class.java) + args = (project.extra["cliArgs"] as List<*>).filterIsInstance() } else if (args.isNullOrEmpty()) { args = listOf("-h") println("primeCLI Gradle task usage: gradle primeCLI --args=''") @@ -521,12 +522,13 @@ tasks.register("generateVersionFile") { } } -tasks.register("generateVersionObject") { - val sourceDir = file("$buildDir/generated-src/version") - val sourceFile = file("$sourceDir/Version.kt") - sourceDir.mkdirs() - sourceFile.writeText( - """ +val generateVersionObject = tasks.register("generateVersionObject") { + doLast { + val sourceDir = file("$buildDir/generated-src/version/src/main/kotlin/gov/cdc/prime/router") + val sourceFile = file("$sourceDir/Version.kt") + sourceDir.mkdirs() + sourceFile.writeText( + """ package gov.cdc.prime.router.version /** @@ -537,7 +539,12 @@ tasks.register("generateVersionObject") { const val commitId = "$commitId" } """.trimIndent() - ) + ) + } +} +sourceSets.getByName("main").kotlin.srcDir("$buildDir/generated-src/version/src/main/kotlin") +tasks.named("compileKotlin").configure { + dependsOn(generateVersionObject) } val azureResourcesTmpDir = File(buildDir, "$azureFunctionsDir-resources/$azureAppName") @@ -645,7 +652,6 @@ task("uploadSwaggerUI") { } tasks.register("killFunc") { - doLast { val processName = "func" if (org.gradle.internal.os.OperatingSystem.current().isWindows) { exec { @@ -658,7 +664,6 @@ tasks.register("killFunc") { commandLine = listOf("sh", "-c", "pkill -9 $processName || true") } } - } } tasks.register("run") { @@ -772,6 +777,7 @@ tasks.named("generateJooq") { tasks.register("compile") { group = rootProject.description ?: "" description = "Compile the code" + dependsOn("generateVersionObject") dependsOn("compileKotlin") } diff --git a/prime-router/docs/universal-pipeline/translate.md b/prime-router/docs/universal-pipeline/translate.md index 6f49a09e37c..aa010074310 100644 --- a/prime-router/docs/universal-pipeline/translate.md +++ b/prime-router/docs/universal-pipeline/translate.md @@ -38,7 +38,8 @@ The two kinds of transforms work the same at a high level. The schema enumerates - contains a FHIR path to the resource that needs to be transformed - a condition specifying whether the resource should be transformed -- how the resource should get transformed +- how the resource should get transformed; a resource can be transformed either by setting it to a value or applying a +FHIR function The primary difference between the FHIR and HL7 schemas is that the HL7 converter has special handling for converting a FHIR resource into an HL7 segment or component. diff --git a/prime-router/metadata/json_schema/fhir/fhir-to-fhir-transform.json b/prime-router/metadata/json_schema/fhir/fhir-to-fhir-transform.json index 3b94072dd9a..487f8fc46e7 100644 --- a/prime-router/metadata/json_schema/fhir/fhir-to-fhir-transform.json +++ b/prime-router/metadata/json_schema/fhir/fhir-to-fhir-transform.json @@ -67,6 +67,9 @@ "type": "string" } }, + "function": { + "type": "string" + }, "valueSet": { "anyOf": [ { diff --git a/prime-router/src/main/kotlin/config/validation/Validations.kt b/prime-router/src/main/kotlin/config/validation/Validations.kt index 1d7b0a41d43..5b32bcb4472 100644 --- a/prime-router/src/main/kotlin/config/validation/Validations.kt +++ b/prime-router/src/main/kotlin/config/validation/Validations.kt @@ -113,6 +113,9 @@ object FhirToFhirTransformValidation : KonformValidation() addConstraint("Invalid FHIR path: {value}", test = ::validFhirPath) } } + addConstraint("Value and function cannot both be set") { element -> + !(element.value != null && element.function != null) + } } } } diff --git a/prime-router/src/main/kotlin/fhirengine/translation/hl7/FhirTransformer.kt b/prime-router/src/main/kotlin/fhirengine/translation/hl7/FhirTransformer.kt index ecfe4571007..af338c1b338 100644 --- a/prime-router/src/main/kotlin/fhirengine/translation/hl7/FhirTransformer.kt +++ b/prime-router/src/main/kotlin/fhirengine/translation/hl7/FhirTransformer.kt @@ -114,9 +114,13 @@ class FhirTransformer( eligibleFocusResources.forEach { singleFocusResource -> elementContext.focusResource = singleFocusResource val value = getValue(element, bundle, singleFocusResource, elementContext) + val function = element.function + if (value != null && function != null) { + throw SchemaException("Element can only set function or value") + } + val bundleProperty = element.bundleProperty + ?: throw SchemaException("bundleProperty must be set for element ${element.name}") if (value != null) { - val bundleProperty = element.bundleProperty - ?: throw SchemaException("bundleProperty must be set for element ${element.name}") updateBundle( bundleProperty, value, @@ -124,9 +128,18 @@ class FhirTransformer( bundle, singleFocusResource ) + } else if (function != null) { + updateBundle( + bundleProperty, + function, + elementContext, + bundle, + singleFocusResource + ) } else { logger.warn( - "Element ${element.name} is updating a bundle property, but did not specify a value" + "Element ${element.name} is updating a bundle property," + + " but did not specify a value or function" ) } debugMsg += "condition: true, resourceType: ${singleFocusResource.fhirType()}, " + @@ -298,7 +311,32 @@ class FhirTransformer( focusResource, null ) - setBundleProperty(penultimateElements, lastElement, value) + setBundleProperty(penultimateElements, lastElement, value, context) + } + + /** + * Updates a bundle by setting a value at a specified spot + * + * @param bundleProperty the property to update + * @param function the function to apply to the bundle property + * @param context the context to evaluate the bundle under + * @param focusResource the focus resource for any FHIR path evaluations + */ + internal fun updateBundle( + bundleProperty: String, + function: String, + context: CustomContext, + bundle: Bundle, + focusResource: Base, + ) { + val (lastElement, penultimateElements) = createMissingElementsInBundleProperty( + bundleProperty, + context, + bundle, + focusResource, + null + ) + applyFunction(penultimateElements, lastElement, function, context, bundle) } /** @@ -350,7 +388,29 @@ class FhirTransformer( focusResource, appendToElements ) - setBundleProperty(bundlePenultimateElements, lastBundlePropertyElement, value) + setBundleProperty(bundlePenultimateElements, lastBundlePropertyElement, value, context) + } + + /** + * Updates a list of [Base] by applying the passed FHIR [function] + * + * @param elementsToUpdate the list of [Base] to update + * @param propertyName the property to set on each element + * @param function the function to apply + */ + private fun applyFunction( + elementsToUpdate: List, + propertyName: String, + function: String, + context: CustomContext, + bundle: Bundle, + ) { + elementsToUpdate.forEach { penultimateElement -> + val propertyInfo = extractChildProperty(propertyName, context, penultimateElement) + FhirPathUtils.evaluate( + context, penultimateElement, bundle, "%resource.${propertyInfo.propertyString}.$function" + ) + } } /** @@ -364,8 +424,13 @@ class FhirTransformer( elementsToUpdate: List, propertyName: String, value: Base, + context: CustomContext, ) { elementsToUpdate.forEach { penultimateElement -> + val propertyInfo = extractChildProperty(propertyName, context, penultimateElement) + if (propertyInfo.index != null) { + throw SchemaException("Schema is attempting to set a value for a particular index which is not allowed") + } val property = penultimateElement.getNamedProperty(propertyName) val newValue = FhirBundleUtils.convertFhirType(value, value.fhirType(), property.typeCode, logger) penultimateElement.setProperty(propertyName, newValue.copy()) diff --git a/prime-router/src/main/kotlin/fhirengine/translation/hl7/schema/fhirTransform/FhirTransformSchema.kt b/prime-router/src/main/kotlin/fhirengine/translation/hl7/schema/fhirTransform/FhirTransformSchema.kt index 929b22d0a22..991acbb0b99 100644 --- a/prime-router/src/main/kotlin/fhirengine/translation/hl7/schema/fhirTransform/FhirTransformSchema.kt +++ b/prime-router/src/main/kotlin/fhirengine/translation/hl7/schema/fhirTransform/FhirTransformSchema.kt @@ -74,6 +74,7 @@ class FhirTransformSchemaElement( debug: Boolean = false, var bundleProperty: String? = null, val appendToProperty: String? = null, + var function: String? = null, ) : ConfigSchemaElement( name = name, @@ -105,6 +106,7 @@ class FhirTransformSchemaElement( bundleProperty: String? = null, action: FhirTransformSchemaElementAction, appendToProperty: String? = null, + function: String? = null, ) : this( name, condition, @@ -118,7 +120,8 @@ class FhirTransformSchemaElement( valueSet, debug, bundleProperty, - appendToProperty + appendToProperty, + function ) { this.action = action } diff --git a/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/CustomFHIRFunctions.kt b/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/CustomFHIRFunctions.kt index e6986f75388..c6900d23a94 100644 --- a/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/CustomFHIRFunctions.kt +++ b/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/CustomFHIRFunctions.kt @@ -9,6 +9,7 @@ import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.BaseDateTimeType import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.StringType import java.time.DateTimeException @@ -37,6 +38,7 @@ object CustomFHIRFunctions : FhirPathFunctions { HasPhoneNumberExtension, ChangeTimezone, ConvertDateToAge, + DeidentifyHumanName, ; companion object { @@ -122,6 +124,14 @@ object CustomFHIRFunctions : FhirPathFunctions { ) } + CustomFHIRFunctionNames.DeidentifyHumanName -> { + FunctionDetails( + "removes PII from a name", + 0, + 1 + ) + } + else -> additionalFunctions?.resolveFunction(functionName) } } @@ -185,6 +195,10 @@ object CustomFHIRFunctions : FhirPathFunctions { convertDateToAge(focus, parameters) } + CustomFHIRFunctionNames.DeidentifyHumanName -> { + deidentifyHumanName(focus, parameters) + } + else -> additionalFunctions?.executeFunction(focus, functionName, parameters) ?: throw IllegalStateException("Tried to execute invalid FHIR Path function $functionName") } @@ -353,6 +367,22 @@ object CustomFHIRFunctions : FhirPathFunctions { return if (type != null) mutableListOf(StringType(type)) else mutableListOf() } + fun deidentifyHumanName(focus: MutableList, parameters: MutableList>?): MutableList { + val deidentifiedValue = parameters?.firstOrNull()?.filterIsInstance()?.firstOrNull()?.value ?: "" + focus.filterIsInstance().forEach { name -> + if (deidentifiedValue.isNotEmpty()) { + val updatedGiven = name.given.map { StringType(deidentifiedValue) } + name.setGiven(updatedGiven.toMutableList()) + } else { + name.setGiven(emptyList()) + } + + name.setFamily(deidentifiedValue) + } + + return focus + } + /** * Get the ID type for the value in [focus]. * @return a list with one value denoting the ID type, or an empty list diff --git a/prime-router/src/test/kotlin/fhirengine/translation/hl7/FhirTransformerTests.kt b/prime-router/src/test/kotlin/fhirengine/translation/hl7/FhirTransformerTests.kt index 8577dd4abd5..1829329a702 100644 --- a/prime-router/src/test/kotlin/fhirengine/translation/hl7/FhirTransformerTests.kt +++ b/prime-router/src/test/kotlin/fhirengine/translation/hl7/FhirTransformerTests.kt @@ -2,6 +2,7 @@ package gov.cdc.prime.router.fhirengine.translation.hl7 import assertk.assertFailure import assertk.assertThat +import assertk.assertions.contains import assertk.assertions.containsOnly import assertk.assertions.hasSize import assertk.assertions.isEmpty @@ -45,6 +46,7 @@ import org.hl7.fhir.r4.model.Observation import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.ServiceRequest import org.hl7.fhir.r4.model.StringType +import org.junit.jupiter.api.assertThrows import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -1240,6 +1242,92 @@ class FhirTransformerTests { assertThat(patient.name).hasSize(2) } + @Test + fun `test accessing by index while setting the actual bundle property`() { + val bundle = Bundle() + bundle.id = "abc123" + val patient = Patient() + val name = HumanName() + name.given = mutableListOf(StringType("foo"), StringType("bar")) + patient.name = mutableListOf(name) + patient.id = "def456" + val patientEntry = bundle.addEntry() + patientEntry.fullUrl = patient.id + patientEntry.resource = patient + + val updateFirstGivenNameSchemaElement = FhirTransformSchemaElement( + "update-first-given-name", + resource = "Bundle.entry.resource.ofType(Patient).name", + bundleProperty = "%resource.given[0]", + value = listOf("''") + ) + val schema = FhirTransformSchema(elements = mutableListOf(updateFirstGivenNameSchemaElement)) + + val transformer = FhirTransformer(schema) + val exception = assertThrows { + transformer.process(bundle) + } + assertThat(exception.message) + .contains("Schema is attempting to set a value for a particular index which is not allowed") + } + + @Test + fun `test deidentify human name`() { + val bundle = Bundle() + bundle.id = "abc123" + val patient = Patient() + val name = HumanName() + name.given = mutableListOf(StringType("foo"), StringType("bar")) + name.family = "family" + patient.name = mutableListOf(name) + patient.id = "def456" + val patientEntry = bundle.addEntry() + patientEntry.fullUrl = patient.id + patientEntry.resource = patient + + val updateFirstGivenNameSchemaElement = FhirTransformSchemaElement( + "update-first-given-name", + resource = "Bundle.entry.resource.ofType(Patient)", + bundleProperty = "%resource.name", + function = "deidentifyHumanName()", + ) + val schema = FhirTransformSchema(elements = mutableListOf(updateFirstGivenNameSchemaElement)) + + val transformer = FhirTransformer(schema) + transformer.process(bundle) + assertThat(name.given).isEmpty() + assertThat(name.family).isNull() + } + + @Test + fun `test deidentify human name with a value`() { + val bundle = Bundle() + bundle.id = "abc123" + val patient = Patient() + val name = HumanName() + name.given = mutableListOf(StringType("foo"), StringType("bar")) + name.family = "family" + patient.name = mutableListOf(name) + patient.id = "def456" + val patientEntry = bundle.addEntry() + patientEntry.fullUrl = patient.id + patientEntry.resource = patient + + val updateFirstGivenNameSchemaElement = FhirTransformSchemaElement( + "update-first-given-name", + resource = "Bundle.entry.resource.ofType(Patient)", + bundleProperty = "%resource.name", + function = "deidentifyHumanName('deidentified')", + ) + val schema = FhirTransformSchema(elements = mutableListOf(updateFirstGivenNameSchemaElement)) + + val transformer = FhirTransformer(schema) + transformer.process(bundle) + assertThat(name.given).transform { it -> it.map { st -> st.value } } + .containsOnly("deidentified", "deidentified") + assertThat(name.family).isEqualTo("deidentified") + } + @Test @Suppress("ktlint:standard:max-line-length") fun `test move Observation to ServiceRequest note`() { diff --git a/prime-router/src/test/kotlin/fhirengine/translation/hl7/utils/CustomFHIRFunctionsTests.kt b/prime-router/src/test/kotlin/fhirengine/translation/hl7/utils/CustomFHIRFunctionsTests.kt index e53c36c51e4..b81f4143a72 100644 --- a/prime-router/src/test/kotlin/fhirengine/translation/hl7/utils/CustomFHIRFunctionsTests.kt +++ b/prime-router/src/test/kotlin/fhirengine/translation/hl7/utils/CustomFHIRFunctionsTests.kt @@ -2,6 +2,7 @@ package gov.cdc.prime.router.fhirengine.translation.hl7.utils import assertk.assertFailure import assertk.assertThat +import assertk.assertions.containsOnly import assertk.assertions.doesNotHaveClass import assertk.assertions.hasClass import assertk.assertions.isEmpty @@ -15,6 +16,7 @@ import gov.cdc.prime.router.fhirengine.translation.hl7.SchemaException import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.BaseDateTimeType import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.InstantType import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.MessageHeader @@ -503,4 +505,29 @@ class CustomFHIRFunctionsTests { ) }.hasClass(SchemaException::class.java) } + + @Test + fun `test deidentifies a human name`() { + val name = HumanName() + name.given = mutableListOf(StringType("foo"), StringType("bar")) + name.family = "family" + + CustomFHIRFunctions.deidentifyHumanName(mutableListOf(name), mutableListOf()) + + assertThat(name.given).isEmpty() + assertThat(name.family).isNull() + + val name2 = HumanName() + name2.given = mutableListOf(StringType("foo"), StringType("bar")) + name2.family = "family" + + CustomFHIRFunctions.deidentifyHumanName( + mutableListOf(name2), + mutableListOf(mutableListOf(StringType("baz"))) + ) + + assertThat(name2.given).transform { it -> it.map { st -> st.value } } + .containsOnly("baz", "baz") + assertThat(name2.family).isEqualTo("baz") + } } \ No newline at end of file