1
0
mirror of https://github.com/pcvolkmer/etl-processor.git synced 2025-07-01 22:22:53 +00:00

33 Commits

Author SHA1 Message Date
5d0e815037 build: bump version 2023-12-29 17:27:21 +01:00
a5a19e0cea chore: update hapi-fhir dependency to 6.10.2
This mitigates CVE-2023-6378, CVE-2023-2976 and CVE-2020-8908
2023-12-29 17:27:17 +01:00
1493a63e02 chore: remove snakeyaml dependency version override
Spring Boot 3.2.1 uses newer version 2.2, so there is no need to
override dependency version.
2023-12-29 17:27:10 +01:00
fe927e65aa chore: remove explicit kafka dependency version
Spring Boot 3.6.1 uses Kafka 3.6.1 that mitigates
CVE-2023-34453, CVE-2023-34454, CVE-2023-34455, CVE-2023-43642
and new CVE-2023-44981 from version 3.6.0
2023-12-29 17:26:51 +01:00
add09c3f9c chore: update spring boot to version 3.2.1 2023-12-29 17:06:47 +01:00
5eb969c36a Bump version 2023-12-15 11:46:50 +01:00
3cc4f8c1a4 test: add tests to ensure patient id pseudonym
This uses fake MTBFile JSON as described here:
https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API
2023-12-14 12:56:36 +01:00
707bc55ab6 fix: Replace the patient's id in more places (#14)
This adds studyInclusionRequests and tumorMorphology.
2023-12-14 12:55:09 +01:00
d7949a7dce test: expect sorted data quality report issues 2023-12-05 14:34:51 +01:00
f5999ff325 test: expect 3 issues with different severity 2023-12-05 14:31:43 +01:00
a62da60809 feat: sort data quality report items by severity 2023-12-05 14:24:53 +01:00
ced6609d9a fix: add info severity to data quality report 2023-12-05 14:24:40 +01:00
8dee349c37 build: update to Spring Boot 3.2.0 2023-12-04 18:18:31 +01:00
3e45de56cf feat: add page that shows transformation configuration 2023-12-04 17:35:44 +01:00
7f54efe034 docs: remove notice on how to setup kafka 2023-12-04 16:11:33 +01:00
effcdd811f style: add colored table rows for requests 2023-12-04 16:11:02 +01:00
acf49a892e chore: update Kotlin and dependency management plugin 2023-12-04 14:37:58 +01:00
284806d130 chore: update Spring Boot to version 3.1.6 2023-11-25 14:36:53 +01:00
cf2d338e13 test: add integration test for mtb file transformation 2023-11-25 14:33:02 +01:00
d5552b3ca4 chore: Update Kotlin version to 1.9.20 2023-11-21 08:31:18 +01:00
892c0dea8f chore: Update Apache Kafka client library to version 3.6.0 2023-10-20 13:50:07 +02:00
0305e69e9e chore: Update Spring Boot to version 3.1.5 2023-10-20 13:49:38 +02:00
1a913b2644 Issue #12: Remove obsolete braces from transformation examples 2023-10-05 12:44:09 +02:00
0eee1908df Merge pull request #13 from CCC-MF/issue_12
Transformation of MTBFile data based on rules
2023-10-05 12:41:49 +02:00
ffea9343c8 Issue #12: Change README.md to show transformation config names as env var 2023-10-05 12:36:37 +02:00
eb24995ed9 Issue #12: Log transformation count applied on application start 2023-10-05 12:35:29 +02:00
4196664060 Issue #12: Transform MTBFile objects by using transformation rules 2023-10-05 12:09:56 +02:00
2824951e5e Issue #12: Add information about transformation rules in README.md 2023-10-05 11:45:42 +02:00
1e1db1c4d9 Issue #12: Add application config for transformation configuration 2023-10-05 11:37:10 +02:00
7440fe1e23 Issue #12: Basic implementation of transformation service 2023-10-05 10:51:49 +02:00
3f5c5e28fa chore: update Spring Boot dependencies 2023-09-26 09:27:21 +02:00
6397b2a019 chore: pump version to dev version snapshot 2023-09-26 09:27:21 +02:00
bf8f87b261 fix: removed gaps system from GPAS pseudonym value. Also added clean up method, which will replace filename invalid characters witch '_'. (#11) 2023-09-04 15:41:22 +02:00
23 changed files with 637 additions and 44 deletions

View File

@ -49,6 +49,20 @@ Wurde die Verwendung von gPAS konfiguriert, so sind weitere Angaben zu konfiguri
* `APP_PSEUDONYMIZE_GPAS_PASSWORD`: gPas Basic-Auth Passwort * `APP_PSEUDONYMIZE_GPAS_PASSWORD`: gPas Basic-Auth Passwort
* `APP_PSEUDONYMIZE_GPAS_SSLCALOCATION`: Root Zertifikat für gPas, falls es dediziert hinzugefügt werden muss. * `APP_PSEUDONYMIZE_GPAS_SSLCALOCATION`: Root Zertifikat für gPas, falls es dediziert hinzugefügt werden muss.
## Transformation von Werten
In Onkostar kann es vorkommen, dass ein Wert eines Merkmalskatalogs an einem Standort angepasst wurde und dadurch nicht dem Wert entspricht,
der vom bwHC-Backend akzeptiert wird.
Diese Anwendung bietet daher die Möglichkeit, eine Transformation vorzunehmen. Hierzu muss der "Pfad" innerhalb des JSON-MTB-Files angegeben werden und
welcher Wert wie ersetzt werden soll.
Hier ein Beispiel für die erste (Index 0 - weitere dann mit 1,2,...) Transformationsregel:
* `APP_TRANSFORMATIONS_0_PATH`: Pfad zum Wert in der JSON-MTB-Datei. Beispiel: `diagnoses[*].icd10.version` für **alle** Diagnosen
* `APP_TRANSFORMATIONS_0_FROM`: Angabe des Werts, der ersetzt werden soll. Andere Werte bleiben dabei unverändert.
* `APP_TRANSFORMATIONS_0_TO`: Angabe des neuen Werts.
## Mögliche Endpunkte ## Mögliche Endpunkte
Für REST-Requests als auch zur Nutzung von Kafka-Topics können Endpunkte konfiguriert werden. Für REST-Requests als auch zur Nutzung von Kafka-Topics können Endpunkte konfiguriert werden.

View File

@ -4,26 +4,22 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
plugins { plugins {
war war
id("org.springframework.boot") version "3.1.3" id("org.springframework.boot") version "3.2.1"
id("io.spring.dependency-management") version "1.1.3" id("io.spring.dependency-management") version "1.1.4"
kotlin("jvm") version "1.9.10" kotlin("jvm") version "1.9.21"
kotlin("plugin.spring") version "1.9.10" kotlin("plugin.spring") version "1.9.21"
} }
group = "de.ukw.ccc" group = "de.ukw.ccc"
version = "0.1.2" version = "0.3.1"
var versions = mapOf( var versions = mapOf(
"bwhc-dto-java" to "0.2.0", "bwhc-dto-java" to "0.2.0",
"hapi-fhir" to "6.6.2", "hapi-fhir" to "6.10.2",
"httpclient5" to "5.2.1", "httpclient5" to "5.2.1",
"mockito-kotlin" to "5.1.0" "mockito-kotlin" to "5.1.0"
) )
// Override Apache Kafka to be used
// Fixes: CVE-2023-34455, CVE-2023-34454, CVE-2023-34453
extra["kafka.version"] = "3.5.1"
java { java {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
} }
@ -60,8 +56,6 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jdbc") implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.kafka:spring-kafka") implementation("org.springframework.kafka:spring-kafka")
// fix CVE-2022-1471
implementation("org.yaml:snakeyaml:2.1")
implementation("org.flywaydb:flyway-mysql") implementation("org.flywaydb:flyway-mysql")
implementation("commons-codec:commons-codec") implementation("commons-codec:commons-codec")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
@ -69,6 +63,7 @@ dependencies {
implementation("ca.uhn.hapi.fhir:hapi-fhir-base:${versions["hapi-fhir"]}") implementation("ca.uhn.hapi.fhir:hapi-fhir-base:${versions["hapi-fhir"]}")
implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${versions["hapi-fhir"]}") implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${versions["hapi-fhir"]}")
implementation("org.apache.httpcomponents.client5:httpclient5:${versions["httpclient5"]}") implementation("org.apache.httpcomponents.client5:httpclient5:${versions["httpclient5"]}")
implementation("com.jayway.jsonpath:json-path")
runtimeOnly("org.mariadb.jdbc:mariadb-java-client") runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
runtimeOnly("org.postgresql:postgresql") runtimeOnly("org.postgresql:postgresql")
developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-devtools")

View File

@ -1,6 +1,4 @@
services: services:
# Note: Make sure, hostname "kafka" points to 127.0.0.1
# otherwise connection will not be available
kafka: kafka:
image: bitnami/kafka image: bitnami/kafka
hostname: kafka hostname: kafka

View File

@ -19,15 +19,27 @@
package dev.dnpm.etl.processor package dev.dnpm.etl.processor
import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.*
import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.output.MtbFileSender
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.kotlin.*
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationContext
import org.springframework.http.MediaType
import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.post
import org.testcontainers.junit.jupiter.Testcontainers import org.testcontainers.junit.jupiter.Testcontainers
@Testcontainers @Testcontainers
@ -42,4 +54,85 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
assertThat(context).isNotNull assertThat(context).isNotNull
} }
@Nested
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
@TestPropertySource(
properties = [
"app.transformations[0].path=diagnoses[*].icd10.version",
"app.transformations[0].from=2013",
"app.transformations[0].to=2014",
]
)
inner class TransformationTest {
@MockBean
private lateinit var mtbFileSender: MtbFileSender
@Autowired
private lateinit var mockMvc: MockMvc
@Autowired
private lateinit var objectMapper: ObjectMapper
@BeforeEach
fun setup(@Autowired requestRepository: RequestRepository) {
requestRepository.deleteAll()
}
@Test
fun mtbFileIsTransformed() {
doAnswer {
MtbFileSender.Response(RequestStatus.SUCCESS)
}.whenever(mtbFileSender).send(any<MtbFileSender.MtbFileRequest>())
val mtbFile = MtbFile.builder()
.withPatient(
Patient.builder()
.withId("TEST_12345678")
.withBirthDate("2000-08-08")
.withGender(Patient.Gender.MALE)
.build()
)
.withConsent(
Consent.builder()
.withId("1")
.withStatus(Consent.Status.ACTIVE)
.withPatient("TEST_12345678")
.build()
)
.withEpisode(
Episode.builder()
.withId("1")
.withPatient("TEST_12345678")
.withPeriod(PeriodStart("2023-08-08"))
.build()
)
.withDiagnoses(
listOf(
Diagnosis.builder()
.withId("1234")
.withIcd10(Icd10.builder().withCode("F79.9").withVersion("2013").build())
.build()
)
)
.build()
mockMvc.post("/mtbfile") {
content = objectMapper.writeValueAsString(mtbFile)
contentType = MediaType.APPLICATION_JSON
}.andExpect {
status {
isAccepted()
}
}
val captor = argumentCaptor<MtbFileSender.MtbFileRequest>()
verify(mtbFileSender).send(captor.capture())
assertThat(captor.firstValue.mtbFile.diagnoses).hasSize(1).allMatch { diagnosis ->
diagnosis.icd10.version == "2014"
}
}
}
} }

View File

@ -31,13 +31,13 @@ import org.springframework.beans.factory.NoSuchBeanDefinitionException
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.boot.test.mock.mockito.MockBeans
import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationContext
import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource import org.springframework.test.context.TestPropertySource
@SpringBootTest @SpringBootTest
@ContextConfiguration(classes = [KafkaAutoConfiguration::class, AppKafkaConfiguration::class, AppRestConfiguration::class]) @ContextConfiguration(classes = [AppConfiguration::class, KafkaAutoConfiguration::class, AppKafkaConfiguration::class, AppRestConfiguration::class])
@MockBean(ObjectMapper::class)
class AppConfigurationTest { class AppConfigurationTest {
@Nested @Nested
@ -65,10 +65,7 @@ class AppConfigurationTest {
"app.kafka.group-id=test" "app.kafka.group-id=test"
] ]
) )
@MockBeans(value = [ @MockBean(RequestRepository::class)
MockBean(ObjectMapper::class),
MockBean(RequestRepository::class)
])
inner class AppConfigurationKafkaTest(private val context: ApplicationContext) { inner class AppConfigurationKafkaTest(private val context: ApplicationContext) {
@Test @Test
@ -99,4 +96,24 @@ class AppConfigurationTest {
} }
@Nested
@TestPropertySource(
properties = [
"app.transformations[0].path=consent.status",
"app.transformations[0].from=rejected",
"app.transformations[0].to=accept",
]
)
inner class AppConfigurationTransformationTest(private val context: ApplicationContext) {
@Test
fun shouldRecognizeTransformations() {
val appConfigProperties = context.getBean(AppConfigProperties::class.java)
assertThat(appConfigProperties).isNotNull
assertThat(appConfigProperties.transformations).hasSize(1)
}
}
} }

View File

@ -127,7 +127,21 @@ public class GpasPseudonymGenerator implements Generator {
.orElseGet(ParametersParameterComponent::new).getValue(); .orElseGet(ParametersParameterComponent::new).getValue();
// pseudonym // pseudonym
return identifier.getSystem() + "|" + identifier.getValue(); return sanitizeValue(identifier.getValue());
}
/**
* Allow only filename friendly values
*
* @param psnValue GAPS pseudonym value
* @return cleaned up value
*/
public static String sanitizeValue(String psnValue) {
// pattern to match forbidden characters
String forbiddenCharsRegex = "[\\\\/:*?\"<>|;]";
// Replace all forbidden characters with underscores
return psnValue.replaceAll(forbiddenCharsRegex, "_");
} }

View File

@ -24,7 +24,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(AppConfigProperties.NAME) @ConfigurationProperties(AppConfigProperties.NAME)
data class AppConfigProperties( data class AppConfigProperties(
var bwhcUri: String?, var bwhcUri: String?,
var generator: PseudonymGenerator = PseudonymGenerator.BUILDIN var generator: PseudonymGenerator = PseudonymGenerator.BUILDIN,
var transformations: List<TransformationProperties> = listOf()
) { ) {
companion object { companion object {
const val NAME = "app" const val NAME = "app"
@ -78,4 +79,10 @@ data class KafkaTargetProperties(
enum class PseudonymGenerator { enum class PseudonymGenerator {
BUILDIN, BUILDIN,
GPAS GPAS
} }
data class TransformationProperties(
val path: String,
val from: String,
val to: String
)

View File

@ -25,6 +25,9 @@ import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
import dev.dnpm.etl.processor.pseudonym.Generator import dev.dnpm.etl.processor.pseudonym.Generator
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService import dev.dnpm.etl.processor.pseudonym.PseudonymizeService
import dev.dnpm.etl.processor.services.Transformation
import dev.dnpm.etl.processor.services.TransformationService
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
@ -41,6 +44,8 @@ import reactor.core.publisher.Sinks
) )
class AppConfiguration { class AppConfiguration {
private val logger = LoggerFactory.getLogger(AppConfiguration::class.java)
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS") @ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS")
@Bean @Bean
fun gpasPseudonymGenerator(configProperties: GPasConfigProperties): Generator { fun gpasPseudonymGenerator(configProperties: GPasConfigProperties): Generator {
@ -71,5 +76,16 @@ class AppConfiguration {
return Sinks.many().multicast().directBestEffort() return Sinks.many().multicast().directBestEffort()
} }
@Bean
fun transformationService(
objectMapper: ObjectMapper,
configProperties: AppConfigProperties
): TransformationService {
logger.info("Apply ${configProperties.transformations.size} transformation rules")
return TransformationService(objectMapper, configProperties.transformations.map {
Transformation.of(it.path) from it.from to it.to
})
}
} }

View File

@ -34,7 +34,10 @@ class ReportService(
return listOf() return listOf()
} }
return try { return try {
objectMapper.readValue(dataQualityReport, DataQualityReport::class.java).issues objectMapper
.readValue(dataQualityReport, DataQualityReport::class.java)
.issues
.sortedBy { it.severity }
} catch (e: Exception) { } catch (e: Exception) {
val otherIssue = val otherIssue =
Issue(Severity.ERROR, "Not parsable data quality report '$dataQualityReport'") Issue(Severity.ERROR, "Not parsable data quality report '$dataQualityReport'")
@ -56,5 +59,6 @@ class ReportService(
enum class Severity(@JsonValue val value: String) { enum class Severity(@JsonValue val value: String) {
ERROR("error"), ERROR("error"),
WARNING("warning"), WARNING("warning"),
INFO("info")
} }
} }

View File

@ -35,7 +35,10 @@ infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
this.familyMemberDiagnoses.forEach { it.patient = patientPseudonym } this.familyMemberDiagnoses.forEach { it.patient = patientPseudonym }
this.geneticCounsellingRequests.forEach { it.patient = patientPseudonym } this.geneticCounsellingRequests.forEach { it.patient = patientPseudonym }
this.histologyReevaluationRequests.forEach { it.patient = patientPseudonym } this.histologyReevaluationRequests.forEach { it.patient = patientPseudonym }
this.histologyReports.forEach { it.patient = patientPseudonym } this.histologyReports.forEach {
it.patient = patientPseudonym
it.tumorMorphology.patient = patientPseudonym
}
this.lastGuidelineTherapies.forEach { it.patient = patientPseudonym } this.lastGuidelineTherapies.forEach { it.patient = patientPseudonym }
this.molecularPathologyFindings.forEach { it.patient = patientPseudonym } this.molecularPathologyFindings.forEach { it.patient = patientPseudonym }
this.molecularTherapies.forEach { molecularTherapy -> molecularTherapy.history.forEach { it.patient = patientPseudonym } } this.molecularTherapies.forEach { molecularTherapy -> molecularTherapy.history.forEach { it.patient = patientPseudonym } }
@ -45,6 +48,6 @@ infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
this.recommendations.forEach { it.patient = patientPseudonym } this.recommendations.forEach { it.patient = patientPseudonym }
this.recommendations.forEach { it.patient = patientPseudonym } this.recommendations.forEach { it.patient = patientPseudonym }
this.responses.forEach { it.patient = patientPseudonym } this.responses.forEach { it.patient = patientPseudonym }
this.specimens.forEach { it.patient = patientPseudonym } this.studyInclusionRequests.forEach { it.patient = patientPseudonym }
this.specimens.forEach { it.patient = patientPseudonym } this.specimens.forEach { it.patient = patientPseudonym }
} }

View File

@ -38,6 +38,7 @@ import java.util.*
@Service @Service
class RequestProcessor( class RequestProcessor(
private val pseudonymizeService: PseudonymizeService, private val pseudonymizeService: PseudonymizeService,
private val transformationService: TransformationService,
private val sender: MtbFileSender, private val sender: MtbFileSender,
private val requestService: RequestService, private val requestService: RequestService,
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
@ -50,7 +51,7 @@ class RequestProcessor(
mtbFile pseudonymizeWith pseudonymizeService mtbFile pseudonymizeWith pseudonymizeService
val request = MtbFileSender.MtbFileRequest(requestId, mtbFile) val request = MtbFileSender.MtbFileRequest(requestId, transformationService.transform(mtbFile))
requestService.save( requestService.save(
Request( Request(

View File

@ -0,0 +1,85 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.services
import com.fasterxml.jackson.databind.ObjectMapper
import com.jayway.jsonpath.JsonPath
import com.jayway.jsonpath.PathNotFoundException
import de.ukw.ccc.bwhc.dto.MtbFile
class TransformationService(private val objectMapper: ObjectMapper, private val transformations: List<Transformation>) {
fun transform(mtbFile: MtbFile): MtbFile {
var json = objectMapper.writeValueAsString(mtbFile)
transformations.forEach { transformation ->
val jsonPath = JsonPath.parse(json)
try {
val before = transformation.path.substringBeforeLast(".")
val last = transformation.path.substringAfterLast(".")
val existingValue = if (transformation.existingValue is Number) transformation.existingValue else transformation.existingValue.toString()
val newValue = if (transformation.newValue is Number) transformation.newValue else transformation.newValue.toString()
jsonPath.set("$.$before.[?]$last", newValue, {
it.item(HashMap::class.java)[last] == existingValue
})
} catch (e: PathNotFoundException) {
// Ignore
}
json = jsonPath.jsonString()
}
return objectMapper.readValue(json, MtbFile::class.java)
}
fun getTransformations(): List<Transformation> {
return this.transformations
}
}
class Transformation private constructor(val path: String) {
lateinit var existingValue: Any
private set
lateinit var newValue: Any
private set
infix fun from(value: Any): Transformation {
this.existingValue = value
return this
}
infix fun to(value: Any): Transformation {
this.newValue = value
return this
}
companion object {
fun of(path: String): Transformation {
return Transformation(path)
}
}
}

View File

@ -0,0 +1,41 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.services.TransformationService
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
@Controller
@RequestMapping(path = ["transformations"])
class TransformationController(
private val transformationService: TransformationService
) {
@GetMapping
fun index(model: Model): String {
model.addAttribute("transformations", transformationService.getTransformations())
return "transformations"
}
}

View File

@ -1,3 +1,24 @@
:root {
--table-border: rgba(96, 96, 96, 1);
--bg-blue: rgb(0, 74, 157);
--bg-blue-op: rgba(0, 74, 157, .35);
--bg-green: rgb(0, 128, 0);
--bg-green-op: rgba(0, 128, 0, .35);
--bg-yellow: rgb(255, 140, 0);
--bg-yellow-op: rgba(255, 140, 0, .35);
--bg-red: rgb(255, 0, 0);
--bg-red-op: rgba(255, 0, 0, .35);
--bg-gray: rgb(112, 128, 144);
--bg-gray-op: rgba(112, 128, 144, .35);
}
body { body {
margin: 0; margin: 0;
font-family: sans-serif; font-family: sans-serif;
@ -57,7 +78,7 @@ nav > ul > li:first-of-type {
display: inline; display: inline;
} }
.breadcrumps ul li+li:before { .breadcrumps ul li + li:before {
padding: .4rem; padding: .4rem;
color: gray; color: gray;
content: "/\00a0"; content: "/\00a0";
@ -68,6 +89,10 @@ nav > ul > li:first-of-type {
text-decoration: none; text-decoration: none;
} }
.centered {
text-align: center;
}
main { main {
margin: 0 auto; margin: 0 auto;
max-width: 1140px; max-width: 1140px;
@ -115,8 +140,8 @@ form.samplecode-input input:focus-visible {
} }
table { table {
border-top: 1px solid lightgray; border-top: 1px solid var(--table-border);
border-left: 1px solid lightgray; border-left: 1px solid var(--table-border);
border-spacing: 0; border-spacing: 0;
border-radius: 3px; border-radius: 3px;
@ -145,10 +170,10 @@ th {
} }
td, th { td, th {
padding: .2rem; padding: 0.4rem .2rem;
border-right: 1px solid lightgray; border-right: 1px solid var(--table-border);
border-bottom: 1px solid lightgray; border-bottom: 1px solid var(--table-border);
text-align: left; text-align: left;
white-space: nowrap; white-space: nowrap;
@ -159,26 +184,66 @@ td {
font-family: monospace; font-family: monospace;
} }
td.bg-green, th.bg-green { td.bg-blue, th.bg-blue {
background: green; background: var(--bg-blue);
color: white; color: white;
} }
tr:has(td.bg-blue) {
background: var(--bg-blue-op);
}
td.bg-green, th.bg-green {
background: var(--bg-green);
color: white;
}
tr:has(td.bg-green) {
background: var(--bg-green-op);
}
td.bg-yellow, th.bg-yellow { td.bg-yellow, th.bg-yellow {
background: darkorange; background: var(--bg-yellow);
color: white; color: white;
} }
tr:has(td.bg-yellow) {
background: var(--bg-yellow-op);
}
td.bg-red, th.bg-red { td.bg-red, th.bg-red {
background: red; background: var(--bg-red);
color: white; color: white;
} }
tr:has(td.bg-red) {
background: var(--bg-red-op);
}
td.bg-gray, th.bg-gray { td.bg-gray, th.bg-gray {
background: slategray; background: var(--bg-gray);
color: white; color: white;
} }
.bg-path {
background: var(--bg-gray-op);
}
.bg-from {
background: var(--bg-red-op);
}
.bg-to {
background: var(--bg-green-op);
}
.bg-path, .bg-from, .bg-to {
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-family: monospace;
}
td.bg-shaded, th.bg-shaded { td.bg-shaded, th.bg-shaded {
background: repeating-linear-gradient(140deg, white, #e5e5f5 4px, white 8px); background: repeating-linear-gradient(140deg, white, #e5e5f5 4px, white 8px);
} }
@ -279,7 +344,7 @@ input.inline:focus-visible {
padding: 1rem; padding: 1rem;
margin: .2rem; margin: .2rem;
border: 1px solid lightgray; border: 1px solid var(--table-border);
border-radius: 3px; border-radius: 3px;
width: calc(100% - 2.4rem - 4px); width: calc(100% - 2.4rem - 4px);

View File

@ -10,6 +10,7 @@
<ul> <ul>
<li><a th:href="@{/}">Übersicht</a></li> <li><a th:href="@{/}">Übersicht</a></li>
<li><a th:href="@{/statistics}">Statistiken</a></li> <li><a th:href="@{/statistics}">Statistiken</a></li>
<li><a th:href="@{/transformations}">Transformationen</a></li>
</ul> </ul>
</nav> </nav>
</div> </div>

View File

@ -45,6 +45,7 @@
</thead> </thead>
<tbody> <tbody>
<tr th:each="issue : ${issues}"> <tr th:each="issue : ${issues}">
<td th:if="${issue.severity.value == 'info'}" class="bg-blue"><small>[[ ${issue.severity} ]]</small></td>
<td th:if="${issue.severity.value == 'warning'}" class="bg-yellow"><small>[[ ${issue.severity} ]]</small></td> <td th:if="${issue.severity.value == 'warning'}" class="bg-yellow"><small>[[ ${issue.severity} ]]</small></td>
<td th:if="${issue.severity.value == 'error'}" class="bg-red"><small>[[ ${issue.severity} ]]</small></td> <td th:if="${issue.severity.value == 'error'}" class="bg-red"><small>[[ ${issue.severity} ]]</small></td>
<td>[[ ${issue.message} ]]</td> <td>[[ ${issue.message} ]]</td>

View File

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="de" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ETL-Prozessor</title>
<link rel="stylesheet" th:href="@{/style.css}" />
</head>
<body>
<div th:replace="~{fragments.html :: nav}"></div>
<main>
<h1>Transformationen</h1>
<h2>Syntax</h2>
Hier einige Beispiele zum Syntax des JSON-Path
<ul>
<li style="padding: 0.6rem 0;"><span class="bg-path">diagnoses[*].icdO3T.version</span>: Ersetze die ICD-O3T-Version in allen Diagnosen, z.B. zur Version der deutschen Übersetzung</li>
<li style="padding: 0.6rem 0;"><span class="bg-path">patient.gender</span>: Ersetze das Geschlecht des Patienten, z.B. in das von bwHC verlangte Format</li>
</ul>
<h2>Konfigurierte Transformationen</h2>
<p>
Hier sehen Sie eine Übersicht der konfigurierten Transformationen.
</p>
<table>
<thead>
<tr>
<th>JSON-Path</th>
<th>Transformation von &rArr; nach</th>
</tr>
</thead>
<tbody>
<tr th:each="transformation : ${transformations}">
<td>
<span class="bg-path" title="Ersetze Wert(e) an dieser Stelle im MTB-File">[[ ${transformation.path} ]]</span>
</td>
<td>
<span class="bg-from" title="Ersetze immer dann, wenn dieser Wert enthalten ist">[[ ${transformation.existingValue} ]]</span>
<strong>&rArr;</strong>
<span class="bg-to" title="Ersetze durch diesen Wert">[[ ${transformation.newValue} ]]</span>
</td>
</tr>
</tbody>
</table>
</main>
</body>
</html>

View File

@ -0,0 +1,64 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.pseudonym
import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.MtbFile
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.ArgumentMatchers
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.whenever
import org.springframework.core.io.ClassPathResource
const val FAKE_MTB_FILE_PATH = "fake_MTBFile.json"
const val CLEAN_PATIENT_ID = "5dad2f0b-49c6-47d8-a952-7b9e9e0f7549"
@ExtendWith(MockitoExtension::class)
class ExtensionsTest {
private fun fakeMtbFile(): MtbFile {
val mtbFile = ClassPathResource(FAKE_MTB_FILE_PATH).inputStream
return ObjectMapper().readValue(mtbFile, MtbFile::class.java)
}
private fun MtbFile.serialized(): String {
return ObjectMapper().writeValueAsString(this)
}
@Test
fun shouldNotContainCleanPatientId(@Mock pseudonymizeService: PseudonymizeService) {
doAnswer {
it.arguments[0]
"PSEUDO-ID"
}.whenever(pseudonymizeService).patientPseudonym(ArgumentMatchers.anyString())
val mtbFile = fakeMtbFile()
mtbFile.pseudonymizeWith(pseudonymizeService)
assertThat(mtbFile.patient.id).isEqualTo("PSEUDO-ID")
assertThat(mtbFile.serialized()).doesNotContain(CLEAN_PATIENT_ID)
}
}

View File

@ -70,6 +70,13 @@ class PseudonymizeServiceTest {
assertThat(mtbFile.patient.id).isEqualTo("123") assertThat(mtbFile.patient.id).isEqualTo("123")
} }
@Test
fun sanitizeFileName(@Mock generator: GpasPseudonymGenerator) {
val result= GpasPseudonymGenerator.sanitizeValue("l://a\\bs;1*2?3>")
assertThat(result).isEqualTo("l___a_bs_1_2_3_")
}
@Test @Test
fun shouldUsePseudonymPrefixForBuiltin(@Mock generator: AnonymizingGenerator) { fun shouldUsePseudonymPrefixForBuiltin(@Mock generator: AnonymizingGenerator) {
doAnswer { doAnswer {

View File

@ -41,6 +41,7 @@ class ReportServiceTest {
{ {
"patient": "4711", "patient": "4711",
"issues": [ "issues": [
{ "severity": "info", "message": "Info Message" },
{ "severity": "warning", "message": "Warning Message" }, { "severity": "warning", "message": "Warning Message" },
{ "severity": "error", "message": "Error Message" } { "severity": "error", "message": "Error Message" }
] ]
@ -49,11 +50,13 @@ class ReportServiceTest {
val actual = this.reportService.deserialize(json) val actual = this.reportService.deserialize(json)
assertThat(actual).hasSize(2) assertThat(actual).hasSize(3)
assertThat(actual[0].severity).isEqualTo(ReportService.Severity.WARNING) assertThat(actual[0].severity).isEqualTo(ReportService.Severity.ERROR)
assertThat(actual[0].message).isEqualTo("Warning Message") assertThat(actual[0].message).isEqualTo("Error Message")
assertThat(actual[1].severity).isEqualTo(ReportService.Severity.ERROR) assertThat(actual[1].severity).isEqualTo(ReportService.Severity.WARNING)
assertThat(actual[1].message).isEqualTo("Error Message") assertThat(actual[1].message).isEqualTo("Warning Message")
assertThat(actual[2].severity).isEqualTo(ReportService.Severity.INFO)
assertThat(actual[2].message).isEqualTo("Info Message")
} }
@Test @Test

View File

@ -37,6 +37,7 @@ import org.mockito.Mockito.*
import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.whenever
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import java.time.Instant import java.time.Instant
import java.util.* import java.util.*
@ -46,6 +47,7 @@ import java.util.*
class RequestProcessorTest { class RequestProcessorTest {
private lateinit var pseudonymizeService: PseudonymizeService private lateinit var pseudonymizeService: PseudonymizeService
private lateinit var transformationService: TransformationService
private lateinit var sender: MtbFileSender private lateinit var sender: MtbFileSender
private lateinit var requestService: RequestService private lateinit var requestService: RequestService
private lateinit var applicationEventPublisher: ApplicationEventPublisher private lateinit var applicationEventPublisher: ApplicationEventPublisher
@ -55,11 +57,13 @@ class RequestProcessorTest {
@BeforeEach @BeforeEach
fun setup( fun setup(
@Mock pseudonymizeService: PseudonymizeService, @Mock pseudonymizeService: PseudonymizeService,
@Mock transformationService: TransformationService,
@Mock sender: RestMtbFileSender, @Mock sender: RestMtbFileSender,
@Mock requestService: RequestService, @Mock requestService: RequestService,
@Mock applicationEventPublisher: ApplicationEventPublisher @Mock applicationEventPublisher: ApplicationEventPublisher
) { ) {
this.pseudonymizeService = pseudonymizeService this.pseudonymizeService = pseudonymizeService
this.transformationService = transformationService
this.sender = sender this.sender = sender
this.requestService = requestService this.requestService = requestService
this.applicationEventPublisher = applicationEventPublisher this.applicationEventPublisher = applicationEventPublisher
@ -68,6 +72,7 @@ class RequestProcessorTest {
requestProcessor = RequestProcessor( requestProcessor = RequestProcessor(
pseudonymizeService, pseudonymizeService,
transformationService,
sender, sender,
requestService, requestService,
objectMapper, objectMapper,
@ -98,6 +103,10 @@ class RequestProcessorTest {
it.arguments[0] as String it.arguments[0] as String
}.`when`(pseudonymizeService).patientPseudonym(any()) }.`when`(pseudonymizeService).patientPseudonym(any())
doAnswer {
it.arguments[0]
}.whenever(transformationService).transform(any())
val mtbFile = MtbFile.builder() val mtbFile = MtbFile.builder()
.withPatient( .withPatient(
Patient.builder() Patient.builder()
@ -153,6 +162,10 @@ class RequestProcessorTest {
it.arguments[0] as String it.arguments[0] as String
}.`when`(pseudonymizeService).patientPseudonym(any()) }.`when`(pseudonymizeService).patientPseudonym(any())
doAnswer {
it.arguments[0]
}.whenever(transformationService).transform(any())
val mtbFile = MtbFile.builder() val mtbFile = MtbFile.builder()
.withPatient( .withPatient(
Patient.builder() Patient.builder()
@ -212,6 +225,10 @@ class RequestProcessorTest {
it.arguments[0] as String it.arguments[0] as String
}.`when`(pseudonymizeService).patientPseudonym(any()) }.`when`(pseudonymizeService).patientPseudonym(any())
doAnswer {
it.arguments[0]
}.whenever(transformationService).transform(any())
val mtbFile = MtbFile.builder() val mtbFile = MtbFile.builder()
.withPatient( .withPatient(
Patient.builder() Patient.builder()
@ -271,6 +288,10 @@ class RequestProcessorTest {
it.arguments[0] as String it.arguments[0] as String
}.`when`(pseudonymizeService).patientPseudonym(any()) }.`when`(pseudonymizeService).patientPseudonym(any())
doAnswer {
it.arguments[0]
}.whenever(transformationService).transform(any())
val mtbFile = MtbFile.builder() val mtbFile = MtbFile.builder()
.withPatient( .withPatient(
Patient.builder() Patient.builder()

View File

@ -0,0 +1,95 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.services
import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.Consent
import de.ukw.ccc.bwhc.dto.Diagnosis
import de.ukw.ccc.bwhc.dto.Icd10
import de.ukw.ccc.bwhc.dto.MtbFile
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class TransformationServiceTest {
private lateinit var service: TransformationService
@BeforeEach
fun setup() {
this.service = TransformationService(
ObjectMapper(), listOf(
Transformation.of("consent.status") from Consent.Status.ACTIVE to Consent.Status.REJECTED,
Transformation.of("diagnoses[*].icd10.version") from "2013" to "2014",
)
)
}
@Test
fun shouldTransformMtbFile() {
val mtbFile = MtbFile.builder().withDiagnoses(
listOf(
Diagnosis.builder().withId("1234").withIcd10(Icd10("F79.9").also {
it.version = "2013"
}).build()
)
).build()
val actual = this.service.transform(mtbFile)
assertThat(actual).isNotNull
assertThat(actual.diagnoses[0].icd10.version).isEqualTo("2014")
}
@Test
fun shouldOnlyTransformGivenValues() {
val mtbFile = MtbFile.builder().withDiagnoses(
listOf(
Diagnosis.builder().withId("1234").withIcd10(Icd10("F79.9").also {
it.version = "2013"
}).build(),
Diagnosis.builder().withId("5678").withIcd10(Icd10("F79.8").also {
it.version = "2019"
}).build()
)
).build()
val actual = this.service.transform(mtbFile)
assertThat(actual).isNotNull
assertThat(actual.diagnoses[0].icd10.code).isEqualTo("F79.9")
assertThat(actual.diagnoses[0].icd10.version).isEqualTo("2014")
assertThat(actual.diagnoses[1].icd10.code).isEqualTo("F79.8")
assertThat(actual.diagnoses[1].icd10.version).isEqualTo("2019")
}
@Test
fun shouldTransformMtbFileWithConsentEnum() {
val mtbFile = MtbFile.builder().withConsent(
Consent("123", "456", Consent.Status.ACTIVE)
).build()
val actual = this.service.transform(mtbFile)
assertThat(actual.consent).isNotNull
assertThat(actual.consent.status).isEqualTo(Consent.Status.REJECTED)
}
}

File diff suppressed because one or more lines are too long