From 4a7030e85b123ecd405a049c979ac5f2b195618a Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 1 Nov 2024 13:27:31 +0100 Subject: [PATCH 01/54] chore: update to Spring Boot 3.3.2 (#76) --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 2207e81..0ca4177 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage plugins { war - id("org.springframework.boot") version "3.3.1" + id("org.springframework.boot") version "3.3.2" id("io.spring.dependency-management") version "1.1.5" kotlin("jvm") version "1.9.24" kotlin("plugin.spring") version "1.9.24" From efa736f232e2e5efbde373099ce0051e6795503c Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 1 Nov 2024 13:42:20 +0100 Subject: [PATCH 02/54] chore: update to Spring Boot 3.3.3 (#77) --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 0ca4177..b44950f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage plugins { war - id("org.springframework.boot") version "3.3.2" + id("org.springframework.boot") version "3.3.3" id("io.spring.dependency-management") version "1.1.5" kotlin("jvm") version "1.9.24" kotlin("plugin.spring") version "1.9.24" From eb49ba611b00e4eee2c761c5fbe0cd7b354506be Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 1 Nov 2024 13:51:06 +0100 Subject: [PATCH 03/54] chore: update to Spring Boot 3.3.4 (#78) --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index b44950f..fb55def 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage plugins { war - id("org.springframework.boot") version "3.3.3" + id("org.springframework.boot") version "3.3.4" id("io.spring.dependency-management") version "1.1.5" kotlin("jvm") version "1.9.24" kotlin("plugin.spring") version "1.9.24" From d258d9081b1ecdd4f6cc51c55ae25c1e2e057423 Mon Sep 17 00:00:00 2001 From: jlidke <67630067+jlidke@users.noreply.github.com> Date: Fri, 1 Nov 2024 13:54:40 +0100 Subject: [PATCH 04/54] chore: gPas health check, fetch metadata instead of send invalid gPas request (#73) --- .../etl/processor/monitoring/ConnectionCheckService.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt index 1afaa32..e70da3e 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt @@ -162,11 +162,8 @@ class GPasConnectionCheckService( fun check() { result = try { val uri = UriComponentsBuilder.fromUriString( - gPasConfigProperties.uri?.replace("/\$pseudonymizeAllowCreate", "/\$pseudonymize").toString() - ) - .queryParam("target", gPasConfigProperties.target) - .queryParam("original", "???") - .build().toUri() + gPasConfigProperties.uri?.replace("/\$pseudonymizeAllowCreate", "/metadata").toString() + ).build().toUri() val headers = HttpHeaders() headers.contentType = MediaType.APPLICATION_JSON From 6cdbd35e644727bc01e2e81d5deab82750b463cc Mon Sep 17 00:00:00 2001 From: Niklas Date: Fri, 1 Nov 2024 13:56:54 +0100 Subject: [PATCH 05/54] feat: Allow configuring basic auth for the rest uri (#75) --- README.md | 2 ++ deploy/docker-compose.yaml | 2 ++ deploy/env-sample.env | 2 ++ .../processor/config/AppConfigProperties.kt | 2 ++ .../etl/processor/output/RestMtbFileSender.kt | 20 +++++++++++++++---- .../processor/output/RestMtbFileSenderTest.kt | 6 +++--- 6 files changed, 27 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 258c13b..45efef9 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,8 @@ Werden sowohl REST als auch Kafka-Endpunkt konfiguriert, wird nur der REST-Endpu Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an das bwHC-Backend gesendet wird: * `APP_REST_URI`: URI der zu benutzenden API der bwHC-Backend-Instanz. z.B.: `http://localhost:9000/bwhc/etl/api` +* `APP_REST_USERNAME`: Basic-Auth-Benutzername für bwHC-Backend +* `APP_REST_PASSWORD`: Basic-Auth-Passwort für bwHC-Backend #### Kafka-Topics diff --git a/deploy/docker-compose.yaml b/deploy/docker-compose.yaml index 4641ca6..2180786 100644 --- a/deploy/docker-compose.yaml +++ b/deploy/docker-compose.yaml @@ -18,6 +18,8 @@ services: APP_KAFKA_GROUP_ID: ${DNPM_KAFKA_GROUP_ID} APP_KAFKA_RESPONSE_TOPIC: ${DNPM_KAFKA_RESPONSE_TOPIC} APP_REST_URI: ${DNPM_BWHC_REST_URI} + APP_REST_USERNAME: ${DNPM_BWHC_REST_USERNAME} + APP_REST_PASSWORD: ${DNPM_BWHC_REST_PASSWORD} APP_SECURITY_ADMIN_USER: ${DNPM_ADMIN_USER} APP_SECURITY_ADMIN_PASSWORD: ${DNPM_ADMIN_PASSWORD} SPRING_DATASOURCE_URL: ${DNPM_DATASOURCE_URL} diff --git a/deploy/env-sample.env b/deploy/env-sample.env index 04a3f8f..9c06341 100644 --- a/deploy/env-sample.env +++ b/deploy/env-sample.env @@ -28,6 +28,8 @@ DNPM_DATASOURCE_URL=jdbc:mariadb://dnpm-monitor-db:3306/$DNPM_MARIADB_DB ## TARGET SYSTEMS CONFIG # in case of direct access to bwhc enter endpoint url here DNPM_BWHC_REST_URI= +DNPM_BWHC_REST_USERNAME= +DNPM_BWHC_REST_PASSWORD= # produce mtb files to this topic - values 'false' disabling kafka processing DNPM_KAFKA_TOPIC=false diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt index d951c60..dd7e461 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt @@ -69,6 +69,8 @@ data class GPasConfigProperties( @ConfigurationProperties(RestTargetProperties.NAME) data class RestTargetProperties( val uri: String?, + val username: String?, + val password: String?, ) { companion object { const val NAME = "app.rest" diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt index e1aecb7..58459b9 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt @@ -40,8 +40,7 @@ class RestMtbFileSender( override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response { try { return retryTemplate.execute { - val headers = HttpHeaders() - headers.contentType = MediaType.APPLICATION_JSON + val headers = getHttpHeaders() val entityReq = HttpEntity(request.mtbFile, headers) val response = restTemplate.postForEntity( "${restTargetProperties.uri}/MTBFile", @@ -70,8 +69,7 @@ class RestMtbFileSender( override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response { try { return retryTemplate.execute { - val headers = HttpHeaders() - headers.contentType = MediaType.APPLICATION_JSON + val headers = getHttpHeaders() val entityReq = HttpEntity(null, headers) restTemplate.delete( "${restTargetProperties.uri}/Patient/${request.patientId}", @@ -94,4 +92,18 @@ class RestMtbFileSender( return this.restTargetProperties.uri.orEmpty() } + private fun getHttpHeaders(): HttpHeaders { + val username = restTargetProperties.username + val password = restTargetProperties.password + val headers = HttpHeaders() + headers.setContentType(MediaType.APPLICATION_JSON) + + if (username.isNullOrBlank() || password.isNullOrBlank()) { + return headers + } + + headers.setBasicAuth(username, password) + return headers + } + } \ No newline at end of file diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt index 9cc1437..9b6332a 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt @@ -48,7 +48,7 @@ class RestMtbFileSenderTest { @BeforeEach fun setup() { val restTemplate = RestTemplate() - val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile") + val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null) val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) @@ -90,7 +90,7 @@ class RestMtbFileSenderTest { @MethodSource("mtbFileRequestWithResponseSource") fun shouldRetryOnMtbFileHttpRequestError(requestWithResponse: RequestWithResponse) { val restTemplate = RestTemplate() - val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile") + val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null) val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) @@ -119,7 +119,7 @@ class RestMtbFileSenderTest { @MethodSource("deleteRequestWithResponseSource") fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) { val restTemplate = RestTemplate() - val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile") + val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null) val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) From 46ba565c296b1672882f403f8d9e9a34748051ea Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 1 Nov 2024 14:05:20 +0100 Subject: [PATCH 06/54] chore: update to Spring Boot 3.3.5 (#79) --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index fb55def..51050f4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,8 +5,8 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage plugins { war - id("org.springframework.boot") version "3.3.4" - id("io.spring.dependency-management") version "1.1.5" + id("org.springframework.boot") version "3.3.5" + id("io.spring.dependency-management") version "1.1.6" kotlin("jvm") version "1.9.24" kotlin("plugin.spring") version "1.9.24" jacoco From 557586763276a56f737b5ef98db3bc7494614bdd Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 1 Nov 2024 14:14:45 +0100 Subject: [PATCH 07/54] chore: update to Spring Boot 3.2.11 (#80) --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 497ee58..cc21ee0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage plugins { war - id("org.springframework.boot") version "3.2.7" + id("org.springframework.boot") version "3.2.11" id("io.spring.dependency-management") version "1.1.5" kotlin("jvm") version "1.9.24" kotlin("plugin.spring") version "1.9.24" From 2fc329954378131831bdd8a2caafbf5f818df910 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 1 Nov 2024 14:23:26 +0100 Subject: [PATCH 08/54] build: replace hard coded repo name with variable (#81) --- .github/workflows/deploy.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b703b73..65b5e78 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,6 +30,6 @@ jobs: - name: Execute image build and push run: | ./gradlew bootBuildImage - docker tag ghcr.io/ccc-mf/etl-processor ghcr.io/ccc-mf/etl-processor:${{ github.ref_name }} - docker push ghcr.io/ccc-mf/etl-processor - docker push ghcr.io/ccc-mf/etl-processor:${{ github.ref_name }} \ No newline at end of file + docker tag ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}:${{ github.ref_name }} + docker push ghcr.io/${{ github.repository }} + docker push ghcr.io/${{ github.repository }}:${{ github.ref_name }} \ No newline at end of file From 1bcc8c13defb8808ebbf33041d4f77bb14825af9 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 1 Nov 2024 14:40:11 +0100 Subject: [PATCH 09/54] build: change group name --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index cc21ee0..6a620b8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ plugins { kotlin("plugin.spring") version "1.9.24" } -group = "de.ukw.ccc" +group = "dev.dnpm" version = "0.9-SNAPSHOT" var versions = mapOf( From e95fa2fb1238f10db64930ab9b304f3148868769 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 1 Nov 2024 15:06:24 +0100 Subject: [PATCH 10/54] build: change BP_OCI_SOURCE --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 6a620b8..011ade4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -123,7 +123,7 @@ tasks.named("bootBuildImage") { environment.set(environment.get() + mapOf( // Enable this line to embed CA Certs into image on build time //"BP_EMBED_CERTS" to "true", - "BP_OCI_SOURCE" to "https://github.com/CCC-MF/etl-processor", + "BP_OCI_SOURCE" to "https://github.com/pcvolkmer/etl-processor", "BP_OCI_LICENSES" to "AGPLv3", "BP_OCI_DESCRIPTION" to "ETL Processor for bwHC MTB files" )) From 8ce3aed870359991211a4870216b226e445192f4 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 1 Nov 2024 15:38:47 +0100 Subject: [PATCH 11/54] docs: modify changed urls --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 45efef9..30b4ee8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ETL-Processor for bwHC data [![Run Tests](https://github.com/CCC-MF/etl-processor/actions/workflows/test.yml/badge.svg)](https://github.com/CCC-MF/etl-processor/actions/workflows/test.yml) +# ETL-Processor for bwHC data [![Run Tests](https://github.com/pcvolkmer/etl-processor/actions/workflows/test.yml/badge.svg)](https://github.com/pcvolkmer/etl-processor/actions/workflows/test.yml) Diese Anwendung versendet ein bwHC-MTB-File an das bwHC-Backend und pseudonymisiert die Patienten-ID. @@ -263,7 +263,7 @@ Dieses Vorgehen empfiehlt sich, wenn Sie gespeicherte Records nachgelagert für ## Docker-Images -Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/CCC-MF/etl-processor/pkgs/container/etl-processor +Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/pcvolkmer/etl-processor/pkgs/container/etl-processor ### Images lokal bauen From 2036077c064480bc517468d54f34c8596ca6868d Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sat, 2 Nov 2024 14:59:57 +0100 Subject: [PATCH 12/54] build: do not hard code version numbers in dependencies --- build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 4e6499b..e84886c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,6 +22,7 @@ var versions = mapOf( "mockito-kotlin" to "5.3.1", "archunit" to "1.3.0", // Webjars + "webjars-locator" to "0.52", "echarts" to "5.4.3", "htmx.org" to "1.9.12" ) @@ -74,7 +75,7 @@ dependencies { implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${versions["hapi-fhir"]}") implementation("org.apache.httpcomponents.client5:httpclient5") implementation("com.jayway.jsonpath:json-path") - implementation("org.webjars:webjars-locator:0.52") + implementation("org.webjars:webjars-locator:${versions["webjars-locator"]}") implementation("org.webjars.npm:echarts:${versions["echarts"]}") implementation("org.webjars.npm:htmx.org:${versions["htmx.org"]}") runtimeOnly("org.mariadb.jdbc:mariadb-java-client") From 3257493b6a3876f3780bd1c8aff7d2f3501cc537 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sat, 2 Nov 2024 15:03:15 +0100 Subject: [PATCH 13/54] build: update HAPI dependencies This also overrides 'commons-io' due to CVE-2024-47554 --- build.gradle.kts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index e84886c..d34c05a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,8 +17,9 @@ version = "0.10.0-SNAPSHOT" var versions = mapOf( "bwhc-dto-java" to "0.3.0", - "hapi-fhir" to "6.10.5", + "hapi-fhir" to "7.4.5", "commons-compress" to "1.26.2", + "commons-io" to "2.17.0", "mockito-kotlin" to "5.3.1", "archunit" to "1.3.0", // Webjars @@ -78,16 +79,24 @@ dependencies { implementation("org.webjars:webjars-locator:${versions["webjars-locator"]}") implementation("org.webjars.npm:echarts:${versions["echarts"]}") implementation("org.webjars.npm:htmx.org:${versions["htmx.org"]}") + // Override dependecy version from ca.uhn.hapi.fhir:hapi-fhir-base - CVE-2024-47554 + implementation("commons-io:commons-io:${versions["commons-io"]}") + runtimeOnly("org.mariadb.jdbc:mariadb-java-client") runtimeOnly("org.postgresql:postgresql") + developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-docker-compose") + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + providedRuntime("org.springframework.boot:spring-boot-starter-tomcat") + testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.security:spring-security-test") testImplementation("io.projectreactor:reactor-test") testImplementation("org.mockito.kotlin:mockito-kotlin:${versions["mockito-kotlin"]}") + integrationTestImplementation("org.testcontainers:junit-jupiter") integrationTestImplementation("org.testcontainers:postgresql") integrationTestImplementation("com.tngtech.archunit:archunit:${versions["archunit"]}") From 5ce13e962b8185a1d2702529a994f9510adca47c Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sat, 2 Nov 2024 15:03:43 +0100 Subject: [PATCH 14/54] build: add description and group to task --- build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index d34c05a..3eb7429 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -130,6 +130,8 @@ task("integrationTest") { } tasks.register("allTests") { + description = "Run all tests" + group = JavaBasePlugin.VERIFICATION_GROUP dependsOn(tasks.withType()) } From 53b4cf1a95266d27949402ce486d2d97b5be1edf Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Mon, 25 Nov 2024 21:02:55 +0100 Subject: [PATCH 15/54] chore: update dependencies --- build.gradle.kts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 3eb7429..b828715 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,10 +5,10 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage plugins { war - id("org.springframework.boot") version "3.3.5" + id("org.springframework.boot") version "3.3.6" id("io.spring.dependency-management") version "1.1.6" - kotlin("jvm") version "1.9.24" - kotlin("plugin.spring") version "1.9.24" + kotlin("jvm") version "1.9.25" + kotlin("plugin.spring") version "1.9.25" jacoco } @@ -17,7 +17,7 @@ version = "0.10.0-SNAPSHOT" var versions = mapOf( "bwhc-dto-java" to "0.3.0", - "hapi-fhir" to "7.4.5", + "hapi-fhir" to "7.6.0", "commons-compress" to "1.26.2", "commons-io" to "2.17.0", "mockito-kotlin" to "5.3.1", From 23cc2f365ab659121466b0e3c48daa3f68a5d5df Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 20 Dec 2024 10:45:16 +0100 Subject: [PATCH 16/54] chore: update dependencies --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index b828715..9daf1f4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage plugins { war - id("org.springframework.boot") version "3.3.6" + id("org.springframework.boot") version "3.3.7" id("io.spring.dependency-management") version "1.1.6" kotlin("jvm") version "1.9.25" kotlin("plugin.spring") version "1.9.25" From 74ff9f08a4e73243600e61e4176fc1e8f190c329 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Mon, 23 Dec 2024 18:16:37 +0100 Subject: [PATCH 17/54] chore: update to mockito-kotlin 5.4.0 With this change, `anyValueClass()` from mockito-kotlin replaces own implementation. --- build.gradle.kts | 2 +- src/test/kotlin/dev/dnpm/etl/processor/helpers.kt | 11 +---------- .../etl/processor/input/KafkaInputListenerTest.kt | 2 +- .../etl/processor/input/MtbFileRestControllerTest.kt | 2 +- .../dnpm/etl/processor/pseudonym/ExtensionsTest.kt | 2 +- .../etl/processor/services/RequestProcessorTest.kt | 1 + .../dnpm/etl/processor/services/RequestServiceTest.kt | 1 + 7 files changed, 7 insertions(+), 14 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 9daf1f4..e966448 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,7 +20,7 @@ var versions = mapOf( "hapi-fhir" to "7.6.0", "commons-compress" to "1.26.2", "commons-io" to "2.17.0", - "mockito-kotlin" to "5.3.1", + "mockito-kotlin" to "5.4.0", "archunit" to "1.3.0", // Webjars "webjars-locator" to "0.52", diff --git a/src/test/kotlin/dev/dnpm/etl/processor/helpers.kt b/src/test/kotlin/dev/dnpm/etl/processor/helpers.kt index 55d6327..8caa908 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/helpers.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/helpers.kt @@ -17,13 +17,4 @@ * along with this program. If not, see . */ -package dev.dnpm.etl.processor - -import org.mockito.ArgumentMatchers - -inline fun anyValueClass(): T { - val unboxedClass = T::class.java.declaredFields.first().type - return ArgumentMatchers.any(unboxedClass as Class) - ?: T::class.java.getDeclaredMethod("box-impl", unboxedClass) - .invoke(null, null) as T -} \ No newline at end of file +package dev.dnpm.etl.processor \ No newline at end of file diff --git a/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt index 7753dbc..b54a02e 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt @@ -23,7 +23,6 @@ import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.Patient -import dev.dnpm.etl.processor.anyValueClass import dev.dnpm.etl.processor.services.RequestProcessor import org.apache.kafka.clients.consumer.ConsumerRecord import org.apache.kafka.common.header.internals.RecordHeader @@ -35,6 +34,7 @@ import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any +import org.mockito.kotlin.anyValueClass import org.mockito.kotlin.times import org.mockito.kotlin.verify import java.util.* diff --git a/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt index f9fe3f3..3e5b53a 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt @@ -21,7 +21,6 @@ package dev.dnpm.etl.processor.input import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* -import dev.dnpm.etl.processor.anyValueClass import dev.dnpm.etl.processor.services.RequestProcessor import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -31,6 +30,7 @@ import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any +import org.mockito.kotlin.anyValueClass import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.delete diff --git a/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt index fbc26ae..0acf7db 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt @@ -21,13 +21,13 @@ package dev.dnpm.etl.processor.pseudonym import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* -import dev.dnpm.etl.processor.anyValueClass import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.anyValueClass import org.mockito.kotlin.doAnswer import org.mockito.kotlin.whenever import org.springframework.core.io.ClassPathResource diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt index 1c58d5d..5578c7b 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt @@ -37,6 +37,7 @@ import org.mockito.Mock import org.mockito.Mockito.* import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any +import org.mockito.kotlin.anyValueClass import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.whenever import org.springframework.context.ApplicationEventPublisher diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestServiceTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestServiceTest.kt index 2e289c5..c0e4400 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestServiceTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestServiceTest.kt @@ -31,6 +31,7 @@ import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock import org.mockito.Mockito.* import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.anyValueClass import org.mockito.kotlin.whenever import java.time.Instant From 1e652a7856c0e658e4fdd0941879fd3959aad26b Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sun, 9 Feb 2025 11:19:35 +0100 Subject: [PATCH 18/54] test: explicit request URI check and fix use of expect() --- .../processor/output/RestMtbFileSenderTest.kt | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt index 9b6332a..8a12186 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt @@ -59,12 +59,12 @@ class RestMtbFileSenderTest { @ParameterizedTest @MethodSource("deleteRequestWithResponseSource") fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) { - this.mockRestServiceServer.expect { - method(HttpMethod.DELETE) - requestTo("/mtbfile") - }.andRespond { - withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) - } + this.mockRestServiceServer + .expect(method(HttpMethod.DELETE)) + .andExpect(requestTo("http://localhost:9000/mtbfile/Patient/$TEST_PATIENT_PSEUDONYM")) + .andRespond { + withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + } val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) assertThat(response.status).isEqualTo(requestWithResponse.response.status) @@ -74,12 +74,12 @@ class RestMtbFileSenderTest { @ParameterizedTest @MethodSource("mtbFileRequestWithResponseSource") fun shouldReturnExpectedResponseForMtbFilePost(requestWithResponse: RequestWithResponse) { - this.mockRestServiceServer.expect { - method(HttpMethod.POST) - requestTo("/mtbfile") - }.andRespond { - withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) - } + this.mockRestServiceServer + .expect(method(HttpMethod.POST)) + .andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile")) + .andRespond { + withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + } val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile)) assertThat(response.status).isEqualTo(requestWithResponse.response.status) @@ -103,12 +103,12 @@ class RestMtbFileSenderTest { else -> ExpectedCount.max(3) } - this.mockRestServiceServer.expect(expectedCount) { - method(HttpMethod.POST) - requestTo("/mtbfile") - }.andRespond { - withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) - } + this.mockRestServiceServer + .expect(expectedCount, method(HttpMethod.POST)) + .andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile")) + .andRespond { + withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + } val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile)) assertThat(response.status).isEqualTo(requestWithResponse.response.status) @@ -132,12 +132,12 @@ class RestMtbFileSenderTest { else -> ExpectedCount.max(3) } - this.mockRestServiceServer.expect(expectedCount) { - method(HttpMethod.DELETE) - requestTo("/mtbfile") - }.andRespond { - withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) - } + this.mockRestServiceServer + .expect(expectedCount, method(HttpMethod.DELETE)) + .andExpect(requestTo("http://localhost:9000/mtbfile/Patient/$TEST_PATIENT_PSEUDONYM")) + .andRespond { + withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + } val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) assertThat(response.status).isEqualTo(requestWithResponse.response.status) From ff27b7157d4d032626e74bf154be5241261b92bd Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Mon, 10 Feb 2025 19:18:27 +0100 Subject: [PATCH 19/54] chore: update spring boot and dependency management plugin --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index e966448..a9aa746 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,8 +5,8 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage plugins { war - id("org.springframework.boot") version "3.3.7" - id("io.spring.dependency-management") version "1.1.6" + id("org.springframework.boot") version "3.3.8" + id("io.spring.dependency-management") version "1.1.7" kotlin("jvm") version "1.9.25" kotlin("plugin.spring") version "1.9.25" jacoco From b25e58011371d5271f4db240545208dd6c792ff0 Mon Sep 17 00:00:00 2001 From: Niklas Sombert Date: Thu, 6 Feb 2025 16:15:44 +0100 Subject: [PATCH 20/54] feat: Support POSTing data to dnpm:dip --- README.md | 1 + deploy/docker-compose.yaml | 1 + deploy/env-sample.env | 1 + .../processor/config/AppConfigProperties.kt | 1 + .../etl/processor/output/RestMtbFileSender.kt | 21 +++++++++++++++++-- .../processor/output/RestMtbFileSenderTest.kt | 14 ++++++------- 6 files changed, 30 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 30b4ee8..9fb0164 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an das * `APP_REST_URI`: URI der zu benutzenden API der bwHC-Backend-Instanz. z.B.: `http://localhost:9000/bwhc/etl/api` * `APP_REST_USERNAME`: Basic-Auth-Benutzername für bwHC-Backend * `APP_REST_PASSWORD`: Basic-Auth-Passwort für bwHC-Backend +* `APP_REST_IS_BWHC`: `true` für bwHC, weglassen oder `false` für dnpm:dip #### Kafka-Topics diff --git a/deploy/docker-compose.yaml b/deploy/docker-compose.yaml index 2180786..754bb23 100644 --- a/deploy/docker-compose.yaml +++ b/deploy/docker-compose.yaml @@ -20,6 +20,7 @@ services: APP_REST_URI: ${DNPM_BWHC_REST_URI} APP_REST_USERNAME: ${DNPM_BWHC_REST_USERNAME} APP_REST_PASSWORD: ${DNPM_BWHC_REST_PASSWORD} + APP_REST_IS_BWHC: ${DNPM_BWHC_REST_IS_BWHC} APP_SECURITY_ADMIN_USER: ${DNPM_ADMIN_USER} APP_SECURITY_ADMIN_PASSWORD: ${DNPM_ADMIN_PASSWORD} SPRING_DATASOURCE_URL: ${DNPM_DATASOURCE_URL} diff --git a/deploy/env-sample.env b/deploy/env-sample.env index 9c06341..4888474 100644 --- a/deploy/env-sample.env +++ b/deploy/env-sample.env @@ -30,6 +30,7 @@ DNPM_DATASOURCE_URL=jdbc:mariadb://dnpm-monitor-db:3306/$DNPM_MARIADB_DB DNPM_BWHC_REST_URI= DNPM_BWHC_REST_USERNAME= DNPM_BWHC_REST_PASSWORD= +DNPM_BWHC_REST_IS_BWHC=false # produce mtb files to this topic - values 'false' disabling kafka processing DNPM_KAFKA_TOPIC=false diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt index dd7e461..7c192c8 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt @@ -71,6 +71,7 @@ data class RestTargetProperties( val uri: String?, val username: String?, val password: String?, + val isBwhc: Boolean = false, ) { companion object { const val NAME = "app.rest" diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt index 58459b9..6dfe0eb 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt @@ -21,6 +21,7 @@ package dev.dnpm.etl.processor.output import dev.dnpm.etl.processor.config.RestTargetProperties import dev.dnpm.etl.processor.monitoring.RequestStatus +import dev.dnpm.etl.processor.PatientPseudonym import org.slf4j.LoggerFactory import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders @@ -37,13 +38,29 @@ class RestMtbFileSender( private val logger = LoggerFactory.getLogger(RestMtbFileSender::class.java) + fun sendUrl(): String { + return if(restTargetProperties.isBwhc) { + "${restTargetProperties.uri}/MTBFile" + } else { + "${restTargetProperties.uri}/patient-record" + } + } + + fun deleteUrl(patientId: PatientPseudonym): String { + return if(restTargetProperties.isBwhc) { + "${restTargetProperties.uri}/Patient/${patientId.value}" + } else { + "${restTargetProperties.uri}/patient/${patientId.value}" + } + } + override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response { try { return retryTemplate.execute { val headers = getHttpHeaders() val entityReq = HttpEntity(request.mtbFile, headers) val response = restTemplate.postForEntity( - "${restTargetProperties.uri}/MTBFile", + sendUrl(), entityReq, String::class.java ) @@ -72,7 +89,7 @@ class RestMtbFileSender( val headers = getHttpHeaders() val entityReq = HttpEntity(null, headers) restTemplate.delete( - "${restTargetProperties.uri}/Patient/${request.patientId}", + deleteUrl(request.patientId), entityReq, String::class.java ) diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt index 8a12186..b3a87b0 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt @@ -48,7 +48,7 @@ class RestMtbFileSenderTest { @BeforeEach fun setup() { val restTemplate = RestTemplate() - val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null) + val restTargetProperties = RestTargetProperties("http://localhost:9000/", null, null, false) val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) @@ -61,7 +61,7 @@ class RestMtbFileSenderTest { fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) { this.mockRestServiceServer .expect(method(HttpMethod.DELETE)) - .andExpect(requestTo("http://localhost:9000/mtbfile/Patient/$TEST_PATIENT_PSEUDONYM")) + .andExpect(requestTo("http://localhost:9000/patient/$TEST_PATIENT_PSEUDONYM")) .andRespond { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } @@ -76,7 +76,7 @@ class RestMtbFileSenderTest { fun shouldReturnExpectedResponseForMtbFilePost(requestWithResponse: RequestWithResponse) { this.mockRestServiceServer .expect(method(HttpMethod.POST)) - .andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile")) + .andExpect(requestTo("http://localhost:9000/patient-record")) .andRespond { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } @@ -90,7 +90,7 @@ class RestMtbFileSenderTest { @MethodSource("mtbFileRequestWithResponseSource") fun shouldRetryOnMtbFileHttpRequestError(requestWithResponse: RequestWithResponse) { val restTemplate = RestTemplate() - val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null) + val restTargetProperties = RestTargetProperties("http://localhost:9000/", null, null, false) val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) @@ -105,7 +105,7 @@ class RestMtbFileSenderTest { this.mockRestServiceServer .expect(expectedCount, method(HttpMethod.POST)) - .andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile")) + .andExpect(requestTo("http://localhost:9000/patient-record")) .andRespond { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } @@ -119,7 +119,7 @@ class RestMtbFileSenderTest { @MethodSource("deleteRequestWithResponseSource") fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) { val restTemplate = RestTemplate() - val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null) + val restTargetProperties = RestTargetProperties("http://localhost:9000/", null, null, false) val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) @@ -134,7 +134,7 @@ class RestMtbFileSenderTest { this.mockRestServiceServer .expect(expectedCount, method(HttpMethod.DELETE)) - .andExpect(requestTo("http://localhost:9000/mtbfile/Patient/$TEST_PATIENT_PSEUDONYM")) + .andExpect(requestTo("http://localhost:9000/patient/$TEST_PATIENT_PSEUDONYM")) .andRespond { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } From 262c54f2e54652ea5f6d2cbc30ee381b415dbec8 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sat, 8 Mar 2025 10:55:30 +0100 Subject: [PATCH 21/54] fix: use patient pseudonym value --- .../dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt index b3a87b0..7fd5259 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt @@ -61,7 +61,7 @@ class RestMtbFileSenderTest { fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) { this.mockRestServiceServer .expect(method(HttpMethod.DELETE)) - .andExpect(requestTo("http://localhost:9000/patient/$TEST_PATIENT_PSEUDONYM")) + .andExpect(requestTo("http://localhost:9000/patient/${TEST_PATIENT_PSEUDONYM.value}")) .andRespond { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } @@ -134,7 +134,7 @@ class RestMtbFileSenderTest { this.mockRestServiceServer .expect(expectedCount, method(HttpMethod.DELETE)) - .andExpect(requestTo("http://localhost:9000/patient/$TEST_PATIENT_PSEUDONYM")) + .andExpect(requestTo("http://localhost:9000/patient/${TEST_PATIENT_PSEUDONYM.value}")) .andRespond { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } From 91e2cf5ef136c41b847b044610e805653913eac7 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sat, 8 Mar 2025 11:18:47 +0100 Subject: [PATCH 22/54] refactor: use different sender classes for bwHC and DIP --- .../processor/config/AppRestConfiguration.kt | 14 +- .../processor/output/RestBwhcMtbFileSender.kt | 41 +++ .../processor/output/RestDipMtbFileSender.kt | 41 +++ .../etl/processor/output/RestMtbFileSender.kt | 18 +- .../output/RestBwhcMtbFileSenderTest.kt | 262 ++++++++++++++++++ ...derTest.kt => RestDipMtbFileSenderTest.kt} | 10 +- 6 files changed, 362 insertions(+), 24 deletions(-) create mode 100644 src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt create mode 100644 src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt create mode 100644 src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt rename src/test/kotlin/dev/dnpm/etl/processor/output/{RestMtbFileSenderTest.kt => RestDipMtbFileSenderTest.kt} (96%) diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt index fc2676b..a393267 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -23,7 +23,8 @@ import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult import dev.dnpm.etl.processor.monitoring.ConnectionCheckService import dev.dnpm.etl.processor.monitoring.RestConnectionCheckService import dev.dnpm.etl.processor.output.MtbFileSender -import dev.dnpm.etl.processor.output.RestMtbFileSender +import dev.dnpm.etl.processor.output.RestBwhcMtbFileSender +import dev.dnpm.etl.processor.output.RestDipMtbFileSender import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty @@ -54,8 +55,13 @@ class AppRestConfiguration { restTargetProperties: RestTargetProperties, retryTemplate: RetryTemplate ): MtbFileSender { - logger.info("Selected 'RestMtbFileSender'") - return RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + if (restTargetProperties.isBwhc) { + logger.info("Selected 'RestBwhcMtbFileSender'") + return RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + } + + logger.info("Selected 'RestDipMtbFileSender'") + return RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate) } @Bean diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt new file mode 100644 index 0000000..bc940fd --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt @@ -0,0 +1,41 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2025 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 . + */ + +package dev.dnpm.etl.processor.output + +import dev.dnpm.etl.processor.PatientPseudonym +import dev.dnpm.etl.processor.config.RestTargetProperties +import org.springframework.retry.support.RetryTemplate +import org.springframework.web.client.RestTemplate + +class RestBwhcMtbFileSender( + private val restTemplate: RestTemplate, + private val restTargetProperties: RestTargetProperties, + private val retryTemplate: RetryTemplate +) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate) { + + override fun sendUrl(): String { + return "${restTargetProperties.uri}/MTBFile" + } + + override fun deleteUrl(patientId: PatientPseudonym): String { + return "${restTargetProperties.uri}/Patient/${patientId.value}" + } + +} \ No newline at end of file diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt new file mode 100644 index 0000000..21ea967 --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt @@ -0,0 +1,41 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2025 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 . + */ + +package dev.dnpm.etl.processor.output + +import dev.dnpm.etl.processor.PatientPseudonym +import dev.dnpm.etl.processor.config.RestTargetProperties +import org.springframework.retry.support.RetryTemplate +import org.springframework.web.client.RestTemplate + +class RestDipMtbFileSender( + private val restTemplate: RestTemplate, + private val restTargetProperties: RestTargetProperties, + private val retryTemplate: RetryTemplate +) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate) { + + override fun sendUrl(): String { + return "${restTargetProperties.uri}/patient-record" + } + + override fun deleteUrl(patientId: PatientPseudonym): String { + return "${restTargetProperties.uri}/patient/${patientId.value}" + } + +} \ No newline at end of file diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt index 6dfe0eb..b77611c 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt @@ -30,7 +30,7 @@ import org.springframework.retry.support.RetryTemplate import org.springframework.web.client.RestClientException import org.springframework.web.client.RestTemplate -class RestMtbFileSender( +abstract class RestMtbFileSender( private val restTemplate: RestTemplate, private val restTargetProperties: RestTargetProperties, private val retryTemplate: RetryTemplate @@ -38,21 +38,9 @@ class RestMtbFileSender( private val logger = LoggerFactory.getLogger(RestMtbFileSender::class.java) - fun sendUrl(): String { - return if(restTargetProperties.isBwhc) { - "${restTargetProperties.uri}/MTBFile" - } else { - "${restTargetProperties.uri}/patient-record" - } - } + abstract fun sendUrl(): String - fun deleteUrl(patientId: PatientPseudonym): String { - return if(restTargetProperties.isBwhc) { - "${restTargetProperties.uri}/Patient/${patientId.value}" - } else { - "${restTargetProperties.uri}/patient/${patientId.value}" - } - } + abstract fun deleteUrl(patientId: PatientPseudonym): String override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response { try { diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt new file mode 100644 index 0000000..abd7f9d --- /dev/null +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt @@ -0,0 +1,262 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2025 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 . + */ + +package dev.dnpm.etl.processor.output + +import de.ukw.ccc.bwhc.dto.* +import dev.dnpm.etl.processor.PatientPseudonym +import dev.dnpm.etl.processor.RequestId +import dev.dnpm.etl.processor.config.RestTargetProperties +import dev.dnpm.etl.processor.monitoring.RequestStatus +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.retry.policy.SimpleRetryPolicy +import org.springframework.retry.support.RetryTemplateBuilder +import org.springframework.test.web.client.ExpectedCount +import org.springframework.test.web.client.MockRestServiceServer +import org.springframework.test.web.client.match.MockRestRequestMatchers.method +import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo +import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus +import org.springframework.web.client.RestTemplate + +class RestBwhcMtbFileSenderTest { + + private lateinit var mockRestServiceServer: MockRestServiceServer + + private lateinit var restMtbFileSender: RestMtbFileSender + + @BeforeEach + fun setup() { + val restTemplate = RestTemplate() + val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null) + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() + + this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) + + this.restMtbFileSender = RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + } + + @ParameterizedTest + @MethodSource("deleteRequestWithResponseSource") + fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) { + this.mockRestServiceServer + .expect(method(HttpMethod.DELETE)) + .andExpect(requestTo("http://localhost:9000/mtbfile/Patient/${TEST_PATIENT_PSEUDONYM.value}")) + .andRespond { + withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + } + + val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) + assertThat(response.status).isEqualTo(requestWithResponse.response.status) + assertThat(response.body).isEqualTo(requestWithResponse.response.body) + } + + @ParameterizedTest + @MethodSource("mtbFileRequestWithResponseSource") + fun shouldReturnExpectedResponseForMtbFilePost(requestWithResponse: RequestWithResponse) { + this.mockRestServiceServer + .expect(method(HttpMethod.POST)) + .andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile")) + .andRespond { + withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + } + + val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile)) + assertThat(response.status).isEqualTo(requestWithResponse.response.status) + assertThat(response.body).isEqualTo(requestWithResponse.response.body) + } + + @ParameterizedTest + @MethodSource("mtbFileRequestWithResponseSource") + fun shouldRetryOnMtbFileHttpRequestError(requestWithResponse: RequestWithResponse) { + val restTemplate = RestTemplate() + val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null) + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() + + this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) + this.restMtbFileSender = RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + + val expectedCount = when (requestWithResponse.httpStatus) { + // OK - No Retry + HttpStatus.OK, HttpStatus.CREATED -> ExpectedCount.max(1) + // Request failed - Retry max 3 times + else -> ExpectedCount.max(3) + } + + this.mockRestServiceServer + .expect(expectedCount, method(HttpMethod.POST)) + .andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile")) + .andRespond { + withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + } + + val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile)) + assertThat(response.status).isEqualTo(requestWithResponse.response.status) + assertThat(response.body).isEqualTo(requestWithResponse.response.body) + } + + @ParameterizedTest + @MethodSource("deleteRequestWithResponseSource") + fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) { + val restTemplate = RestTemplate() + val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null) + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() + + this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) + this.restMtbFileSender = RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + + val expectedCount = when (requestWithResponse.httpStatus) { + // OK - No Retry + HttpStatus.OK, HttpStatus.CREATED -> ExpectedCount.max(1) + // Request failed - Retry max 3 times + else -> ExpectedCount.max(3) + } + + this.mockRestServiceServer + .expect(expectedCount, method(HttpMethod.DELETE)) + .andExpect(requestTo("http://localhost:9000/mtbfile/Patient/${TEST_PATIENT_PSEUDONYM.value}")) + .andRespond { + withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + } + + val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) + assertThat(response.status).isEqualTo(requestWithResponse.response.status) + assertThat(response.body).isEqualTo(requestWithResponse.response.body) + } + + companion object { + data class RequestWithResponse( + val httpStatus: HttpStatus, + val body: String, + val response: MtbFileSender.Response + ) + + val TEST_REQUEST_ID = RequestId("TestId") + val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID") + + private val warningBody = """ + { + "patient_id": "PID", + "issues": [ + { "severity": "warning", "message": "Something is not right" } + ] + } + """.trimIndent() + + private val errorBody = """ + { + "patient_id": "PID", + "issues": [ + { "severity": "error", "message": "Something is very bad" } + ] + } + """.trimIndent() + + val mtbFile: MtbFile = MtbFile.builder() + .withPatient( + Patient.builder() + .withId("PID") + .withBirthDate("2000-08-08") + .withGender(Patient.Gender.MALE) + .build() + ) + .withConsent( + Consent.builder() + .withId("1") + .withStatus(Consent.Status.ACTIVE) + .withPatient("PID") + .build() + ) + .withEpisode( + Episode.builder() + .withId("1") + .withPatient("PID") + .withPeriod(PeriodStart("2023-08-08")) + .build() + ) + .build() + + private const val ERROR_RESPONSE_BODY = "Sonstiger Fehler bei der Übertragung" + + /** + * Synthetic http responses with related request status + * Also see: https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API + */ + @JvmStatic + fun mtbFileRequestWithResponseSource(): Set { + return setOf( + RequestWithResponse(HttpStatus.OK, "{}", MtbFileSender.Response(RequestStatus.SUCCESS, "{}")), + RequestWithResponse( + HttpStatus.CREATED, + warningBody, + MtbFileSender.Response(RequestStatus.WARNING, warningBody) + ), + RequestWithResponse( + HttpStatus.BAD_REQUEST, + "??", + MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) + ), + RequestWithResponse( + HttpStatus.UNPROCESSABLE_ENTITY, + errorBody, + MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) + ), + // Some more errors not mentioned in documentation + RequestWithResponse( + HttpStatus.NOT_FOUND, + "what????", + MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) + ), + RequestWithResponse( + HttpStatus.INTERNAL_SERVER_ERROR, + "what????", + MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) + ) + ) + } + + /** + * Synthetic http responses with related request status + * Also see: https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API + */ + @JvmStatic + fun deleteRequestWithResponseSource(): Set { + return setOf( + RequestWithResponse(HttpStatus.OK, "", MtbFileSender.Response(RequestStatus.SUCCESS)), + // Some more errors not mentioned in documentation + RequestWithResponse( + HttpStatus.NOT_FOUND, + "what????", + MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) + ), + RequestWithResponse( + HttpStatus.INTERNAL_SERVER_ERROR, + "what????", + MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) + ) + ) + } + } + + +} \ No newline at end of file diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt similarity index 96% rename from src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt rename to src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt index 7fd5259..e764769 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -39,7 +39,7 @@ import org.springframework.test.web.client.match.MockRestRequestMatchers.request import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus import org.springframework.web.client.RestTemplate -class RestMtbFileSenderTest { +class RestDipMtbFileSenderTest { private lateinit var mockRestServiceServer: MockRestServiceServer @@ -53,7 +53,7 @@ class RestMtbFileSenderTest { this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) - this.restMtbFileSender = RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate) } @ParameterizedTest @@ -94,7 +94,7 @@ class RestMtbFileSenderTest { val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) - this.restMtbFileSender = RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate) val expectedCount = when (requestWithResponse.httpStatus) { // OK - No Retry @@ -123,7 +123,7 @@ class RestMtbFileSenderTest { val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) - this.restMtbFileSender = RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate) val expectedCount = when (requestWithResponse.httpStatus) { // OK - No Retry From 280fbd445e443aa5b5dfb1549fd62b06bd75a998 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sun, 9 Mar 2025 09:24:28 +0100 Subject: [PATCH 23/54] chore: update Spring Boot --- build.gradle.kts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a9aa746..4fba43a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage plugins { war - id("org.springframework.boot") version "3.3.8" + id("org.springframework.boot") version "3.3.9" id("io.spring.dependency-management") version "1.1.7" kotlin("jvm") version "1.9.25" kotlin("plugin.spring") version "1.9.25" @@ -19,7 +19,6 @@ var versions = mapOf( "bwhc-dto-java" to "0.3.0", "hapi-fhir" to "7.6.0", "commons-compress" to "1.26.2", - "commons-io" to "2.17.0", "mockito-kotlin" to "5.4.0", "archunit" to "1.3.0", // Webjars @@ -79,8 +78,6 @@ dependencies { implementation("org.webjars:webjars-locator:${versions["webjars-locator"]}") implementation("org.webjars.npm:echarts:${versions["echarts"]}") implementation("org.webjars.npm:htmx.org:${versions["htmx.org"]}") - // Override dependecy version from ca.uhn.hapi.fhir:hapi-fhir-base - CVE-2024-47554 - implementation("commons-io:commons-io:${versions["commons-io"]}") runtimeOnly("org.mariadb.jdbc:mariadb-java-client") runtimeOnly("org.postgresql:postgresql") From 3a19212a7847d0f5959d0f66ae37fe1e2d7f8fe6 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sun, 9 Mar 2025 09:38:59 +0100 Subject: [PATCH 24/54] chore: update Spring Boot --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 011ade4..205f1ee 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage plugins { war - id("org.springframework.boot") version "3.2.11" + id("org.springframework.boot") version "3.2.12" id("io.spring.dependency-management") version "1.1.5" kotlin("jvm") version "1.9.24" kotlin("plugin.spring") version "1.9.24" @@ -86,7 +86,7 @@ dependencies { integrationTestImplementation("org.testcontainers:junit-jupiter") integrationTestImplementation("org.testcontainers:postgresql") // Override dependency version from org.testcontainers:junit-jupiter - CVE-2024-26308, CVE-2024-25710 - integrationTestImplementation("org.apache.commons:commons-compress:1.26.1") + integrationTestImplementation("org.apache.commons:commons-compress:1.26.2") } tasks.withType { From f66b737f11cae22ce6399a23237bbea64622f0d0 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Wed, 19 Mar 2025 15:29:55 +0100 Subject: [PATCH 25/54] docs: add example APP_REST_URI for use with dnpm:dip --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9fb0164..f8959be 100644 --- a/README.md +++ b/README.md @@ -196,10 +196,12 @@ Werden sowohl REST als auch Kafka-Endpunkt konfiguriert, wird nur der REST-Endpu Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an das bwHC-Backend gesendet wird: -* `APP_REST_URI`: URI der zu benutzenden API der bwHC-Backend-Instanz. z.B.: `http://localhost:9000/bwhc/etl/api` +* `APP_REST_URI`: URI der zu benutzenden API der bwHC-Backend-Instanz. Zum Beispiel: + * `http://localhost:9000/bwhc/etl/api` für **bwHC Backend** + * `http://localhost:9000/api/mtb/etl` für **dnpm:dip** * `APP_REST_USERNAME`: Basic-Auth-Benutzername für bwHC-Backend * `APP_REST_PASSWORD`: Basic-Auth-Passwort für bwHC-Backend -* `APP_REST_IS_BWHC`: `true` für bwHC, weglassen oder `false` für dnpm:dip +* `APP_REST_IS_BWHC`: `true` für **bwHC Backend**, weglassen oder `false` für **dnpm:dip** #### Kafka-Topics From 775a7df1ce48c7783daccafa4ae21ca33f1a6961 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Thu, 20 Mar 2025 14:13:21 +0100 Subject: [PATCH 26/54] chore: use API URL to DNPM:DIP --- README.md | 2 +- .../processor/output/RestDipMtbFileSenderTest.kt | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f8959be..cc7d10a 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an das * `APP_REST_URI`: URI der zu benutzenden API der bwHC-Backend-Instanz. Zum Beispiel: * `http://localhost:9000/bwhc/etl/api` für **bwHC Backend** - * `http://localhost:9000/api/mtb/etl` für **dnpm:dip** + * `http://localhost:9000/api` für **dnpm:dip** * `APP_REST_USERNAME`: Basic-Auth-Benutzername für bwHC-Backend * `APP_REST_PASSWORD`: Basic-Auth-Passwort für bwHC-Backend * `APP_REST_IS_BWHC`: `true` für **bwHC Backend**, weglassen oder `false` für **dnpm:dip** diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt index e764769..ed8c6f0 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt @@ -48,7 +48,7 @@ class RestDipMtbFileSenderTest { @BeforeEach fun setup() { val restTemplate = RestTemplate() - val restTargetProperties = RestTargetProperties("http://localhost:9000/", null, null, false) + val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false) val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) @@ -61,7 +61,7 @@ class RestDipMtbFileSenderTest { fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) { this.mockRestServiceServer .expect(method(HttpMethod.DELETE)) - .andExpect(requestTo("http://localhost:9000/patient/${TEST_PATIENT_PSEUDONYM.value}")) + .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient/${TEST_PATIENT_PSEUDONYM.value}")) .andRespond { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } @@ -76,7 +76,7 @@ class RestDipMtbFileSenderTest { fun shouldReturnExpectedResponseForMtbFilePost(requestWithResponse: RequestWithResponse) { this.mockRestServiceServer .expect(method(HttpMethod.POST)) - .andExpect(requestTo("http://localhost:9000/patient-record")) + .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient-record")) .andRespond { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } @@ -90,7 +90,7 @@ class RestDipMtbFileSenderTest { @MethodSource("mtbFileRequestWithResponseSource") fun shouldRetryOnMtbFileHttpRequestError(requestWithResponse: RequestWithResponse) { val restTemplate = RestTemplate() - val restTargetProperties = RestTargetProperties("http://localhost:9000/", null, null, false) + val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false) val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) @@ -105,7 +105,7 @@ class RestDipMtbFileSenderTest { this.mockRestServiceServer .expect(expectedCount, method(HttpMethod.POST)) - .andExpect(requestTo("http://localhost:9000/patient-record")) + .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient-record")) .andRespond { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } @@ -119,7 +119,7 @@ class RestDipMtbFileSenderTest { @MethodSource("deleteRequestWithResponseSource") fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) { val restTemplate = RestTemplate() - val restTargetProperties = RestTargetProperties("http://localhost:9000/", null, null, false) + val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false) val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) @@ -134,7 +134,7 @@ class RestDipMtbFileSenderTest { this.mockRestServiceServer .expect(expectedCount, method(HttpMethod.DELETE)) - .andExpect(requestTo("http://localhost:9000/patient/${TEST_PATIENT_PSEUDONYM.value}")) + .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient/${TEST_PATIENT_PSEUDONYM.value}")) .andRespond { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } From f347653be83682e95606358ac25242a219508236 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Thu, 20 Mar 2025 14:19:25 +0100 Subject: [PATCH 27/54] refactor: use UriComponentsBuilder to build URL to be used This prevents problems using trailing slash in remote API URL. --- .../processor/output/RestBwhcMtbFileSender.kt | 16 +++++++++++---- .../processor/output/RestDipMtbFileSender.kt | 20 +++++++++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt index bc940fd..f4a58e8 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt @@ -23,19 +23,27 @@ import dev.dnpm.etl.processor.PatientPseudonym import dev.dnpm.etl.processor.config.RestTargetProperties import org.springframework.retry.support.RetryTemplate import org.springframework.web.client.RestTemplate +import org.springframework.web.util.UriComponentsBuilder class RestBwhcMtbFileSender( - private val restTemplate: RestTemplate, + restTemplate: RestTemplate, private val restTargetProperties: RestTargetProperties, - private val retryTemplate: RetryTemplate + retryTemplate: RetryTemplate ) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate) { override fun sendUrl(): String { - return "${restTargetProperties.uri}/MTBFile" + return UriComponentsBuilder + .fromUriString(restTargetProperties.uri.toString()) + .pathSegment("MTBFile") + .toUriString() } override fun deleteUrl(patientId: PatientPseudonym): String { - return "${restTargetProperties.uri}/Patient/${patientId.value}" + return UriComponentsBuilder + .fromUriString(restTargetProperties.uri.toString()) + .pathSegment("Patient") + .pathSegment(patientId.value) + .toUriString() } } \ No newline at end of file diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt index 21ea967..42dbb30 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt @@ -23,19 +23,31 @@ import dev.dnpm.etl.processor.PatientPseudonym import dev.dnpm.etl.processor.config.RestTargetProperties import org.springframework.retry.support.RetryTemplate import org.springframework.web.client.RestTemplate +import org.springframework.web.util.UriComponentsBuilder class RestDipMtbFileSender( - private val restTemplate: RestTemplate, + restTemplate: RestTemplate, private val restTargetProperties: RestTargetProperties, - private val retryTemplate: RetryTemplate + retryTemplate: RetryTemplate ) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate) { override fun sendUrl(): String { - return "${restTargetProperties.uri}/patient-record" + return UriComponentsBuilder + .fromUriString(restTargetProperties.uri.toString()) + .pathSegment("mtb") + .pathSegment("etl") + .pathSegment("patient-record") + .toUriString() } override fun deleteUrl(patientId: PatientPseudonym): String { - return "${restTargetProperties.uri}/patient/${patientId.value}" + return UriComponentsBuilder + .fromUriString(restTargetProperties.uri.toString()) + .pathSegment("mtb") + .pathSegment("etl") + .pathSegment("patient") + .pathSegment(patientId.value) + .toUriString() } } \ No newline at end of file From 47ebe46974dcd7c6078d7a9a7f0a541f39d82c64 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Thu, 20 Mar 2025 14:39:40 +0100 Subject: [PATCH 28/54] feat: add checks for DNPM:DIP backend Since DNPM:DIP responds with HTTP 404 on API base path, the Kaplan-Meier Config endpoint will be used to check availability of DNPM:DIP backend API. --- .../etl/processor/monitoring/ConnectionCheckService.kt | 10 +++++++++- .../templates/configs/outputConnectionAvailable.html | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt index e70da3e..9d96654 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt @@ -121,7 +121,15 @@ class RestConnectionCheckService( fun check() { result = try { val available = restTemplate.getForEntity( - restTargetProperties.uri?.replace("/etl/api", "").toString(), + if (restTargetProperties.isBwhc) { + UriComponentsBuilder.fromUriString(restTargetProperties.uri.toString()).path("").toUriString() + } else { + UriComponentsBuilder.fromUriString(restTargetProperties.uri.toString()) + .pathSegment("mtb") + .pathSegment("kaplan-meier") + .pathSegment("config") + .toUriString() + }, String::class.java ).statusCode == HttpStatus.OK diff --git a/src/main/resources/templates/configs/outputConnectionAvailable.html b/src/main/resources/templates/configs/outputConnectionAvailable.html index 4b7f8d1..93ad549 100644 --- a/src/main/resources/templates/configs/outputConnectionAvailable.html +++ b/src/main/resources/templates/configs/outputConnectionAvailable.html @@ -20,7 +20,8 @@ Kafka-Broker ETL-Processor - bwHC-Backend + bwHC-Backend + DNPM:DIP-Backend Kafka-Broker \ No newline at end of file From 38261d6d2c778ebeef445dd24b42cf8bad6476f0 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 21 Mar 2025 19:10:40 +0100 Subject: [PATCH 29/54] chore: update bwhc-dto-java This enables use of WHOGrading version 2021. --- build.gradle.kts | 2 +- src/test/resources/fake_MTBFile.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 4fba43a..d9fbd4d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,7 +16,7 @@ group = "dev.dnpm" version = "0.10.0-SNAPSHOT" var versions = mapOf( - "bwhc-dto-java" to "0.3.0", + "bwhc-dto-java" to "0.4.0", "hapi-fhir" to "7.6.0", "commons-compress" to "1.26.2", "mockito-kotlin" to "5.4.0", diff --git a/src/test/resources/fake_MTBFile.json b/src/test/resources/fake_MTBFile.json index 3f4e8a3..cdf8d75 100644 --- a/src/test/resources/fake_MTBFile.json +++ b/src/test/resources/fake_MTBFile.json @@ -1 +1 @@ -{"patient":{"id":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","gender":"female","birthDate":"1971-03","insurance":"Barmer"},"consent":{"id":"b93e4717-7b0e-4ca5-a5d6-cf8d0f7b14cf","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","status":"active"},"episode":{"id":"8ddb893f-0d55-412f-a257-9bc8bb054549","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","period":{"start":"2021-12-29"}},"diagnoses":[{"id":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","recordedOn":"2023-12-14","icd10":{"code":"C16.0","display":"Bösartige Neubildung: Kardia","version":"2023","system":"ICD-10-GM"},"whoGrade":{"code":"III","system":"WHO-Grading-CNS-Tumors"},"histologyResults":["385926d9-51f2-4a3a-96b6-57c5effefd84"],"statusHistory":[{"status":"local","date":"2023-12-14"},{"status":"unknown","date":"2023-12-14"}],"guidelineTreatmentStatus":"no-guidelines-available"}],"familyMemberDiagnoses":[{"id":"c434f063-76d9-4a7f-8ff2-34cd55fc56b2","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","relationship":{"code":"EXT","system":"http://terminology.hl7.org/ValueSet/v3-FamilyMember"}},{"id":"b07a73a8-70ed-48f8-a745-e82e7a5907a8","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","relationship":{"code":"EXT","system":"http://terminology.hl7.org/ValueSet/v3-FamilyMember"}}],"previousGuidelineTherapies":[{"id":"6435d684-18e3-45ad-b063-cdc303f61aa2","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","therapyLine":4,"medication":[{"code":"L01XC18","system":"ATC","display":"Pembrolizumab","version":"2020"},{"code":"L01XE21","system":"ATC","display":"Regorafenib","version":"2020"},{"code":"L01XC17","system":"ATC","display":"Nivolumab","version":"2020"}]},{"id":"e35731db-7447-4c4b-896a-0e90f1e68c67","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","therapyLine":4,"medication":[{"code":"L01XX46","system":"ATC","display":"Olaparib","version":"2020"},{"code":"L01XX32","system":"ATC","display":"Bortezomib","version":"2020"},{"code":"L01XE02","system":"ATC","display":"Gefitinib","version":"2020"}]},{"id":"d8a46cf9-cdc9-4f0d-b106-5aa57e08c4d0","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","therapyLine":1,"medication":[{"code":"L01XC28","system":"ATC","display":"Durvalumab","version":"2020"},{"code":"L01XC06","system":"ATC","display":"Cetuximab","version":"2020"},{"code":"L01XE47","system":"ATC","display":"Dacomitinib","version":"2020"}]}],"lastGuidelineTherapies":[{"id":"9152b20d-ac04-406c-b18b-5b6f7f4d1911","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","therapyLine":1,"period":{"start":"2023-12-14","end":"2024-01-04"},"medication":[{"code":"L01DB01","system":"ATC","display":"Doxorubicin","version":"2020"}],"reasonStopped":{"code":"toxicity","system":"MTB-CDS:GuidelineTherapy-StopReason"}}],"ecogStatus":[{"id":"51589dd2-f48d-4c41-8740-292b88d63b30","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","effectiveDate":"2023-12-14","value":{"code":"2","system":"ECOG-Performance-Status"}},{"id":"30caf153-a30a-4a1c-9056-fd4eae2a55da","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","effectiveDate":"2023-12-14","value":{"code":"4","system":"ECOG-Performance-Status"}}],"specimens":[{"id":"40043ae5-d4cb-48e4-85c6-b34266b7693f","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","icd10":{"code":"C16.0","display":"Bösartige Neubildung: Kardia","version":"2023","system":"ICD-10-GM"},"type":"liquid-biopsy","collection":{"date":"2023-12-14","localization":"unknown","method":"liquid-biopsy"}}],"molecularPathologyFindings":[{"id":"c7622342-3297-489e-850a-26aaf1225b36","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","performingInstitute":"TESTINSTITUTE","issuedOn":"2023-12-14","note":"MolecularPathologyFinding notes..."}],"histologyReports":[{"id":"385926d9-51f2-4a3a-96b6-57c5effefd84","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","issuedOn":"2023-12-14","tumorMorphology":{"id":"592b13c7-9507-4f31-a544-5cff90e35581","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","value":{"code":"8851/3","display":"Gut differenziertes Liposarkom","version":"Zweite Revision","system":"ICD-O-3-M"},"note":"Histology finding notes..."},"tumorCellContent":{"id":"f6af339c-415c-4682-b700-499e392b4558","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","method":"histologic","value":0.38164}}],"ngsReports":[{"id":"223e3e6e-d99b-415e-83c0-18bcfc5729ca","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","issueDate":"2023-12-14","sequencingType":"WGS","metadata":[{"kitType":"Agilent ExomV6","kitManufacturer":"Agilent","sequencer":"Sequencer-XYZ","referenceGenome":"HG19","pipeline":"dummy/uri/to/pipeline"}],"tumorCellContent":{"id":"e865f20a-1307-4ca3-b2ef-3a863b8afde0","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","method":"bioinformatic","value":0.74991},"brcaness":0.39,"msi":0.35,"tmb":594349.91,"simpleVariants":[{"id":"SNV_599c38d4-af9f-416c-ad38-eb2fee8159e1","chromosome":"chr6","gene":{"ensemblId":"ENSENSG00000242908","hgncId":"HGNC:50301","symbol":"AADACL2-AS1","name":"AADACL2 antisense RNA 1"},"startEnd":{"start":6736035388467870105},"refAllele":"A","altAllele":"G","dnaChange":{"code":"A>G","system":"HGVS.c"},"aminoAcidChange":{"code":"Amino acid change code...","system":"HGVS.p"},"readDepth":19,"allelicFrequency":0.75,"cosmicId":"COSMICf106c745-dbaa-453f-8dca-2f584bc1e6cb","dbSNPId":"DBSNPIDc3f51fb2-31f3-4b27-bbcc-aac52736986f","interpretation":{"code":"Probably Inactivating","system":"ClinVAR"}},{"id":"SNV_963a3ea1-a1d5-4a52-b8d4-2e969be74b7b","chromosome":"chr2","gene":{"ensemblId":"ENSENSG00000141338","hgncId":"HGNC:38","symbol":"ABCA8","name":"ATP binding cassette subfamily A member 8"},"startEnd":{"start":1672464855319477743},"refAllele":"T","altAllele":"C","dnaChange":{"code":"T>C","system":"HGVS.c"},"aminoAcidChange":{"code":"Amino acid change code...","system":"HGVS.p"},"readDepth":23,"allelicFrequency":0.36,"cosmicId":"COSMICbcbc96bb-0428-48c9-8c64-7d2fd884528d","dbSNPId":"DBSNPIDc4904618-819c-4af1-b793-ca9d820371dc","interpretation":{"code":"Ambiguous","system":"ClinVAR"}},{"id":"SNV_7f35df9d-d8b1-4421-9ec7-35cf8d3c4546","chromosome":"chr5","gene":{"ensemblId":"ENSENSG00000149313","hgncId":"HGNC:14235","symbol":"AASDHPPT","name":"aminoadipate-semialdehyde dehydrogenase-phosphopantetheinyl transferase"},"startEnd":{"start":4251323878559029469},"refAllele":"A","altAllele":"C","dnaChange":{"code":"A>C","system":"HGVS.c"},"aminoAcidChange":{"code":"Amino acid change code...","system":"HGVS.p"},"readDepth":37,"allelicFrequency":0.48,"cosmicId":"COSMICc3c67469-2303-4cba-9b45-424d62a0d3db","dbSNPId":"DBSNPIDd94cb5ce-250a-471f-a571-015fe8a711c9","interpretation":{"code":"Function Changed","system":"ClinVAR"}},{"id":"SNV_22f943fd-ad99-48af-a61e-42679e851b71","chromosome":"chr2","gene":{"ensemblId":"ENSENSG00000167972","hgncId":"HGNC:33","symbol":"ABCA3","name":"ATP binding cassette subfamily A member 3"},"startEnd":{"start":7454627449124699972},"refAllele":"A","altAllele":"C","dnaChange":{"code":"A>C","system":"HGVS.c"},"aminoAcidChange":{"code":"Amino acid change code...","system":"HGVS.p"},"readDepth":22,"allelicFrequency":0.37,"cosmicId":"COSMICa6074760-ce24-4075-ab11-f5ab4cd6c497","dbSNPId":"DBSNPID3578fa1b-eb03-423c-929d-6705cd8e805c","interpretation":{"code":"Function Changed","system":"ClinVAR"}},{"id":"SNV_fe346bed-74ac-4b84-843b-7490a5823364","chromosome":"chr14","gene":{"ensemblId":"ENSENSG00000125257","hgncId":"HGNC:55","symbol":"ABCC4","name":"ATP binding cassette subfamily C member 4"},"startEnd":{"start":6478613836523717707},"refAllele":"G","altAllele":"T","dnaChange":{"code":"G>T","system":"HGVS.c"},"aminoAcidChange":{"code":"Amino acid change code...","system":"HGVS.p"},"readDepth":30,"allelicFrequency":0.47,"cosmicId":"COSMIC14d8842e-6af9-434f-a993-32fae87a84be","dbSNPId":"DBSNPID1a97211e-a6f6-44be-a909-3620f34b01e2","interpretation":{"code":"Ambiguous","system":"ClinVAR"}}],"copyNumberVariants":[{"id":"CNV_ABALON_ABCA17P_high-level-gain","chromosome":"chr8","startRange":{"start":969911792064545275,"end":969911792064546084},"endRange":{"start":4404138220928659257,"end":4404138220928659925},"totalCopyNumber":2,"relativeCopyNumber":0.18,"cnA":0.87,"cnB":0.49,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000281376","hgncId":"HGNC:49667","symbol":"ABALON","name":"apoptotic BCL2L1-antisense long non-coding RNA"},{"ensemblId":"ENSENSG00000238098","hgncId":"HGNC:32972","symbol":"ABCA17P","name":"ATP binding cassette subfamily A member 17, pseudogene"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000115657","hgncId":"HGNC:47","symbol":"ABCB6","name":"ATP binding cassette subfamily B member 6 (Langereis blood group)"},{"ensemblId":"ENSENSG00000184389","hgncId":"HGNC:30005","symbol":"A3GALT2","name":"alpha 1,3-galactosyltransferase 2"},{"ensemblId":"ENSENSG00000023839","hgncId":"HGNC:53","symbol":"ABCC2","name":"ATP binding cassette subfamily C member 2"},{"ensemblId":"ENSENSG00000127837","hgncId":"HGNC:18","symbol":"AAMP","name":"angio associated migratory cell protein"}]},{"id":"CNV_AAGAB_ABCA6_high-level-gain","chromosome":"chr3","startRange":{"start":6843968935032545040,"end":6843968935032545924},"endRange":{"start":3583631517115538627,"end":3583631517115539281},"totalCopyNumber":4,"relativeCopyNumber":0.11,"cnA":0.15,"cnB":0.29,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000103591","hgncId":"HGNC:25662","symbol":"AAGAB","name":"alpha and gamma adaptin binding protein"},{"ensemblId":"ENSENSG00000154262","hgncId":"HGNC:36","symbol":"ABCA6","name":"ATP binding cassette subfamily A member 6"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000231749","hgncId":"HGNC:39983","symbol":"ABCA9-AS1","name":"ABCA9 antisense RNA 1"},{"ensemblId":"ENSENSG00000154265","hgncId":"HGNC:35","symbol":"ABCA5","name":"ATP binding cassette subfamily A member 5"},{"ensemblId":"ENSENSG00000081760","hgncId":"HGNC:21298","symbol":"AACS","name":"acetoacetyl-CoA synthetase"}]},{"id":"CNV_ABCA12_AASDHPPT_ABCC2_AARS1P1_high-level-gain","chromosome":"chr5","startRange":{"start":8487172994898555456,"end":8487172994898556127},"endRange":{"start":2329045896118581347,"end":2329045896118581894},"totalCopyNumber":3,"relativeCopyNumber":0.67,"cnA":0.47,"cnB":0.46,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000144452","hgncId":"HGNC:14637","symbol":"ABCA12","name":"ATP binding cassette subfamily A member 12"},{"ensemblId":"ENSENSG00000149313","hgncId":"HGNC:14235","symbol":"AASDHPPT","name":"aminoadipate-semialdehyde dehydrogenase-phosphopantetheinyl transferase"},{"ensemblId":"ENSENSG00000023839","hgncId":"HGNC:53","symbol":"ABCC2","name":"ATP binding cassette subfamily C member 2"},{"ensemblId":"ENSENSG00000249038","hgncId":"HGNC:49894","symbol":"AARS1P1","name":"alanyl-tRNA synthetase 1 pseudogene 1"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000141338","hgncId":"HGNC:38","symbol":"ABCA8","name":"ATP binding cassette subfamily A member 8"},{"ensemblId":"ENSENSG00000129673","hgncId":"HGNC:19","symbol":"AANAT","name":"aralkylamine N-acetyltransferase"},{"ensemblId":"ENSENSG00000242908","hgncId":"HGNC:50301","symbol":"AADACL2-AS1","name":"AADACL2 antisense RNA 1"}]},{"id":"CNV_AAAS_AADAT_high-level-gain","chromosome":"chr10","startRange":{"start":1954565432038993495,"end":1954565432038994133},"endRange":{"start":442085989067090995,"end":442085989067091164},"totalCopyNumber":4,"relativeCopyNumber":0.37,"cnA":0.98,"cnB":0.18,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000094914","hgncId":"HGNC:13666","symbol":"AAAS","name":"aladin WD repeat nucleoporin"},{"ensemblId":"ENSENSG00000109576","hgncId":"HGNC:17929","symbol":"AADAT","name":"aminoadipate aminotransferase"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000023839","hgncId":"HGNC:53","symbol":"ABCC2","name":"ATP binding cassette subfamily C member 2"},{"ensemblId":"ENSENSG00000150967","hgncId":"HGNC:50","symbol":"ABCB9","name":"ATP binding cassette subfamily B member 9"},{"ensemblId":"ENSENSG00000149313","hgncId":"HGNC:14235","symbol":"AASDHPPT","name":"aminoadipate-semialdehyde dehydrogenase-phosphopantetheinyl transferase"}]},{"id":"CNV_A3GALT2_ABCB10P4_low-level-gain","chromosome":"chr8","startRange":{"start":1779205446909981075,"end":1779205446909981845},"endRange":{"start":3151805846500148631,"end":3151805846500149455},"totalCopyNumber":4,"relativeCopyNumber":0.94,"cnA":0.3,"cnB":0.66,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000184389","hgncId":"HGNC:30005","symbol":"A3GALT2","name":"alpha 1,3-galactosyltransferase 2"},{"ensemblId":"ENSENSG00000260053","hgncId":"HGNC:31130","symbol":"ABCB10P4","name":"ABCB10 pseudogene 4"}],"reportedFocality":"reported-focality...","type":"low-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000167972","hgncId":"HGNC:33","symbol":"ABCA3","name":"ATP binding cassette subfamily A member 3"},{"ensemblId":"ENSENSG00000231749","hgncId":"HGNC:39983","symbol":"ABCA9-AS1","name":"ABCA9 antisense RNA 1"},{"ensemblId":"ENSENSG00000108846","hgncId":"HGNC:54","symbol":"ABCC3","name":"ATP binding cassette subfamily C member 3"},{"ensemblId":"ENSENSG00000197953","hgncId":"HGNC:24427","symbol":"AADACL2","name":"arylacetamide deacetylase like 2"}]},{"id":"CNV_ABCA10_AADACL2_high-level-gain","chromosome":"chrX","startRange":{"start":165156786091954061,"end":165156786091954176},"endRange":{"start":5591033364020511004,"end":5591033364020511607},"totalCopyNumber":4,"relativeCopyNumber":0.69,"cnA":0.56,"cnB":0.12,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000154263","hgncId":"HGNC:30","symbol":"ABCA10","name":"ATP binding cassette subfamily A member 10"},{"ensemblId":"ENSENSG00000197953","hgncId":"HGNC:24427","symbol":"AADACL2","name":"arylacetamide deacetylase like 2"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000215458","hgncId":"HGNC:51526","symbol":"AATBC","name":"apoptosis associated transcript in bladder cancer"},{"ensemblId":"ENSENSG00000249038","hgncId":"HGNC:49894","symbol":"AARS1P1","name":"alanyl-tRNA synthetase 1 pseudogene 1"},{"ensemblId":"ENSENSG00000261524","hgncId":"HGNC:31129","symbol":"ABCB10P3","name":"ABCB10 pseudogene 3"}]},{"id":"CNV_AAMP_AADACL3_low-level-gain","chromosome":"chrX","startRange":{"start":7552444878262806955,"end":7552444878262807187},"endRange":{"start":140089034030783731,"end":140089034030784407},"totalCopyNumber":2,"relativeCopyNumber":0.57,"cnA":0.26,"cnB":0.82,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000127837","hgncId":"HGNC:18","symbol":"AAMP","name":"angio associated migratory cell protein"},{"ensemblId":"ENSENSG00000188984","hgncId":"HGNC:32037","symbol":"AADACL3","name":"arylacetamide deacetylase like 3"}],"reportedFocality":"reported-focality...","type":"low-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000121410","hgncId":"HGNC:5","symbol":"A1BG","name":"alpha-1-B glycoprotein"},{"ensemblId":"ENSENSG00000175899","hgncId":"HGNC:7","symbol":"A2M","name":"alpha-2-macroglobulin"},{"ensemblId":"ENSENSG00000257408","hgncId":"HGNC:55707","symbol":"ABCA3P1","name":"ABCA3 pseudogene 1"},{"ensemblId":"ENSENSG00000005471","hgncId":"HGNC:45","symbol":"ABCB4","name":"ATP binding cassette subfamily B member 4"}]}],"dnaFusions":[{"id":"DNAFusion_A3GALT2_AAGAB","fusionPartner5prime":{"chromosome":"chr16","position":569568638299166051,"gene":{"ensemblId":"ENSENSG00000184389","hgncId":"HGNC:30005","symbol":"A3GALT2","name":"alpha 1,3-galactosyltransferase 2"}},"fusionPartner3prime":{"chromosome":"chr1","position":1728963273084125905,"gene":{"ensemblId":"ENSENSG00000103591","hgncId":"HGNC:25662","symbol":"AAGAB","name":"alpha and gamma adaptin binding protein"}},"reportedNumReads":25},{"id":"DNAFusion_ABCA3_AASDHPPT","fusionPartner5prime":{"chromosome":"chr12","position":4142955940382701892,"gene":{"ensemblId":"ENSENSG00000167972","hgncId":"HGNC:33","symbol":"ABCA3","name":"ATP binding cassette subfamily A member 3"}},"fusionPartner3prime":{"chromosome":"chr13","position":2530447494476677762,"gene":{"ensemblId":"ENSENSG00000149313","hgncId":"HGNC:14235","symbol":"AASDHPPT","name":"aminoadipate-semialdehyde dehydrogenase-phosphopantetheinyl transferase"}},"reportedNumReads":29},{"id":"DNAFusion_ABCB10P3_AAAS","fusionPartner5prime":{"chromosome":"chr19","position":216619303235013143,"gene":{"ensemblId":"ENSENSG00000261524","hgncId":"HGNC:31129","symbol":"ABCB10P3","name":"ABCB10 pseudogene 3"}},"fusionPartner3prime":{"chromosome":"chr20","position":7983660439294503113,"gene":{"ensemblId":"ENSENSG00000094914","hgncId":"HGNC:13666","symbol":"AAAS","name":"aladin WD repeat nucleoporin"}},"reportedNumReads":30},{"id":"DNAFusion_A1BG_AADACL4","fusionPartner5prime":{"chromosome":"chr19","position":2761782759714541191,"gene":{"ensemblId":"ENSENSG00000121410","hgncId":"HGNC:5","symbol":"A1BG","name":"alpha-1-B glycoprotein"}},"fusionPartner3prime":{"chromosome":"chr15","position":2381966877469433813,"gene":{"ensemblId":"ENSENSG00000204518","hgncId":"HGNC:32038","symbol":"AADACL4","name":"arylacetamide deacetylase like 4"}},"reportedNumReads":22},{"id":"DNAFusion_AAMP_ABCB9","fusionPartner5prime":{"chromosome":"chr12","position":2738282492147015127,"gene":{"ensemblId":"ENSENSG00000127837","hgncId":"HGNC:18","symbol":"AAMP","name":"angio associated migratory cell protein"}},"fusionPartner3prime":{"chromosome":"chr18","position":4689414126579295665,"gene":{"ensemblId":"ENSENSG00000150967","hgncId":"HGNC:50","symbol":"ABCB9","name":"ATP binding cassette subfamily B member 9"}},"reportedNumReads":44},{"id":"DNAFusion_AACS_ABCB10","fusionPartner5prime":{"chromosome":"chr19","position":5162788528310959454,"gene":{"ensemblId":"ENSENSG00000081760","hgncId":"HGNC:21298","symbol":"AACS","name":"acetoacetyl-CoA synthetase"}},"fusionPartner3prime":{"chromosome":"chr2","position":281250493569316672,"gene":{"ensemblId":"ENSENSG00000135776","hgncId":"HGNC:41","symbol":"ABCB10","name":"ATP binding cassette subfamily B member 10"}},"reportedNumReads":28},{"id":"DNAFusion_ABCA3_ABCA8","fusionPartner5prime":{"chromosome":"chr17","position":7239027143174816791,"gene":{"ensemblId":"ENSENSG00000167972","hgncId":"HGNC:33","symbol":"ABCA3","name":"ATP binding cassette subfamily A member 3"}},"fusionPartner3prime":{"chromosome":"chr20","position":3415200056745807403,"gene":{"ensemblId":"ENSENSG00000141338","hgncId":"HGNC:38","symbol":"ABCA8","name":"ATP binding cassette subfamily A member 8"}},"reportedNumReads":47},{"id":"DNAFusion_AATF_AASS","fusionPartner5prime":{"chromosome":"chr14","position":6207520478306983467,"gene":{"ensemblId":"ENSENSG00000275700","hgncId":"HGNC:19235","symbol":"AATF","name":"apoptosis antagonizing transcription factor"}},"fusionPartner3prime":{"chromosome":"chr7","position":966733822586135931,"gene":{"ensemblId":"ENSENSG00000008311","hgncId":"HGNC:17366","symbol":"AASS","name":"aminoadipate-semialdehyde synthase"}},"reportedNumReads":32},{"id":"DNAFusion_AATBC_AARD","fusionPartner5prime":{"chromosome":"chr9","position":6948858453904558539,"gene":{"ensemblId":"ENSENSG00000215458","hgncId":"HGNC:51526","symbol":"AATBC","name":"apoptosis associated transcript in bladder cancer"}},"fusionPartner3prime":{"chromosome":"chr21","position":5188489683141511746,"gene":{"ensemblId":"ENSENSG00000205002","hgncId":"HGNC:33842","symbol":"AARD","name":"alanine and arginine rich domain containing protein"}},"reportedNumReads":21}],"rnaFusions":[{"id":"RNAFusion_A3GALT2_ABCB6","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000184389","hgncId":"HGNC:30005","symbol":"A3GALT2","name":"alpha 1,3-galactosyltransferase 2"},"transcriptId":"TIDf283c026-41bf-41c6-afd5-1980bd408a06","exon":"EXONc9f963b3-4e55-4f1f-9ad1-e79b86e6e751","position":1009212469473862062,"strand":"+"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000115657","hgncId":"HGNC:47","symbol":"ABCB6","name":"ATP binding cassette subfamily B member 6 (Langereis blood group)"},"transcriptId":"TID97eabd37-cf0e-44f3-a1f1-d21543eb3b5d","exon":"EXONc9077d54-f82f-42bb-8bc8-e79e46204085","position":6271859040431005877,"strand":"-"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC28f0a2f7-a923-4622-a9e4-52ecd5806066","reportedNumReads":29},{"id":"RNAFusion_ABCB10P4_A2ML1-AS1","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000260053","hgncId":"HGNC:31130","symbol":"ABCB10P4","name":"ABCB10 pseudogene 4"},"transcriptId":"TID2cd74611-6960-455f-9946-3962aadbbd56","exon":"EXONf7f8f171-248f-44de-9408-37a3bfa75b4f","position":9124790507143722597,"strand":"+"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000256661","hgncId":"HGNC:41022","symbol":"A2ML1-AS1","name":"A2ML1 antisense RNA 1"},"transcriptId":"TIDb16fc5d0-e8ed-40e4-9798-216ad6d4aade","exon":"EXON1bd1a002-966b-4c1d-bb0a-7f331038e5ae","position":461028552708332541,"strand":"-"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC89d4c113-ca01-4804-ad46-ac20a4762389","reportedNumReads":22},{"id":"RNAFusion_A2ML1-AS2_AARS1P1","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000256904","hgncId":"HGNC:41523","symbol":"A2ML1-AS2","name":"A2ML1 antisense RNA 2"},"transcriptId":"TID40d495d1-f498-4b7d-8e71-e3c90af68e58","exon":"EXON23c9ccaa-ea43-444c-8340-f18365c32e4e","position":6743012027657999130,"strand":"+"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000249038","hgncId":"HGNC:49894","symbol":"AARS1P1","name":"alanyl-tRNA synthetase 1 pseudogene 1"},"transcriptId":"TIDb4337aef-0a69-483c-a603-9e3d6beefff6","exon":"EXON6980165c-6f85-4fd3-8c66-4349187f6382","position":4014336725221699201,"strand":"-"},"effect":"RNA Fusion effect...","cosmicId":"COSMICb82c4810-5629-4ea3-b569-3fd9c7167752","reportedNumReads":25},{"id":"RNAFusion_ABCA9_ABCB10P1","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000154258","hgncId":"HGNC:39","symbol":"ABCA9","name":"ATP binding cassette subfamily A member 9"},"transcriptId":"TIDfbbc5ac0-1d3f-476a-a1ac-4ed1456528ba","exon":"EXONf97da06b-9d7d-48c0-b202-d58a92d57e22","position":7451485235432246024,"strand":"-"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000274099","hgncId":"HGNC:14114","symbol":"ABCB10P1","name":"ABCB10 pseudogene 1"},"transcriptId":"TID3154eb7b-4364-4394-a128-75d7eb7174a0","exon":"EXON22661f53-bfb7-4b8a-9e5f-ca0b05cda801","position":2144193097707360397,"strand":"+"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC42bebec2-a56e-4b55-9dc6-986d1b421404","reportedNumReads":36},{"id":"RNAFusion_A2M-AS1_AAR2","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000245105","hgncId":"HGNC:27057","symbol":"A2M-AS1","name":"A2M antisense RNA 1"},"transcriptId":"TID9a631927-221f-455b-b3af-b7ed8204f8ce","exon":"EXON1ae4eead-6101-4f79-b09e-2289476ec75e","position":8438088205780109333,"strand":"-"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000131043","hgncId":"HGNC:15886","symbol":"AAR2","name":"AAR2 splicing factor"},"transcriptId":"TIDe66203bb-2b54-432c-8eb9-41b81f712602","exon":"EXONfe1ed045-ddfa-4a7d-b0c8-6f08a2bc7249","position":4758763569629035168,"strand":"+"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC6f5c8a08-fa74-41da-9a3e-c5965a8d6978","reportedNumReads":32},{"id":"RNAFusion_ABCB7_AADAT","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000131269","hgncId":"HGNC:48","symbol":"ABCB7","name":"ATP binding cassette subfamily B member 7"},"transcriptId":"TID78736fa2-71a0-4dc3-9675-e497da53a019","exon":"EXON6cd359fc-87e1-41b8-90be-9ca397aff1af","position":7631148909251407357,"strand":"+"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000109576","hgncId":"HGNC:17929","symbol":"AADAT","name":"aminoadipate aminotransferase"},"transcriptId":"TIDf79fa95a-c199-4568-959f-14f3baaea274","exon":"EXON98a11577-0325-4199-8b6f-1c8207b4d3d4","position":5619672559603971,"strand":"-"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC8a43fd3b-3df4-414d-8520-571c3ad2cf77","reportedNumReads":49}],"rnaSeqs":[{"id":"RNASeq_8a6e6d50-bfd8-4c29-9003-1937ba3cc9b3","entrezId":"EID8a6e6d50-bfd8-4c29-9003-1937ba3cc9b3","ensemblId":"ENS0fcf6c3b-6389-42a8-a8d3-3e724feb302c","gene":{"ensemblId":"ENSENSG00000129673","hgncId":"HGNC:19","symbol":"AANAT","name":"aralkylamine N-acetyltransferase"},"transcriptId":"TID16c9f024-d10b-48fc-b3eb-fe21455f2016","fragmentsPerKilobaseMillion":0.52,"fromNGS":false,"tissueCorrectedExpression":true,"rawCounts":393,"librarySize":97,"cohortRanking":2},{"id":"RNASeq_0ae924be-d9c2-42e2-b9eb-c946bf768da4","entrezId":"EID0ae924be-d9c2-42e2-b9eb-c946bf768da4","ensemblId":"ENS347f84b9-6147-4336-bba8-bb5e8f32091d","gene":{"ensemblId":"ENSENSG00000260053","hgncId":"HGNC:31130","symbol":"ABCB10P4","name":"ABCB10 pseudogene 4"},"transcriptId":"TID68d3f08f-68c5-4c4e-a868-2a91bf6a74ef","fragmentsPerKilobaseMillion":0.5,"fromNGS":true,"tissueCorrectedExpression":false,"rawCounts":410,"librarySize":82,"cohortRanking":5},{"id":"RNASeq_4d00e0d0-76be-4071-ab10-2d1b6fd2bf32","entrezId":"EID4d00e0d0-76be-4071-ab10-2d1b6fd2bf32","ensemblId":"ENS7b768481-e129-4f87-8bff-815dc5449f58","gene":{"ensemblId":"ENSENSG00000256661","hgncId":"HGNC:41022","symbol":"A2ML1-AS1","name":"A2ML1 antisense RNA 1"},"transcriptId":"TID488b86f2-668b-4d75-a784-8267757b5cdd","fragmentsPerKilobaseMillion":0.55,"fromNGS":true,"tissueCorrectedExpression":false,"rawCounts":968,"librarySize":47,"cohortRanking":2},{"id":"RNASeq_ceeb241f-6dbd-4e55-9c34-666c44e46405","entrezId":"EIDceeb241f-6dbd-4e55-9c34-666c44e46405","ensemblId":"ENS5a0783cb-48ff-4cde-98b1-e2b8ea31a9f4","gene":{"ensemblId":"ENSENSG00000141338","hgncId":"HGNC:38","symbol":"ABCA8","name":"ATP binding cassette subfamily A member 8"},"transcriptId":"TID972fda4e-a47b-43b0-b574-9dc688d668f5","fragmentsPerKilobaseMillion":0.71,"fromNGS":true,"tissueCorrectedExpression":true,"rawCounts":294,"librarySize":35,"cohortRanking":3},{"id":"RNASeq_475d891e-030f-490e-b741-030b965877c0","entrezId":"EID475d891e-030f-490e-b741-030b965877c0","ensemblId":"ENS66d9d63f-8b6e-4de7-baa3-e6be55497c77","gene":{"ensemblId":"ENSENSG00000197150","hgncId":"HGNC:49","symbol":"ABCB8","name":"ATP binding cassette subfamily B member 8"},"transcriptId":"TID7af2745c-811f-481a-a288-81456117a9fa","fragmentsPerKilobaseMillion":0.04,"fromNGS":true,"tissueCorrectedExpression":false,"rawCounts":371,"librarySize":68,"cohortRanking":8}]}],"carePlans":[{"id":"6a3601ea-8fba-437d-8add-bf4f4cce469e","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","issuedOn":"2023-12-14","description":"MTB conference protocol...","recommendations":["df376556-df45-41c3-8bae-af1fe3fb7418","08234e1e-105c-4362-87e8-4f20bf87ed0b"],"geneticCounsellingRequest":"65344fca-0028-4129-a530-dd36fd984bd3","rebiopsyRequests":["5f54fb43-92a5-4f62-ae48-081d428ff2e8"],"studyInclusionRequests":["d49b41ff-e2df-499f-938f-8fb7136366b2"]}],"recommendations":[{"id":"df376556-df45-41c3-8bae-af1fe3fb7418","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","issuedOn":"2023-12-14","medication":[{"code":"L01XE39","system":"ATC","display":"Midostaurin","version":"2020"},{"code":"L01XX42","system":"ATC","display":"Panobinostat","version":"2020"}],"priority":"4","levelOfEvidence":{"grading":{"code":"m1B","system":"MTB-CDS:Level-of-Evidence:Grading"},"addendums":[{"code":"R","system":"MTB-CDS:Level-of-Evidence:Addendum"},{"code":"is","system":"MTB-CDS:Level-of-Evidence:Addendum"}]},"ngsReport":"223e3e6e-d99b-415e-83c0-18bcfc5729ca","supportingVariants":["CNV_AAGAB_ABCA6_high-level-gain","CNV_AAMP_AADACL3_low-level-gain","SNV_7f35df9d-d8b1-4421-9ec7-35cf8d3c4546"]},{"id":"08234e1e-105c-4362-87e8-4f20bf87ed0b","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","issuedOn":"2023-12-14","medication":[{"code":"L01XX42","system":"ATC","display":"Panobinostat","version":"2020"},{"code":"L01XE12","system":"ATC","display":"Vandetanib","version":"2020"}],"priority":"4","levelOfEvidence":{"grading":{"code":"m1A","system":"MTB-CDS:Level-of-Evidence:Grading"},"addendums":[{"code":"R","system":"MTB-CDS:Level-of-Evidence:Addendum"},{"code":"is","system":"MTB-CDS:Level-of-Evidence:Addendum"},{"code":"iv","system":"MTB-CDS:Level-of-Evidence:Addendum"}]},"ngsReport":"223e3e6e-d99b-415e-83c0-18bcfc5729ca","supportingVariants":["CNV_AAGAB_ABCA6_high-level-gain","CNV_ABCA12_AASDHPPT_ABCC2_AARS1P1_high-level-gain","SNV_963a3ea1-a1d5-4a52-b8d4-2e969be74b7b","SNV_7f35df9d-d8b1-4421-9ec7-35cf8d3c4546","CNV_AAAS_AADAT_high-level-gain","CNV_ABALON_ABCA17P_high-level-gain","CNV_AAMP_AADACL3_low-level-gain","SNV_22f943fd-ad99-48af-a61e-42679e851b71","SNV_599c38d4-af9f-416c-ad38-eb2fee8159e1"]}],"geneticCounsellingRequests":[{"id":"65344fca-0028-4129-a530-dd36fd984bd3","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","reason":"Some reason for genetic counselling..."}],"rebiopsyRequests":[{"id":"5f54fb43-92a5-4f62-ae48-081d428ff2e8","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","issuedOn":"2023-12-14"}],"histologyReevaluationRequests":[{"id":"b76dfb95-13e8-4acb-9ab8-364bc5215d63","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","issuedOn":"2023-12-14"}],"studyInclusionRequests":[{"id":"d49b41ff-e2df-499f-938f-8fb7136366b2","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","reason":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","nctNumber":"NCT84044685","issuedOn":"2023-12-14"}],"claims":[{"id":"2280a457-c5b8-4541-b6ca-86de385d789f","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","therapy":"df376556-df45-41c3-8bae-af1fe3fb7418"},{"id":"061fde0a-935c-4e2f-a54a-531679b2f8d5","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","therapy":"08234e1e-105c-4362-87e8-4f20bf87ed0b"}],"claimResponses":[{"id":"1177f670-cf44-4886-b9b3-a4dd25271dcb","claim":"2280a457-c5b8-4541-b6ca-86de385d789f","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","status":"accepted","reason":"standard-therapy-not-exhausted"},{"id":"c85d365d-e3c1-474b-aaa3-0e3e051d4223","claim":"061fde0a-935c-4e2f-a54a-531679b2f8d5","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","status":"rejected","reason":"other"}],"molecularTherapies":[{"history":[{"id":"88801f10-9f77-4a5d-adc1-47dd97b7e9ea","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","recordedOn":"2023-12-14","basedOn":"df376556-df45-41c3-8bae-af1fe3fb7418","period":{"start":"2023-12-14","end":"2023-12-14"},"medication":[{"code":"L01XE39","system":"ATC","display":"Midostaurin","version":"2020"},{"code":"L01XX42","system":"ATC","display":"Panobinostat","version":"2020"}],"dosage":">=50%","reasonStopped":{"code":"medical-reason","system":"MTB-CDS:MolecularTherapy:StopReason"},"note":"Notes on the Therapy...","status":"stopped"}]},{"history":[{"id":"660813fd-a42d-492a-8522-9c4aa3b3e162","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","recordedOn":"2023-12-14","basedOn":"08234e1e-105c-4362-87e8-4f20bf87ed0b","period":{"start":"2023-12-14","end":"2023-12-14"},"medication":[{"code":"L01XX42","system":"ATC","display":"Panobinostat","version":"2020"},{"code":"L01XE12","system":"ATC","display":"Vandetanib","version":"2020"}],"dosage":"<50%","note":"Notes on the Therapy...","status":"completed"}]}],"responses":[{"id":"267cddc7-50fd-43e6-90e6-a7f2806c7da2","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","therapy":"88801f10-9f77-4a5d-adc1-47dd97b7e9ea","effectiveDate":"2023-12-14","value":{"code":"SD","system":"RECIST"}},{"id":"8b2af33e-afee-450b-b947-3370d89603f8","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","therapy":"660813fd-a42d-492a-8522-9c4aa3b3e162","effectiveDate":"2023-12-14","value":{"code":"CR","system":"RECIST"}}]} \ No newline at end of file +{"patient":{"id":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","gender":"female","birthDate":"1971-03","insurance":"Barmer"},"consent":{"id":"b93e4717-7b0e-4ca5-a5d6-cf8d0f7b14cf","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","status":"active"},"episode":{"id":"8ddb893f-0d55-412f-a257-9bc8bb054549","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","period":{"start":"2021-12-29"}},"diagnoses":[{"id":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","recordedOn":"2023-12-14","icd10":{"code":"C16.0","display":"Bösartige Neubildung: Kardia","version":"2023","system":"ICD-10-GM"},"whoGrade":{"code":"3", "version":"2021", "system":"WHO-Grading-CNS-Tumors"},"histologyResults":["385926d9-51f2-4a3a-96b6-57c5effefd84"],"statusHistory":[{"status":"local","date":"2023-12-14"},{"status":"unknown","date":"2023-12-14"}],"guidelineTreatmentStatus":"no-guidelines-available"}],"familyMemberDiagnoses":[{"id":"c434f063-76d9-4a7f-8ff2-34cd55fc56b2","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","relationship":{"code":"EXT","system":"http://terminology.hl7.org/ValueSet/v3-FamilyMember"}},{"id":"b07a73a8-70ed-48f8-a745-e82e7a5907a8","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","relationship":{"code":"EXT","system":"http://terminology.hl7.org/ValueSet/v3-FamilyMember"}}],"previousGuidelineTherapies":[{"id":"6435d684-18e3-45ad-b063-cdc303f61aa2","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","therapyLine":4,"medication":[{"code":"L01XC18","system":"ATC","display":"Pembrolizumab","version":"2020"},{"code":"L01XE21","system":"ATC","display":"Regorafenib","version":"2020"},{"code":"L01XC17","system":"ATC","display":"Nivolumab","version":"2020"}]},{"id":"e35731db-7447-4c4b-896a-0e90f1e68c67","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","therapyLine":4,"medication":[{"code":"L01XX46","system":"ATC","display":"Olaparib","version":"2020"},{"code":"L01XX32","system":"ATC","display":"Bortezomib","version":"2020"},{"code":"L01XE02","system":"ATC","display":"Gefitinib","version":"2020"}]},{"id":"d8a46cf9-cdc9-4f0d-b106-5aa57e08c4d0","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","therapyLine":1,"medication":[{"code":"L01XC28","system":"ATC","display":"Durvalumab","version":"2020"},{"code":"L01XC06","system":"ATC","display":"Cetuximab","version":"2020"},{"code":"L01XE47","system":"ATC","display":"Dacomitinib","version":"2020"}]}],"lastGuidelineTherapies":[{"id":"9152b20d-ac04-406c-b18b-5b6f7f4d1911","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","therapyLine":1,"period":{"start":"2023-12-14","end":"2024-01-04"},"medication":[{"code":"L01DB01","system":"ATC","display":"Doxorubicin","version":"2020"}],"reasonStopped":{"code":"toxicity","system":"MTB-CDS:GuidelineTherapy-StopReason"}}],"ecogStatus":[{"id":"51589dd2-f48d-4c41-8740-292b88d63b30","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","effectiveDate":"2023-12-14","value":{"code":"2","system":"ECOG-Performance-Status"}},{"id":"30caf153-a30a-4a1c-9056-fd4eae2a55da","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","effectiveDate":"2023-12-14","value":{"code":"4","system":"ECOG-Performance-Status"}}],"specimens":[{"id":"40043ae5-d4cb-48e4-85c6-b34266b7693f","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","icd10":{"code":"C16.0","display":"Bösartige Neubildung: Kardia","version":"2023","system":"ICD-10-GM"},"type":"liquid-biopsy","collection":{"date":"2023-12-14","localization":"unknown","method":"liquid-biopsy"}}],"molecularPathologyFindings":[{"id":"c7622342-3297-489e-850a-26aaf1225b36","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","performingInstitute":"TESTINSTITUTE","issuedOn":"2023-12-14","note":"MolecularPathologyFinding notes..."}],"histologyReports":[{"id":"385926d9-51f2-4a3a-96b6-57c5effefd84","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","issuedOn":"2023-12-14","tumorMorphology":{"id":"592b13c7-9507-4f31-a544-5cff90e35581","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","value":{"code":"8851/3","display":"Gut differenziertes Liposarkom","version":"Zweite Revision","system":"ICD-O-3-M"},"note":"Histology finding notes..."},"tumorCellContent":{"id":"f6af339c-415c-4682-b700-499e392b4558","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","method":"histologic","value":0.38164}}],"ngsReports":[{"id":"223e3e6e-d99b-415e-83c0-18bcfc5729ca","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","issueDate":"2023-12-14","sequencingType":"WGS","metadata":[{"kitType":"Agilent ExomV6","kitManufacturer":"Agilent","sequencer":"Sequencer-XYZ","referenceGenome":"HG19","pipeline":"dummy/uri/to/pipeline"}],"tumorCellContent":{"id":"e865f20a-1307-4ca3-b2ef-3a863b8afde0","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","method":"bioinformatic","value":0.74991},"brcaness":0.39,"msi":0.35,"tmb":594349.91,"simpleVariants":[{"id":"SNV_599c38d4-af9f-416c-ad38-eb2fee8159e1","chromosome":"chr6","gene":{"ensemblId":"ENSENSG00000242908","hgncId":"HGNC:50301","symbol":"AADACL2-AS1","name":"AADACL2 antisense RNA 1"},"startEnd":{"start":6736035388467870105},"refAllele":"A","altAllele":"G","dnaChange":{"code":"A>G","system":"HGVS.c"},"aminoAcidChange":{"code":"Tyr","system":"HGVS.p"},"readDepth":19,"allelicFrequency":0.75,"cosmicId":"COSMICf106c745-dbaa-453f-8dca-2f584bc1e6cb","dbSNPId":"DBSNPIDc3f51fb2-31f3-4b27-bbcc-aac52736986f","interpretation":{"code":"Probably Inactivating","system":"ClinVAR"}},{"id":"SNV_963a3ea1-a1d5-4a52-b8d4-2e969be74b7b","chromosome":"chr2","gene":{"ensemblId":"ENSENSG00000141338","hgncId":"HGNC:38","symbol":"ABCA8","name":"ATP binding cassette subfamily A member 8"},"startEnd":{"start":1672464855319477743},"refAllele":"T","altAllele":"C","dnaChange":{"code":"Tyr","system":"HGVS.c"},"aminoAcidChange":{"code":"Tyr","system":"HGVS.p"},"readDepth":23,"allelicFrequency":0.36,"cosmicId":"COSMICbcbc96bb-0428-48c9-8c64-7d2fd884528d","dbSNPId":"DBSNPIDc4904618-819c-4af1-b793-ca9d820371dc","interpretation":{"code":"Ambiguous","system":"ClinVAR"}},{"id":"SNV_7f35df9d-d8b1-4421-9ec7-35cf8d3c4546","chromosome":"chr5","gene":{"ensemblId":"ENSENSG00000149313","hgncId":"HGNC:14235","symbol":"AASDHPPT","name":"aminoadipate-semialdehyde dehydrogenase-phosphopantetheinyl transferase"},"startEnd":{"start":4251323878559029469},"refAllele":"A","altAllele":"C","dnaChange":{"code":"A>C","system":"HGVS.c"},"aminoAcidChange":{"code":"Tyr","system":"HGVS.p"},"readDepth":37,"allelicFrequency":0.48,"cosmicId":"COSMICc3c67469-2303-4cba-9b45-424d62a0d3db","dbSNPId":"DBSNPIDd94cb5ce-250a-471f-a571-015fe8a711c9","interpretation":{"code":"Function Changed","system":"ClinVAR"}},{"id":"SNV_22f943fd-ad99-48af-a61e-42679e851b71","chromosome":"chr2","gene":{"ensemblId":"ENSENSG00000167972","hgncId":"HGNC:33","symbol":"ABCA3","name":"ATP binding cassette subfamily A member 3"},"startEnd":{"start":7454627449124699972},"refAllele":"A","altAllele":"C","dnaChange":{"code":"A>C","system":"HGVS.c"},"aminoAcidChange":{"code":"Tyr","system":"HGVS.p"},"readDepth":22,"allelicFrequency":0.37,"cosmicId":"COSMICa6074760-ce24-4075-ab11-f5ab4cd6c497","dbSNPId":"DBSNPID3578fa1b-eb03-423c-929d-6705cd8e805c","interpretation":{"code":"Function Changed","system":"ClinVAR"}},{"id":"SNV_fe346bed-74ac-4b84-843b-7490a5823364","chromosome":"chr14","gene":{"ensemblId":"ENSENSG00000125257","hgncId":"HGNC:55","symbol":"ABCC4","name":"ATP binding cassette subfamily C member 4"},"startEnd":{"start":6478613836523717707},"refAllele":"G","altAllele":"T","dnaChange":{"code":"G>T","system":"HGVS.c"},"aminoAcidChange":{"code":"Tyr","system":"HGVS.p"},"readDepth":30,"allelicFrequency":0.47,"cosmicId":"COSMIC14d8842e-6af9-434f-a993-32fae87a84be","dbSNPId":"DBSNPID1a97211e-a6f6-44be-a909-3620f34b01e2","interpretation":{"code":"Ambiguous","system":"ClinVAR"}}],"copyNumberVariants":[{"id":"CNV_ABALON_ABCA17P_high-level-gain","chromosome":"chr8","startRange":{"start":969911792064545275,"end":969911792064546084},"endRange":{"start":4404138220928659257,"end":4404138220928659925},"totalCopyNumber":2,"relativeCopyNumber":0.18,"cnA":0.87,"cnB":0.49,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000281376","hgncId":"HGNC:49667","symbol":"ABALON","name":"apoptotic BCL2L1-antisense long non-coding RNA"},{"ensemblId":"ENSENSG00000238098","hgncId":"HGNC:32972","symbol":"ABCA17P","name":"ATP binding cassette subfamily A member 17, pseudogene"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000115657","hgncId":"HGNC:47","symbol":"ABCB6","name":"ATP binding cassette subfamily B member 6 (Langereis blood group)"},{"ensemblId":"ENSENSG00000184389","hgncId":"HGNC:30005","symbol":"A3GALT2","name":"alpha 1,3-galactosyltransferase 2"},{"ensemblId":"ENSENSG00000023839","hgncId":"HGNC:53","symbol":"ABCC2","name":"ATP binding cassette subfamily C member 2"},{"ensemblId":"ENSENSG00000127837","hgncId":"HGNC:18","symbol":"AAMP","name":"angio associated migratory cell protein"}]},{"id":"CNV_AAGAB_ABCA6_high-level-gain","chromosome":"chr3","startRange":{"start":6843968935032545040,"end":6843968935032545924},"endRange":{"start":3583631517115538627,"end":3583631517115539281},"totalCopyNumber":4,"relativeCopyNumber":0.11,"cnA":0.15,"cnB":0.29,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000103591","hgncId":"HGNC:25662","symbol":"AAGAB","name":"alpha and gamma adaptin binding protein"},{"ensemblId":"ENSENSG00000154262","hgncId":"HGNC:36","symbol":"ABCA6","name":"ATP binding cassette subfamily A member 6"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000231749","hgncId":"HGNC:39983","symbol":"ABCA9-AS1","name":"ABCA9 antisense RNA 1"},{"ensemblId":"ENSENSG00000154265","hgncId":"HGNC:35","symbol":"ABCA5","name":"ATP binding cassette subfamily A member 5"},{"ensemblId":"ENSENSG00000081760","hgncId":"HGNC:21298","symbol":"AACS","name":"acetoacetyl-CoA synthetase"}]},{"id":"CNV_ABCA12_AASDHPPT_ABCC2_AARS1P1_high-level-gain","chromosome":"chr5","startRange":{"start":8487172994898555456,"end":8487172994898556127},"endRange":{"start":2329045896118581347,"end":2329045896118581894},"totalCopyNumber":3,"relativeCopyNumber":0.67,"cnA":0.47,"cnB":0.46,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000144452","hgncId":"HGNC:14637","symbol":"ABCA12","name":"ATP binding cassette subfamily A member 12"},{"ensemblId":"ENSENSG00000149313","hgncId":"HGNC:14235","symbol":"AASDHPPT","name":"aminoadipate-semialdehyde dehydrogenase-phosphopantetheinyl transferase"},{"ensemblId":"ENSENSG00000023839","hgncId":"HGNC:53","symbol":"ABCC2","name":"ATP binding cassette subfamily C member 2"},{"ensemblId":"ENSENSG00000249038","hgncId":"HGNC:49894","symbol":"AARS1P1","name":"alanyl-tRNA synthetase 1 pseudogene 1"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000141338","hgncId":"HGNC:38","symbol":"ABCA8","name":"ATP binding cassette subfamily A member 8"},{"ensemblId":"ENSENSG00000129673","hgncId":"HGNC:19","symbol":"AANAT","name":"aralkylamine N-acetyltransferase"},{"ensemblId":"ENSENSG00000242908","hgncId":"HGNC:50301","symbol":"AADACL2-AS1","name":"AADACL2 antisense RNA 1"}]},{"id":"CNV_AAAS_AADAT_high-level-gain","chromosome":"chr10","startRange":{"start":1954565432038993495,"end":1954565432038994133},"endRange":{"start":442085989067090995,"end":442085989067091164},"totalCopyNumber":4,"relativeCopyNumber":0.37,"cnA":0.98,"cnB":0.18,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000094914","hgncId":"HGNC:13666","symbol":"AAAS","name":"aladin WD repeat nucleoporin"},{"ensemblId":"ENSENSG00000109576","hgncId":"HGNC:17929","symbol":"AADAT","name":"aminoadipate aminotransferase"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000023839","hgncId":"HGNC:53","symbol":"ABCC2","name":"ATP binding cassette subfamily C member 2"},{"ensemblId":"ENSENSG00000150967","hgncId":"HGNC:50","symbol":"ABCB9","name":"ATP binding cassette subfamily B member 9"},{"ensemblId":"ENSENSG00000149313","hgncId":"HGNC:14235","symbol":"AASDHPPT","name":"aminoadipate-semialdehyde dehydrogenase-phosphopantetheinyl transferase"}]},{"id":"CNV_A3GALT2_ABCB10P4_low-level-gain","chromosome":"chr8","startRange":{"start":1779205446909981075,"end":1779205446909981845},"endRange":{"start":3151805846500148631,"end":3151805846500149455},"totalCopyNumber":4,"relativeCopyNumber":0.94,"cnA":0.3,"cnB":0.66,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000184389","hgncId":"HGNC:30005","symbol":"A3GALT2","name":"alpha 1,3-galactosyltransferase 2"},{"ensemblId":"ENSENSG00000260053","hgncId":"HGNC:31130","symbol":"ABCB10P4","name":"ABCB10 pseudogene 4"}],"reportedFocality":"reported-focality...","type":"low-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000167972","hgncId":"HGNC:33","symbol":"ABCA3","name":"ATP binding cassette subfamily A member 3"},{"ensemblId":"ENSENSG00000231749","hgncId":"HGNC:39983","symbol":"ABCA9-AS1","name":"ABCA9 antisense RNA 1"},{"ensemblId":"ENSENSG00000108846","hgncId":"HGNC:54","symbol":"ABCC3","name":"ATP binding cassette subfamily C member 3"},{"ensemblId":"ENSENSG00000197953","hgncId":"HGNC:24427","symbol":"AADACL2","name":"arylacetamide deacetylase like 2"}]},{"id":"CNV_ABCA10_AADACL2_high-level-gain","chromosome":"chrX","startRange":{"start":165156786091954061,"end":165156786091954176},"endRange":{"start":5591033364020511004,"end":5591033364020511607},"totalCopyNumber":4,"relativeCopyNumber":0.69,"cnA":0.56,"cnB":0.12,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000154263","hgncId":"HGNC:30","symbol":"ABCA10","name":"ATP binding cassette subfamily A member 10"},{"ensemblId":"ENSENSG00000197953","hgncId":"HGNC:24427","symbol":"AADACL2","name":"arylacetamide deacetylase like 2"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000215458","hgncId":"HGNC:51526","symbol":"AATBC","name":"apoptosis associated transcript in bladder cancer"},{"ensemblId":"ENSENSG00000249038","hgncId":"HGNC:49894","symbol":"AARS1P1","name":"alanyl-tRNA synthetase 1 pseudogene 1"},{"ensemblId":"ENSENSG00000261524","hgncId":"HGNC:31129","symbol":"ABCB10P3","name":"ABCB10 pseudogene 3"}]},{"id":"CNV_AAMP_AADACL3_low-level-gain","chromosome":"chrX","startRange":{"start":7552444878262806955,"end":7552444878262807187},"endRange":{"start":140089034030783731,"end":140089034030784407},"totalCopyNumber":2,"relativeCopyNumber":0.57,"cnA":0.26,"cnB":0.82,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000127837","hgncId":"HGNC:18","symbol":"AAMP","name":"angio associated migratory cell protein"},{"ensemblId":"ENSENSG00000188984","hgncId":"HGNC:32037","symbol":"AADACL3","name":"arylacetamide deacetylase like 3"}],"reportedFocality":"reported-focality...","type":"low-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000121410","hgncId":"HGNC:5","symbol":"A1BG","name":"alpha-1-B glycoprotein"},{"ensemblId":"ENSENSG00000175899","hgncId":"HGNC:7","symbol":"A2M","name":"alpha-2-macroglobulin"},{"ensemblId":"ENSENSG00000257408","hgncId":"HGNC:55707","symbol":"ABCA3P1","name":"ABCA3 pseudogene 1"},{"ensemblId":"ENSENSG00000005471","hgncId":"HGNC:45","symbol":"ABCB4","name":"ATP binding cassette subfamily B member 4"}]}],"dnaFusions":[{"id":"DNAFusion_A3GALT2_AAGAB","fusionPartner5prime":{"chromosome":"chr16","position":569568638299166051,"gene":{"ensemblId":"ENSENSG00000184389","hgncId":"HGNC:30005","symbol":"A3GALT2","name":"alpha 1,3-galactosyltransferase 2"}},"fusionPartner3prime":{"chromosome":"chr1","position":1728963273084125905,"gene":{"ensemblId":"ENSENSG00000103591","hgncId":"HGNC:25662","symbol":"AAGAB","name":"alpha and gamma adaptin binding protein"}},"reportedNumReads":25},{"id":"DNAFusion_ABCA3_AASDHPPT","fusionPartner5prime":{"chromosome":"chr12","position":4142955940382701892,"gene":{"ensemblId":"ENSENSG00000167972","hgncId":"HGNC:33","symbol":"ABCA3","name":"ATP binding cassette subfamily A member 3"}},"fusionPartner3prime":{"chromosome":"chr13","position":2530447494476677762,"gene":{"ensemblId":"ENSENSG00000149313","hgncId":"HGNC:14235","symbol":"AASDHPPT","name":"aminoadipate-semialdehyde dehydrogenase-phosphopantetheinyl transferase"}},"reportedNumReads":29},{"id":"DNAFusion_ABCB10P3_AAAS","fusionPartner5prime":{"chromosome":"chr19","position":216619303235013143,"gene":{"ensemblId":"ENSENSG00000261524","hgncId":"HGNC:31129","symbol":"ABCB10P3","name":"ABCB10 pseudogene 3"}},"fusionPartner3prime":{"chromosome":"chr20","position":7983660439294503113,"gene":{"ensemblId":"ENSENSG00000094914","hgncId":"HGNC:13666","symbol":"AAAS","name":"aladin WD repeat nucleoporin"}},"reportedNumReads":30},{"id":"DNAFusion_A1BG_AADACL4","fusionPartner5prime":{"chromosome":"chr19","position":2761782759714541191,"gene":{"ensemblId":"ENSENSG00000121410","hgncId":"HGNC:5","symbol":"A1BG","name":"alpha-1-B glycoprotein"}},"fusionPartner3prime":{"chromosome":"chr15","position":2381966877469433813,"gene":{"ensemblId":"ENSENSG00000204518","hgncId":"HGNC:32038","symbol":"AADACL4","name":"arylacetamide deacetylase like 4"}},"reportedNumReads":22},{"id":"DNAFusion_AAMP_ABCB9","fusionPartner5prime":{"chromosome":"chr12","position":2738282492147015127,"gene":{"ensemblId":"ENSENSG00000127837","hgncId":"HGNC:18","symbol":"AAMP","name":"angio associated migratory cell protein"}},"fusionPartner3prime":{"chromosome":"chr18","position":4689414126579295665,"gene":{"ensemblId":"ENSENSG00000150967","hgncId":"HGNC:50","symbol":"ABCB9","name":"ATP binding cassette subfamily B member 9"}},"reportedNumReads":44},{"id":"DNAFusion_AACS_ABCB10","fusionPartner5prime":{"chromosome":"chr19","position":5162788528310959454,"gene":{"ensemblId":"ENSENSG00000081760","hgncId":"HGNC:21298","symbol":"AACS","name":"acetoacetyl-CoA synthetase"}},"fusionPartner3prime":{"chromosome":"chr2","position":281250493569316672,"gene":{"ensemblId":"ENSENSG00000135776","hgncId":"HGNC:41","symbol":"ABCB10","name":"ATP binding cassette subfamily B member 10"}},"reportedNumReads":28},{"id":"DNAFusion_ABCA3_ABCA8","fusionPartner5prime":{"chromosome":"chr17","position":7239027143174816791,"gene":{"ensemblId":"ENSENSG00000167972","hgncId":"HGNC:33","symbol":"ABCA3","name":"ATP binding cassette subfamily A member 3"}},"fusionPartner3prime":{"chromosome":"chr20","position":3415200056745807403,"gene":{"ensemblId":"ENSENSG00000141338","hgncId":"HGNC:38","symbol":"ABCA8","name":"ATP binding cassette subfamily A member 8"}},"reportedNumReads":47},{"id":"DNAFusion_AATF_AASS","fusionPartner5prime":{"chromosome":"chr14","position":6207520478306983467,"gene":{"ensemblId":"ENSENSG00000275700","hgncId":"HGNC:19235","symbol":"AATF","name":"apoptosis antagonizing transcription factor"}},"fusionPartner3prime":{"chromosome":"chr7","position":966733822586135931,"gene":{"ensemblId":"ENSENSG00000008311","hgncId":"HGNC:17366","symbol":"AASS","name":"aminoadipate-semialdehyde synthase"}},"reportedNumReads":32},{"id":"DNAFusion_AATBC_AARD","fusionPartner5prime":{"chromosome":"chr9","position":6948858453904558539,"gene":{"ensemblId":"ENSENSG00000215458","hgncId":"HGNC:51526","symbol":"AATBC","name":"apoptosis associated transcript in bladder cancer"}},"fusionPartner3prime":{"chromosome":"chr21","position":5188489683141511746,"gene":{"ensemblId":"ENSENSG00000205002","hgncId":"HGNC:33842","symbol":"AARD","name":"alanine and arginine rich domain containing protein"}},"reportedNumReads":21}],"rnaFusions":[{"id":"RNAFusion_A3GALT2_ABCB6","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000184389","hgncId":"HGNC:30005","symbol":"A3GALT2","name":"alpha 1,3-galactosyltransferase 2"},"transcriptId":"TIDf283c026-41bf-41c6-afd5-1980bd408a06","exon":"EXONc9f963b3-4e55-4f1f-9ad1-e79b86e6e751","position":1009212469473862062,"strand":"+"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000115657","hgncId":"HGNC:47","symbol":"ABCB6","name":"ATP binding cassette subfamily B member 6 (Langereis blood group)"},"transcriptId":"TID97eabd37-cf0e-44f3-a1f1-d21543eb3b5d","exon":"EXONc9077d54-f82f-42bb-8bc8-e79e46204085","position":6271859040431005877,"strand":"-"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC28f0a2f7-a923-4622-a9e4-52ecd5806066","reportedNumReads":29},{"id":"RNAFusion_ABCB10P4_A2ML1-AS1","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000260053","hgncId":"HGNC:31130","symbol":"ABCB10P4","name":"ABCB10 pseudogene 4"},"transcriptId":"TID2cd74611-6960-455f-9946-3962aadbbd56","exon":"EXONf7f8f171-248f-44de-9408-37a3bfa75b4f","position":9124790507143722597,"strand":"+"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000256661","hgncId":"HGNC:41022","symbol":"A2ML1-AS1","name":"A2ML1 antisense RNA 1"},"transcriptId":"TIDb16fc5d0-e8ed-40e4-9798-216ad6d4aade","exon":"EXON1bd1a002-966b-4c1d-bb0a-7f331038e5ae","position":461028552708332541,"strand":"-"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC89d4c113-ca01-4804-ad46-ac20a4762389","reportedNumReads":22},{"id":"RNAFusion_A2ML1-AS2_AARS1P1","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000256904","hgncId":"HGNC:41523","symbol":"A2ML1-AS2","name":"A2ML1 antisense RNA 2"},"transcriptId":"TID40d495d1-f498-4b7d-8e71-e3c90af68e58","exon":"EXON23c9ccaa-ea43-444c-8340-f18365c32e4e","position":6743012027657999130,"strand":"+"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000249038","hgncId":"HGNC:49894","symbol":"AARS1P1","name":"alanyl-tRNA synthetase 1 pseudogene 1"},"transcriptId":"TIDb4337aef-0a69-483c-a603-9e3d6beefff6","exon":"EXON6980165c-6f85-4fd3-8c66-4349187f6382","position":4014336725221699201,"strand":"-"},"effect":"RNA Fusion effect...","cosmicId":"COSMICb82c4810-5629-4ea3-b569-3fd9c7167752","reportedNumReads":25},{"id":"RNAFusion_ABCA9_ABCB10P1","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000154258","hgncId":"HGNC:39","symbol":"ABCA9","name":"ATP binding cassette subfamily A member 9"},"transcriptId":"TIDfbbc5ac0-1d3f-476a-a1ac-4ed1456528ba","exon":"EXONf97da06b-9d7d-48c0-b202-d58a92d57e22","position":7451485235432246024,"strand":"-"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000274099","hgncId":"HGNC:14114","symbol":"ABCB10P1","name":"ABCB10 pseudogene 1"},"transcriptId":"TID3154eb7b-4364-4394-a128-75d7eb7174a0","exon":"EXON22661f53-bfb7-4b8a-9e5f-ca0b05cda801","position":2144193097707360397,"strand":"+"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC42bebec2-a56e-4b55-9dc6-986d1b421404","reportedNumReads":36},{"id":"RNAFusion_A2M-AS1_AAR2","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000245105","hgncId":"HGNC:27057","symbol":"A2M-AS1","name":"A2M antisense RNA 1"},"transcriptId":"TID9a631927-221f-455b-b3af-b7ed8204f8ce","exon":"EXON1ae4eead-6101-4f79-b09e-2289476ec75e","position":8438088205780109333,"strand":"-"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000131043","hgncId":"HGNC:15886","symbol":"AAR2","name":"AAR2 splicing factor"},"transcriptId":"TIDe66203bb-2b54-432c-8eb9-41b81f712602","exon":"EXONfe1ed045-ddfa-4a7d-b0c8-6f08a2bc7249","position":4758763569629035168,"strand":"+"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC6f5c8a08-fa74-41da-9a3e-c5965a8d6978","reportedNumReads":32},{"id":"RNAFusion_ABCB7_AADAT","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000131269","hgncId":"HGNC:48","symbol":"ABCB7","name":"ATP binding cassette subfamily B member 7"},"transcriptId":"TID78736fa2-71a0-4dc3-9675-e497da53a019","exon":"EXON6cd359fc-87e1-41b8-90be-9ca397aff1af","position":7631148909251407357,"strand":"+"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000109576","hgncId":"HGNC:17929","symbol":"AADAT","name":"aminoadipate aminotransferase"},"transcriptId":"TIDf79fa95a-c199-4568-959f-14f3baaea274","exon":"EXON98a11577-0325-4199-8b6f-1c8207b4d3d4","position":5619672559603971,"strand":"-"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC8a43fd3b-3df4-414d-8520-571c3ad2cf77","reportedNumReads":49}],"rnaSeqs":[{"id":"RNASeq_8a6e6d50-bfd8-4c29-9003-1937ba3cc9b3","entrezId":"EID8a6e6d50-bfd8-4c29-9003-1937ba3cc9b3","ensemblId":"ENS0fcf6c3b-6389-42a8-a8d3-3e724feb302c","gene":{"ensemblId":"ENSENSG00000129673","hgncId":"HGNC:19","symbol":"AANAT","name":"aralkylamine N-acetyltransferase"},"transcriptId":"TID16c9f024-d10b-48fc-b3eb-fe21455f2016","fragmentsPerKilobaseMillion":0.52,"fromNGS":false,"tissueCorrectedExpression":true,"rawCounts":393,"librarySize":97,"cohortRanking":2},{"id":"RNASeq_0ae924be-d9c2-42e2-b9eb-c946bf768da4","entrezId":"EID0ae924be-d9c2-42e2-b9eb-c946bf768da4","ensemblId":"ENS347f84b9-6147-4336-bba8-bb5e8f32091d","gene":{"ensemblId":"ENSENSG00000260053","hgncId":"HGNC:31130","symbol":"ABCB10P4","name":"ABCB10 pseudogene 4"},"transcriptId":"TID68d3f08f-68c5-4c4e-a868-2a91bf6a74ef","fragmentsPerKilobaseMillion":0.5,"fromNGS":true,"tissueCorrectedExpression":false,"rawCounts":410,"librarySize":82,"cohortRanking":5},{"id":"RNASeq_4d00e0d0-76be-4071-ab10-2d1b6fd2bf32","entrezId":"EID4d00e0d0-76be-4071-ab10-2d1b6fd2bf32","ensemblId":"ENS7b768481-e129-4f87-8bff-815dc5449f58","gene":{"ensemblId":"ENSENSG00000256661","hgncId":"HGNC:41022","symbol":"A2ML1-AS1","name":"A2ML1 antisense RNA 1"},"transcriptId":"TID488b86f2-668b-4d75-a784-8267757b5cdd","fragmentsPerKilobaseMillion":0.55,"fromNGS":true,"tissueCorrectedExpression":false,"rawCounts":968,"librarySize":47,"cohortRanking":2},{"id":"RNASeq_ceeb241f-6dbd-4e55-9c34-666c44e46405","entrezId":"EIDceeb241f-6dbd-4e55-9c34-666c44e46405","ensemblId":"ENS5a0783cb-48ff-4cde-98b1-e2b8ea31a9f4","gene":{"ensemblId":"ENSENSG00000141338","hgncId":"HGNC:38","symbol":"ABCA8","name":"ATP binding cassette subfamily A member 8"},"transcriptId":"TID972fda4e-a47b-43b0-b574-9dc688d668f5","fragmentsPerKilobaseMillion":0.71,"fromNGS":true,"tissueCorrectedExpression":true,"rawCounts":294,"librarySize":35,"cohortRanking":3},{"id":"RNASeq_475d891e-030f-490e-b741-030b965877c0","entrezId":"EID475d891e-030f-490e-b741-030b965877c0","ensemblId":"ENS66d9d63f-8b6e-4de7-baa3-e6be55497c77","gene":{"ensemblId":"ENSENSG00000197150","hgncId":"HGNC:49","symbol":"ABCB8","name":"ATP binding cassette subfamily B member 8"},"transcriptId":"TID7af2745c-811f-481a-a288-81456117a9fa","fragmentsPerKilobaseMillion":0.04,"fromNGS":true,"tissueCorrectedExpression":false,"rawCounts":371,"librarySize":68,"cohortRanking":8}]}],"carePlans":[{"id":"6a3601ea-8fba-437d-8add-bf4f4cce469e","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","issuedOn":"2023-12-14","description":"MTB conference protocol...","recommendations":["df376556-df45-41c3-8bae-af1fe3fb7418","08234e1e-105c-4362-87e8-4f20bf87ed0b"],"geneticCounsellingRequest":"65344fca-0028-4129-a530-dd36fd984bd3","rebiopsyRequests":["5f54fb43-92a5-4f62-ae48-081d428ff2e8"],"studyInclusionRequests":["d49b41ff-e2df-499f-938f-8fb7136366b2"]}],"recommendations":[{"id":"df376556-df45-41c3-8bae-af1fe3fb7418","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","issuedOn":"2023-12-14","medication":[{"code":"L01XE39","system":"ATC","display":"Midostaurin","version":"2020"},{"code":"L01XX42","system":"ATC","display":"Panobinostat","version":"2020"}],"priority":"4","levelOfEvidence":{"grading":{"code":"m1B","system":"MTB-CDS:Level-of-Evidence:Grading"},"addendums":[{"code":"R","system":"MTB-CDS:Level-of-Evidence:Addendum"},{"code":"is","system":"MTB-CDS:Level-of-Evidence:Addendum"}]},"ngsReport":"223e3e6e-d99b-415e-83c0-18bcfc5729ca","supportingVariants":["CNV_AAGAB_ABCA6_high-level-gain","CNV_AAMP_AADACL3_low-level-gain","SNV_7f35df9d-d8b1-4421-9ec7-35cf8d3c4546"]},{"id":"08234e1e-105c-4362-87e8-4f20bf87ed0b","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","issuedOn":"2023-12-14","medication":[{"code":"L01XX42","system":"ATC","display":"Panobinostat","version":"2020"},{"code":"L01XE12","system":"ATC","display":"Vandetanib","version":"2020"}],"priority":"4","levelOfEvidence":{"grading":{"code":"m1A","system":"MTB-CDS:Level-of-Evidence:Grading"},"addendums":[{"code":"R","system":"MTB-CDS:Level-of-Evidence:Addendum"},{"code":"is","system":"MTB-CDS:Level-of-Evidence:Addendum"},{"code":"iv","system":"MTB-CDS:Level-of-Evidence:Addendum"}]},"ngsReport":"223e3e6e-d99b-415e-83c0-18bcfc5729ca","supportingVariants":["CNV_AAGAB_ABCA6_high-level-gain","CNV_ABCA12_AASDHPPT_ABCC2_AARS1P1_high-level-gain","SNV_963a3ea1-a1d5-4a52-b8d4-2e969be74b7b","SNV_7f35df9d-d8b1-4421-9ec7-35cf8d3c4546","CNV_AAAS_AADAT_high-level-gain","CNV_ABALON_ABCA17P_high-level-gain","CNV_AAMP_AADACL3_low-level-gain","SNV_22f943fd-ad99-48af-a61e-42679e851b71","SNV_599c38d4-af9f-416c-ad38-eb2fee8159e1"]}],"geneticCounsellingRequests":[{"id":"65344fca-0028-4129-a530-dd36fd984bd3","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","reason":"Some reason for genetic counselling..."}],"rebiopsyRequests":[{"id":"5f54fb43-92a5-4f62-ae48-081d428ff2e8","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","issuedOn":"2023-12-14"}],"histologyReevaluationRequests":[{"id":"b76dfb95-13e8-4acb-9ab8-364bc5215d63","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","issuedOn":"2023-12-14"}],"studyInclusionRequests":[{"id":"d49b41ff-e2df-499f-938f-8fb7136366b2","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","reason":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","nctNumber":"NCT84044685","issuedOn":"2023-12-14"}],"claims":[{"id":"2280a457-c5b8-4541-b6ca-86de385d789f","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","therapy":"df376556-df45-41c3-8bae-af1fe3fb7418"},{"id":"061fde0a-935c-4e2f-a54a-531679b2f8d5","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","therapy":"08234e1e-105c-4362-87e8-4f20bf87ed0b"}],"claimResponses":[{"id":"1177f670-cf44-4886-b9b3-a4dd25271dcb","claim":"2280a457-c5b8-4541-b6ca-86de385d789f","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","status":"accepted","reason":"standard-therapy-not-exhausted"},{"id":"c85d365d-e3c1-474b-aaa3-0e3e051d4223","claim":"061fde0a-935c-4e2f-a54a-531679b2f8d5","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","status":"rejected","reason":"other"}],"molecularTherapies":[{"history":[{"id":"660813fd-a42d-492a-8522-9c4aa3b3e162","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","recordedOn":"2023-12-31","basedOn":"08234e1e-105c-4362-87e8-4f20bf87ed0b","period":{"start":"2023-12-16","end":"2023-12-31"},"medication":[{"code":"L01XX42","system":"ATC","display":"Panobinostat","version":"2020"},{"code":"L01XE12","system":"ATC","display":"Vandetanib","version":"2020"}],"dosage":"<50%","note":"Notes on the Therapy...","status":"completed"}]}],"responses":[{"id":"8b2af33e-afee-450b-b947-3370d89603f8","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","therapy":"660813fd-a42d-492a-8522-9c4aa3b3e162","effectiveDate":"2023-12-16","value":{"code":"CR","system":"RECIST"}}]} \ No newline at end of file From 639159c6773a31b3a346f900ed479142ac3e8cfb Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 21 Mar 2025 19:15:20 +0100 Subject: [PATCH 30/54] docs: add information about DNPM:DIP dev environment --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index cc7d10a..357e44e 100644 --- a/README.md +++ b/README.md @@ -379,3 +379,5 @@ Die Datei `application-dev.yml` enthält hierzu die Konfiguration für das Profi Beim Ausführen der Integrationstests wird eine Testdatenbank in einem Docker-Container gestartet. Siehe hier auch die Klasse `AbstractTestcontainerTest` unter `src/integrationTest`. + +Ein einfaches Entwickler-Setup inklusive DNPM:DIP ist mit Hilfe von https://github.com/pcvolkmer/dnpmdip-devenv realisierbar. \ No newline at end of file From 3c5639708f0f9eb36feb2d0cb7f2cc68a17c3dff Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 21 Mar 2025 19:26:25 +0100 Subject: [PATCH 31/54] chore: highlight selected config tab --- src/main/resources/static/style.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/resources/static/style.css b/src/main/resources/static/style.css index 7066e2b..c6a8c33 100644 --- a/src/main/resources/static/style.css +++ b/src/main/resources/static/style.css @@ -650,11 +650,12 @@ input.inline:focus-visible { .tab:hover, .tab.active { - background: var(--table-border); + background: var(--bg-gray); + color: white; } .tabcontent { - border: 1px solid var(--table-border); + border: 2px solid var(--bg-gray); border-radius: 0 .5em .5em .5em; display: none; padding: 1em; From f027339425891c9cb94025f348e8cba8208280f8 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sat, 22 Mar 2025 10:20:35 +0100 Subject: [PATCH 32/54] chore: update gradle --- build.gradle.kts | 4 +++- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index d9fbd4d..f4bf26c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,7 +28,9 @@ var versions = mapOf( ) java { - sourceCompatibility = JavaVersion.VERSION_21 + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } sourceSets { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 20db9ad..c6f0030 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 9bdd8ba3751a88d3530d803318da1d5e4ecb9219 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sat, 22 Mar 2025 10:20:52 +0100 Subject: [PATCH 33/54] chore: update Spring Boot --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index f4bf26c..acd68cf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage plugins { war - id("org.springframework.boot") version "3.3.9" + id("org.springframework.boot") version "3.3.10" id("io.spring.dependency-management") version "1.1.7" kotlin("jvm") version "1.9.25" kotlin("plugin.spring") version "1.9.25" From 4ad6c4bd0a35ebd903040dcd01f64331811c7fee Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sat, 22 Mar 2025 10:43:31 +0100 Subject: [PATCH 34/54] feat: handle and save issue report for non HTTP 2xx responses --- .../dev/dnpm/etl/processor/output/RestMtbFileSender.kt | 6 ++++-- .../dev/dnpm/etl/processor/services/RequestProcessor.kt | 2 +- .../etl/processor/output/RestBwhcMtbFileSenderTest.kt | 8 ++++---- .../dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt | 8 ++++---- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt index b77611c..5ea42e3 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt @@ -28,6 +28,7 @@ import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.retry.support.RetryTemplate import org.springframework.web.client.RestClientException +import org.springframework.web.client.RestClientResponseException import org.springframework.web.client.RestTemplate abstract class RestMtbFileSender( @@ -64,9 +65,10 @@ abstract class RestMtbFileSender( } } catch (e: IllegalArgumentException) { logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!) - } catch (e: RestClientException) { + } catch (e: RestClientResponseException) { logger.info(restTargetProperties.uri!!.toString()) - logger.error("Cannot send data to remote system", e) + logger.error("Request data not accepted by remote system", e) + return MtbFileSender.Response(e.statusCode.asRequestStatus(), e.responseBodyAsString) } return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung") } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt index f4e6222..5b2c42a 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt @@ -93,7 +93,7 @@ class RequestProcessor( Instant.now(), responseStatus.status, when (responseStatus.status) { - RequestStatus.WARNING -> Optional.of(responseStatus.body) + RequestStatus.ERROR, RequestStatus.WARNING -> Optional.of(responseStatus.body) else -> Optional.empty() } ) diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt index abd7f9d..5063a97 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt @@ -213,23 +213,23 @@ class RestBwhcMtbFileSenderTest { ), RequestWithResponse( HttpStatus.BAD_REQUEST, - "??", + ERROR_RESPONSE_BODY, MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) ), RequestWithResponse( HttpStatus.UNPROCESSABLE_ENTITY, errorBody, - MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) + MtbFileSender.Response(RequestStatus.ERROR, errorBody) ), // Some more errors not mentioned in documentation RequestWithResponse( HttpStatus.NOT_FOUND, - "what????", + ERROR_RESPONSE_BODY, MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) ), RequestWithResponse( HttpStatus.INTERNAL_SERVER_ERROR, - "what????", + ERROR_RESPONSE_BODY, MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) ) ) diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt index ed8c6f0..dac6496 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt @@ -213,23 +213,23 @@ class RestDipMtbFileSenderTest { ), RequestWithResponse( HttpStatus.BAD_REQUEST, - "??", + ERROR_RESPONSE_BODY, MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) ), RequestWithResponse( HttpStatus.UNPROCESSABLE_ENTITY, errorBody, - MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) + MtbFileSender.Response(RequestStatus.ERROR, errorBody) ), // Some more errors not mentioned in documentation RequestWithResponse( HttpStatus.NOT_FOUND, - "what????", + ERROR_RESPONSE_BODY, MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) ), RequestWithResponse( HttpStatus.INTERNAL_SERVER_ERROR, - "what????", + ERROR_RESPONSE_BODY, MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) ) ) From d49671f0d4a7ffdcdb6e3f14a215d3a91129a621 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sat, 22 Mar 2025 23:40:13 +0100 Subject: [PATCH 35/54] build: update image name --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 205f1ee..e9e698e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -113,7 +113,7 @@ task("integrationTest") { } tasks.named("bootBuildImage") { - imageName.set("ghcr.io/ccc-mf/etl-processor") + imageName.set("ghcr.io/pcvolkmer/etl-processor") // Binding for CA Certs bindings.set(listOf( From c0ea5fcd513f9a63b6390065ae07a7a41e8c7263 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sun, 23 Mar 2025 01:05:06 +0100 Subject: [PATCH 36/54] test: use Europe/Berlin as timezone in tests --- .../processor/web/StatisticsRestControllerTest.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt index b9a1338..8164f15 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt @@ -54,6 +54,7 @@ import org.springframework.web.context.WebApplicationContext import reactor.core.publisher.Sinks import reactor.test.StepVerifier import java.time.Instant +import java.time.ZoneId import java.time.temporal.ChronoUnit @@ -185,6 +186,7 @@ class StatisticsRestControllerTest { @BeforeEach fun setup() { + val zoneId = ZoneId.of("Europe/Berlin") doAnswer { _ -> listOf( Request( @@ -195,7 +197,7 @@ class StatisticsRestControllerTest { Fingerprint("0123456789abcdef1"), RequestType.MTB_FILE, RequestStatus.SUCCESS, - Instant.now().truncatedTo(ChronoUnit.DAYS).minus(2, ChronoUnit.DAYS) + Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).minus(2, ChronoUnit.DAYS).toInstant() ), Request( 2, @@ -205,7 +207,7 @@ class StatisticsRestControllerTest { Fingerprint("0123456789abcdef2"), RequestType.MTB_FILE, RequestStatus.WARNING, - Instant.now().truncatedTo(ChronoUnit.DAYS).minus(2, ChronoUnit.DAYS) + Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).minus(2, ChronoUnit.DAYS).toInstant() ), Request( 3, @@ -215,7 +217,7 @@ class StatisticsRestControllerTest { Fingerprint("0123456789abcdee1"), RequestType.DELETE, RequestStatus.ERROR, - Instant.now().truncatedTo(ChronoUnit.DAYS).minus(1, ChronoUnit.DAYS) + Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).minus(1, ChronoUnit.DAYS).toInstant() ), Request( 4, @@ -225,7 +227,7 @@ class StatisticsRestControllerTest { Fingerprint("0123456789abcdef2"), RequestType.MTB_FILE, RequestStatus.DUPLICATION, - Instant.now().truncatedTo(ChronoUnit.DAYS).minus(1, ChronoUnit.DAYS) + Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).minus(1, ChronoUnit.DAYS).toInstant() ), Request( 5, @@ -235,7 +237,7 @@ class StatisticsRestControllerTest { Fingerprint("0123456789abcdef2"), RequestType.DELETE, RequestStatus.UNKNOWN, - Instant.now().truncatedTo(ChronoUnit.DAYS) + Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).toInstant() ), ) }.whenever(requestService).findAll() From 56a63b276e17412adb5253e9e34d830228a24583 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sun, 23 Mar 2025 12:09:34 +0100 Subject: [PATCH 37/54] Code cleanup (#87) * refactor: Replace usage of Void with Kotlins Unit * refactor: make ConnectionCheckService a functional interface * refactor: ignore unused exception * refactor: use property access syntax * refactor: use const value for login path --- .../etl/processor/config/AppSecurityConfiguration.kt | 10 ++++++---- .../dnpm/etl/processor/input/MtbFileRestController.kt | 4 ++-- .../etl/processor/monitoring/ConnectionCheckService.kt | 8 ++++---- .../dev/dnpm/etl/processor/output/RestMtbFileSender.kt | 2 +- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt index 6b063bd..ddcf202 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt @@ -44,6 +44,8 @@ import org.springframework.security.web.SecurityFilterChain import java.util.* +private const val LOGIN_PATH = "/login" + @Configuration @EnableConfigurationProperties( value = [ @@ -104,15 +106,15 @@ class AppSecurityConfiguration( realmName = "ETL-Processor" } formLogin { - loginPage = "/login" + loginPage = LOGIN_PATH } oauth2Login { - loginPage = "/login" + loginPage = LOGIN_PATH } sessionManagement { sessionConcurrency { maximumSessions = 1 - expiredUrl = "/login?expired" + expiredUrl = "$LOGIN_PATH?expired" } sessionFixation { newSession() @@ -155,7 +157,7 @@ class AppSecurityConfiguration( realmName = "ETL-Processor" } formLogin { - loginPage = "/login" + loginPage = LOGIN_PATH } csrf { disable() } } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt b/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt index 9e282c2..ded485c 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt @@ -41,7 +41,7 @@ class MtbFileRestController( } @PostMapping - fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity { + fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity { if (mtbFile.consent.status == Consent.Status.ACTIVE) { logger.debug("Accepted MTB File for processing") requestProcessor.processMtbFile(mtbFile) @@ -54,7 +54,7 @@ class MtbFileRestController( } @DeleteMapping(path = ["{patientId}"]) - fun deleteData(@PathVariable patientId: String): ResponseEntity { + fun deleteData(@PathVariable patientId: String): ResponseEntity { logger.debug("Accepted patient ID to process deletion") requestProcessor.processDeletion(PatientId(patientId)) return ResponseEntity.accepted().build() diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt index 9d96654..b845e21 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt @@ -35,7 +35,7 @@ import java.time.Instant import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration -interface ConnectionCheckService { +fun interface ConnectionCheckService { fun connectionAvailable(): ConnectionCheckResult @@ -88,7 +88,7 @@ class KafkaConnectionCheckService( Instant.now(), if (result.available == available) { result.lastChange } else { Instant.now() } ) - } catch (e: TimeoutException) { + } catch (_: TimeoutException) { ConnectionCheckResult.KafkaConnectionCheckResult( false, Instant.now(), @@ -138,7 +138,7 @@ class RestConnectionCheckService( Instant.now(), if (result.available == available) { result.lastChange } else { Instant.now() } ) - } catch (e: Exception) { + } catch (_: Exception) { ConnectionCheckResult.RestConnectionCheckResult( false, Instant.now(), @@ -191,7 +191,7 @@ class GPasConnectionCheckService( Instant.now(), if (result.available == available) { result.lastChange } else { Instant.now() } ) - } catch (e: Exception) { + } catch (_: Exception) { ConnectionCheckResult.GPasConnectionCheckResult( false, Instant.now(), diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt index 5ea42e3..016981c 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt @@ -103,7 +103,7 @@ abstract class RestMtbFileSender( val username = restTargetProperties.username val password = restTargetProperties.password val headers = HttpHeaders() - headers.setContentType(MediaType.APPLICATION_JSON) + headers.contentType = MediaType.APPLICATION_JSON if (username.isNullOrBlank() || password.isNullOrBlank()) { return headers From 98b971d7db5c08e838b9e1506594fa53e7385ea8 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sun, 23 Mar 2025 13:35:24 +0100 Subject: [PATCH 38/54] feat: do not retry on validation issues (#89) This will prevent retry if response is HTTP 400 or HTTP 422. --- .../dnpm/etl/processor/config/AppConfiguration.kt | 3 +++ .../processor/output/RestDipMtbFileSenderTest.kt | 13 +++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt index 5fc1120..66af288 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt @@ -55,6 +55,7 @@ import org.springframework.retry.support.RetryTemplateBuilder import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.web.client.HttpClientErrorException import org.springframework.web.client.RestTemplate import reactor.core.publisher.Sinks import java.io.BufferedInputStream @@ -224,6 +225,8 @@ class AppConfiguration { fun retryTemplate(configProperties: AppConfigProperties): RetryTemplate { return RetryTemplateBuilder() .notRetryOn(IllegalArgumentException::class.java) + .notRetryOn(HttpClientErrorException.BadRequest::class.java) + .notRetryOn(HttpClientErrorException.UnprocessableEntity::class.java) .exponentialBackoff(2.seconds.toJavaDuration(), 1.25, 5.seconds.toJavaDuration()) .customPolicy(SimpleRetryPolicy(configProperties.maxRetryAttempts)) .withListener(object : RetryListener { diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt index dac6496..26e25c6 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt @@ -22,6 +22,8 @@ package dev.dnpm.etl.processor.output import de.ukw.ccc.bwhc.dto.* import dev.dnpm.etl.processor.PatientPseudonym import dev.dnpm.etl.processor.RequestId +import dev.dnpm.etl.processor.config.AppConfigProperties +import dev.dnpm.etl.processor.config.AppConfiguration import dev.dnpm.etl.processor.config.RestTargetProperties import dev.dnpm.etl.processor.monitoring.RequestStatus import org.assertj.core.api.Assertions.assertThat @@ -30,6 +32,7 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus +import org.springframework.retry.backoff.NoBackOffPolicy import org.springframework.retry.policy.SimpleRetryPolicy import org.springframework.retry.support.RetryTemplateBuilder import org.springframework.test.web.client.ExpectedCount @@ -91,14 +94,15 @@ class RestDipMtbFileSenderTest { fun shouldRetryOnMtbFileHttpRequestError(requestWithResponse: RequestWithResponse) { val restTemplate = RestTemplate() val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false) - val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() + val retryTemplate = AppConfiguration().retryTemplate(AppConfigProperties("http://localhost:9000")) + retryTemplate.setBackOffPolicy(NoBackOffPolicy()) this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate) val expectedCount = when (requestWithResponse.httpStatus) { // OK - No Retry - HttpStatus.OK, HttpStatus.CREATED -> ExpectedCount.max(1) + HttpStatus.OK, HttpStatus.CREATED, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.BAD_REQUEST -> ExpectedCount.max(1) // Request failed - Retry max 3 times else -> ExpectedCount.max(3) } @@ -120,14 +124,15 @@ class RestDipMtbFileSenderTest { fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) { val restTemplate = RestTemplate() val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false) - val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() + val retryTemplate = AppConfiguration().retryTemplate(AppConfigProperties("http://localhost:9000")) + retryTemplate.setBackOffPolicy(NoBackOffPolicy()) this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate) val expectedCount = when (requestWithResponse.httpStatus) { // OK - No Retry - HttpStatus.OK, HttpStatus.CREATED -> ExpectedCount.max(1) + HttpStatus.OK, HttpStatus.CREATED, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.BAD_REQUEST -> ExpectedCount.max(1) // Request failed - Retry max 3 times else -> ExpectedCount.max(3) } From befeef31539af73864a2ba21352d62faa5f76af9 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Thu, 3 Apr 2025 17:06:03 +0200 Subject: [PATCH 39/54] feat: use issue severity to create status (#90) --- README.md | 21 ++++ .../processor/config/AppRestConfiguration.kt | 8 +- .../etl/processor/monitoring/ReportService.kt | 11 ++ .../processor/output/RestBwhcMtbFileSender.kt | 6 +- .../processor/output/RestDipMtbFileSender.kt | 6 +- .../etl/processor/output/RestMtbFileSender.kt | 11 +- .../output/RestBwhcMtbFileSenderTest.kt | 106 ++++++++++++----- .../output/RestDipMtbFileSenderTest.kt | 107 +++++++++++++----- .../processor/services/ReportServiceTest.kt | 87 +++++++++++++- 9 files changed, 293 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 357e44e..5d76a96 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,27 @@ ein Consent-Widerspruch erfolgte. Dieses Vorgehen empfiehlt sich, wenn Sie gespeicherte Records nachgelagert für andere Auswertungen verwenden möchten. +### Antworten und Statusauswertung + +Anfragen and bwHC-Backend aus Versionen bis 0.9.x wurden wie folgt behandelt: + +| HTTP-Response | Status | +|----------------|-----------| +| `HTTP 200` | `SUCCESS` | +| `HTTP 201` | `WARNING` | +| `HTTP 400-...` | `ERROR` | + +Dies konnte dazu führen, dass zwar mit einem `HTTP 201` geantwortet wurde, aber dennoch in der Issue-Liste die +Severity `error` aufgetaucht ist. + +Ab Version 0.10 wird die Issue-Liste der Antwort verwendet und die darion enthaltene höchste Severity-Stufe als Ergebnis verwendet. + +| Höchste Severity | Status | +|------------------|-----------| +| `info` | `SUCCESS` | +| `warning` | `WARNING` | +| `error`, `fatal` | `ERROR` | + ## Docker-Images Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/pcvolkmer/etl-processor/pkgs/container/etl-processor diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt index a393267..1a18924 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt @@ -21,6 +21,7 @@ package dev.dnpm.etl.processor.config import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult import dev.dnpm.etl.processor.monitoring.ConnectionCheckService +import dev.dnpm.etl.processor.monitoring.ReportService import dev.dnpm.etl.processor.monitoring.RestConnectionCheckService import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.output.RestBwhcMtbFileSender @@ -53,15 +54,16 @@ class AppRestConfiguration { fun restMtbFileSender( restTemplate: RestTemplate, restTargetProperties: RestTargetProperties, - retryTemplate: RetryTemplate + retryTemplate: RetryTemplate, + reportService: ReportService, ): MtbFileSender { if (restTargetProperties.isBwhc) { logger.info("Selected 'RestBwhcMtbFileSender'") - return RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + return RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) } logger.info("Selected 'RestDipMtbFileSender'") - return RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + return RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) } @Bean diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt index 062f749..9f4c568 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt @@ -25,6 +25,8 @@ import com.fasterxml.jackson.annotation.JsonValue import com.fasterxml.jackson.core.JsonParseException import com.fasterxml.jackson.databind.JsonMappingException import com.fasterxml.jackson.databind.ObjectMapper +import dev.dnpm.etl.processor.monitoring.ReportService.Issue +import dev.dnpm.etl.processor.monitoring.ReportService.Severity class ReportService( private val objectMapper: ObjectMapper @@ -63,4 +65,13 @@ class ReportService( WARNING("warning"), INFO("info") } +} + +fun List.asRequestStatus(): RequestStatus { + val severity = this.minOfOrNull { it.severity } + return when (severity) { + Severity.FATAL, Severity.ERROR -> RequestStatus.ERROR + Severity.WARNING -> RequestStatus.WARNING + else -> RequestStatus.SUCCESS + } } \ No newline at end of file diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt index f4a58e8..fbe6d0d 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt @@ -21,6 +21,7 @@ package dev.dnpm.etl.processor.output import dev.dnpm.etl.processor.PatientPseudonym import dev.dnpm.etl.processor.config.RestTargetProperties +import dev.dnpm.etl.processor.monitoring.ReportService import org.springframework.retry.support.RetryTemplate import org.springframework.web.client.RestTemplate import org.springframework.web.util.UriComponentsBuilder @@ -28,8 +29,9 @@ import org.springframework.web.util.UriComponentsBuilder class RestBwhcMtbFileSender( restTemplate: RestTemplate, private val restTargetProperties: RestTargetProperties, - retryTemplate: RetryTemplate -) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate) { + retryTemplate: RetryTemplate, + reportService: ReportService, +) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) { override fun sendUrl(): String { return UriComponentsBuilder diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt index 42dbb30..1e6a5a7 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt @@ -21,6 +21,7 @@ package dev.dnpm.etl.processor.output import dev.dnpm.etl.processor.PatientPseudonym import dev.dnpm.etl.processor.config.RestTargetProperties +import dev.dnpm.etl.processor.monitoring.ReportService import org.springframework.retry.support.RetryTemplate import org.springframework.web.client.RestTemplate import org.springframework.web.util.UriComponentsBuilder @@ -28,8 +29,9 @@ import org.springframework.web.util.UriComponentsBuilder class RestDipMtbFileSender( restTemplate: RestTemplate, private val restTargetProperties: RestTargetProperties, - retryTemplate: RetryTemplate -) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate) { + retryTemplate: RetryTemplate, + reportService: ReportService +) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) { override fun sendUrl(): String { return UriComponentsBuilder diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt index 016981c..90e3629 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt @@ -22,6 +22,8 @@ package dev.dnpm.etl.processor.output import dev.dnpm.etl.processor.config.RestTargetProperties import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.PatientPseudonym +import dev.dnpm.etl.processor.monitoring.ReportService +import dev.dnpm.etl.processor.monitoring.asRequestStatus import org.slf4j.LoggerFactory import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders @@ -34,7 +36,8 @@ import org.springframework.web.client.RestTemplate abstract class RestMtbFileSender( private val restTemplate: RestTemplate, private val restTargetProperties: RestTargetProperties, - private val retryTemplate: RetryTemplate + private val retryTemplate: RetryTemplate, + private val reportService: ReportService ) : MtbFileSender { private val logger = LoggerFactory.getLogger(RestMtbFileSender::class.java) @@ -56,19 +59,19 @@ abstract class RestMtbFileSender( if (!response.statusCode.is2xxSuccessful) { logger.warn("Error sending to remote system: {}", response.body) return@execute MtbFileSender.Response( - response.statusCode.asRequestStatus(), + reportService.deserialize(response.body).asRequestStatus(), "Status-Code: ${response.statusCode.value()}" ) } logger.debug("Sent file via RestMtbFileSender") - return@execute MtbFileSender.Response(response.statusCode.asRequestStatus(), response.body.orEmpty()) + return@execute MtbFileSender.Response(reportService.deserialize(response.body).asRequestStatus(), response.body.orEmpty()) } } catch (e: IllegalArgumentException) { logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!) } catch (e: RestClientResponseException) { logger.info(restTargetProperties.uri!!.toString()) logger.error("Request data not accepted by remote system", e) - return MtbFileSender.Response(e.statusCode.asRequestStatus(), e.responseBodyAsString) + return MtbFileSender.Response(reportService.deserialize(e.responseBodyAsString).asRequestStatus(), e.responseBodyAsString) } return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung") } diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt index 5063a97..ffbc65c 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt @@ -19,14 +19,18 @@ package dev.dnpm.etl.processor.output +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule import de.ukw.ccc.bwhc.dto.* import dev.dnpm.etl.processor.PatientPseudonym import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.config.RestTargetProperties +import dev.dnpm.etl.processor.monitoring.ReportService import dev.dnpm.etl.processor.monitoring.RequestStatus import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus @@ -45,6 +49,8 @@ class RestBwhcMtbFileSenderTest { private lateinit var restMtbFileSender: RestMtbFileSender + private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build())) + @BeforeEach fun setup() { val restTemplate = RestTemplate() @@ -53,7 +59,8 @@ class RestBwhcMtbFileSenderTest { this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) - this.restMtbFileSender = RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + this.restMtbFileSender = + RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) } @ParameterizedTest @@ -94,7 +101,8 @@ class RestBwhcMtbFileSenderTest { val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) - this.restMtbFileSender = RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + this.restMtbFileSender = + RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) val expectedCount = when (requestWithResponse.httpStatus) { // OK - No Retry @@ -123,7 +131,8 @@ class RestBwhcMtbFileSenderTest { val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) - this.restMtbFileSender = RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + this.restMtbFileSender = + RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) val expectedCount = when (requestWithResponse.httpStatus) { // OK - No Retry @@ -154,24 +163,6 @@ class RestBwhcMtbFileSenderTest { val TEST_REQUEST_ID = RequestId("TestId") val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID") - private val warningBody = """ - { - "patient_id": "PID", - "issues": [ - { "severity": "warning", "message": "Something is not right" } - ] - } - """.trimIndent() - - private val errorBody = """ - { - "patient_id": "PID", - "issues": [ - { "severity": "error", "message": "Something is very bad" } - ] - } - """.trimIndent() - val mtbFile: MtbFile = MtbFile.builder() .withPatient( Patient.builder() @@ -205,21 +196,34 @@ class RestBwhcMtbFileSenderTest { @JvmStatic fun mtbFileRequestWithResponseSource(): Set { return setOf( - RequestWithResponse(HttpStatus.OK, "{}", MtbFileSender.Response(RequestStatus.SUCCESS, "{}")), + RequestWithResponse( + HttpStatus.OK, + responseBodyWithMaxSeverity(ReportService.Severity.INFO), + MtbFileSender.Response( + RequestStatus.SUCCESS, + responseBodyWithMaxSeverity(ReportService.Severity.INFO) + ) + ), RequestWithResponse( HttpStatus.CREATED, - warningBody, - MtbFileSender.Response(RequestStatus.WARNING, warningBody) + responseBodyWithMaxSeverity(ReportService.Severity.WARNING), + MtbFileSender.Response( + RequestStatus.WARNING, + responseBodyWithMaxSeverity(ReportService.Severity.WARNING) + ) ), RequestWithResponse( HttpStatus.BAD_REQUEST, - ERROR_RESPONSE_BODY, - MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) + responseBodyWithMaxSeverity(ReportService.Severity.ERROR), + MtbFileSender.Response(RequestStatus.ERROR, responseBodyWithMaxSeverity(ReportService.Severity.ERROR)) ), RequestWithResponse( HttpStatus.UNPROCESSABLE_ENTITY, - errorBody, - MtbFileSender.Response(RequestStatus.ERROR, errorBody) + responseBodyWithMaxSeverity(ReportService.Severity.FATAL), + MtbFileSender.Response( + RequestStatus.ERROR, + responseBodyWithMaxSeverity(ReportService.Severity.FATAL) + ) ), // Some more errors not mentioned in documentation RequestWithResponse( @@ -256,6 +260,52 @@ class RestBwhcMtbFileSenderTest { ) ) } + + fun responseBodyWithMaxSeverity(severity: ReportService.Severity): String { + return when (severity) { + ReportService.Severity.INFO -> """ + { + "patient": "PID", + "issues": [ + { "severity": "info", "message": "Info Message" } + ] + } + """ + + ReportService.Severity.WARNING -> """ + { + "patient": "PID", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" } + ] + } + """ + + ReportService.Severity.ERROR -> """ + { + "patient": "PID", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" }, + { "severity": "error", "message": "Error Message" } + ] + } + """ + + ReportService.Severity.FATAL -> """ + { + "patient": "PID", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" }, + { "severity": "error", "message": "Error Message" }, + { "severity": "fatal", "message": "Fatal Message" } + ] + } + """ + } + } } diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt index 26e25c6..005c0fd 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt @@ -19,13 +19,17 @@ package dev.dnpm.etl.processor.output +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule import de.ukw.ccc.bwhc.dto.* import dev.dnpm.etl.processor.PatientPseudonym import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.config.AppConfigProperties import dev.dnpm.etl.processor.config.AppConfiguration import dev.dnpm.etl.processor.config.RestTargetProperties +import dev.dnpm.etl.processor.monitoring.ReportService import dev.dnpm.etl.processor.monitoring.RequestStatus +import dev.dnpm.etl.processor.output.RestBwhcMtbFileSenderTest.Companion import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.params.ParameterizedTest @@ -48,6 +52,8 @@ class RestDipMtbFileSenderTest { private lateinit var restMtbFileSender: RestMtbFileSender + private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build())) + @BeforeEach fun setup() { val restTemplate = RestTemplate() @@ -56,7 +62,7 @@ class RestDipMtbFileSenderTest { this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) - this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) } @ParameterizedTest @@ -98,11 +104,14 @@ class RestDipMtbFileSenderTest { retryTemplate.setBackOffPolicy(NoBackOffPolicy()) this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) - this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + this.restMtbFileSender = + RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) val expectedCount = when (requestWithResponse.httpStatus) { // OK - No Retry - HttpStatus.OK, HttpStatus.CREATED, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.BAD_REQUEST -> ExpectedCount.max(1) + HttpStatus.OK, HttpStatus.CREATED, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.BAD_REQUEST -> ExpectedCount.max( + 1 + ) // Request failed - Retry max 3 times else -> ExpectedCount.max(3) } @@ -128,11 +137,14 @@ class RestDipMtbFileSenderTest { retryTemplate.setBackOffPolicy(NoBackOffPolicy()) this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) - this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + this.restMtbFileSender = + RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) val expectedCount = when (requestWithResponse.httpStatus) { // OK - No Retry - HttpStatus.OK, HttpStatus.CREATED, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.BAD_REQUEST -> ExpectedCount.max(1) + HttpStatus.OK, HttpStatus.CREATED, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.BAD_REQUEST -> ExpectedCount.max( + 1 + ) // Request failed - Retry max 3 times else -> ExpectedCount.max(3) } @@ -159,24 +171,6 @@ class RestDipMtbFileSenderTest { val TEST_REQUEST_ID = RequestId("TestId") val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID") - private val warningBody = """ - { - "patient_id": "PID", - "issues": [ - { "severity": "warning", "message": "Something is not right" } - ] - } - """.trimIndent() - - private val errorBody = """ - { - "patient_id": "PID", - "issues": [ - { "severity": "error", "message": "Something is very bad" } - ] - } - """.trimIndent() - val mtbFile: MtbFile = MtbFile.builder() .withPatient( Patient.builder() @@ -210,21 +204,28 @@ class RestDipMtbFileSenderTest { @JvmStatic fun mtbFileRequestWithResponseSource(): Set { return setOf( - RequestWithResponse(HttpStatus.OK, "{}", MtbFileSender.Response(RequestStatus.SUCCESS, "{}")), + RequestWithResponse( + HttpStatus.OK, + responseBodyWithMaxSeverity(ReportService.Severity.INFO), + MtbFileSender.Response( + RequestStatus.SUCCESS, + responseBodyWithMaxSeverity(ReportService.Severity.INFO) + ) + ), RequestWithResponse( HttpStatus.CREATED, - warningBody, - MtbFileSender.Response(RequestStatus.WARNING, warningBody) + responseBodyWithMaxSeverity(ReportService.Severity.WARNING), + MtbFileSender.Response(RequestStatus.WARNING, responseBodyWithMaxSeverity(ReportService.Severity.WARNING)) ), RequestWithResponse( HttpStatus.BAD_REQUEST, - ERROR_RESPONSE_BODY, - MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) + responseBodyWithMaxSeverity(ReportService.Severity.ERROR), + MtbFileSender.Response(RequestStatus.ERROR, responseBodyWithMaxSeverity(ReportService.Severity.ERROR)) ), RequestWithResponse( HttpStatus.UNPROCESSABLE_ENTITY, - errorBody, - MtbFileSender.Response(RequestStatus.ERROR, errorBody) + responseBodyWithMaxSeverity(ReportService.Severity.ERROR), + MtbFileSender.Response(RequestStatus.ERROR, responseBodyWithMaxSeverity(ReportService.Severity.ERROR)) ), // Some more errors not mentioned in documentation RequestWithResponse( @@ -261,6 +262,52 @@ class RestDipMtbFileSenderTest { ) ) } + + fun responseBodyWithMaxSeverity(severity: ReportService.Severity): String { + return when (severity) { + ReportService.Severity.INFO -> """ + { + "patient": "PID", + "issues": [ + { "severity": "info", "message": "Info Message" } + ] + } + """ + + ReportService.Severity.WARNING -> """ + { + "patient": "PID", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" } + ] + } + """ + + ReportService.Severity.ERROR -> """ + { + "patient": "PID", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" }, + { "severity": "error", "message": "Error Message" } + ] + } + """ + + ReportService.Severity.FATAL -> """ + { + "patient": "PID", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" }, + { "severity": "error", "message": "Error Message" }, + { "severity": "fatal", "message": "Fatal Message" } + ] + } + """ + } + } } diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/ReportServiceTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/services/ReportServiceTest.kt index 349202a..fc95808 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/services/ReportServiceTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/services/ReportServiceTest.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -22,9 +22,14 @@ package dev.dnpm.etl.processor.services import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.KotlinModule import dev.dnpm.etl.processor.monitoring.ReportService +import dev.dnpm.etl.processor.monitoring.RequestStatus +import dev.dnpm.etl.processor.monitoring.asRequestStatus import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource class ReportServiceTest { @@ -60,6 +65,15 @@ class ReportServiceTest { assertThat(actual[2].message).isEqualTo("Warning Message") assertThat(actual[3].severity).isEqualTo(ReportService.Severity.INFO) assertThat(actual[3].message).isEqualTo("Info Message") + + assertThat(actual.asRequestStatus()).isEqualTo(RequestStatus.ERROR) + } + + @ParameterizedTest + @MethodSource("testData") + fun shouldParseDataQualityReport(json: String, requestStatus: RequestStatus) { + val actual = this.reportService.deserialize(json) + assertThat(actual.asRequestStatus()).isEqualTo(requestStatus) } @Test @@ -73,4 +87,75 @@ class ReportServiceTest { assertThat(actual[0].message).isEqualTo("Not parsable data quality report '$invalidResponse'") } + companion object { + + @JvmStatic + fun testData(): Set { + return setOf( + Arguments.of( + """ + { + "patient": "4711", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" }, + { "severity": "error", "message": "Error Message" }, + { "severity": "fatal", "message": "Fatal Message" } + ] + } + """.trimIndent(), + RequestStatus.ERROR + ), + Arguments.of( + """ + { + "patient": "4711", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" }, + { "severity": "error", "message": "Error Message" } + ] + } + """.trimIndent(), + RequestStatus.ERROR + ), + Arguments.of( + """ + { + "patient": "4711", + "issues": [ + { "severity": "error", "message": "Error Message" } + { "severity": "info", "message": "Info Message" } + ] + } + """.trimIndent(), + RequestStatus.ERROR + ), + Arguments.of( + """ + { + "patient": "4711", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" } + ] + } + """.trimIndent(), + RequestStatus.WARNING + ), + Arguments.of( + """ + { + "patient": "4711", + "issues": [ + { "severity": "info", "message": "Info Message" } + ] + } + """.trimIndent(), + RequestStatus.SUCCESS + ) + ) + } + } + } \ No newline at end of file From 033750eb1015ebc4d1612858dff54496e64a3410 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 4 Apr 2025 13:59:51 +0200 Subject: [PATCH 40/54] feat: show issue path if available in response body (#92) --- .../dev/dnpm/etl/processor/monitoring/ReportService.kt | 7 ++++++- src/main/resources/static/style.css | 10 ++++++++++ src/main/resources/templates/report.html | 8 ++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt index 9f4c568..e9ea489 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt @@ -27,6 +27,7 @@ import com.fasterxml.jackson.databind.JsonMappingException import com.fasterxml.jackson.databind.ObjectMapper import dev.dnpm.etl.processor.monitoring.ReportService.Issue import dev.dnpm.etl.processor.monitoring.ReportService.Severity +import java.util.Optional class ReportService( private val objectMapper: ObjectMapper @@ -57,7 +58,11 @@ class ReportService( private data class DataQualityReport(val issues: List) @JsonIgnoreProperties(ignoreUnknown = true) - data class Issue(val severity: Severity, @JsonAlias("details") val message: String) + data class Issue( + val severity: Severity, + @JsonAlias("details") val message: String, + val path: Optional = Optional.empty() + ) enum class Severity(@JsonValue val value: String) { FATAL("fatal"), diff --git a/src/main/resources/static/style.css b/src/main/resources/static/style.css index c6a8c33..b6713d2 100644 --- a/src/main/resources/static/style.css +++ b/src/main/resources/static/style.css @@ -697,4 +697,14 @@ a.reload { .no-token { padding: 1em; background: var(--bg-red-op); +} + +.issue-message { + font-family: monospace; + font-weight: bolder; +} + +.issue-path { + font-family: monospace; + line-height: 1rem; } \ No newline at end of file diff --git a/src/main/resources/templates/report.html b/src/main/resources/templates/report.html index 21d1b48..5442fd4 100644 --- a/src/main/resources/templates/report.html +++ b/src/main/resources/templates/report.html @@ -47,7 +47,7 @@ Schweregrad - Beschreibung + Beschreibung und Pfad @@ -56,7 +56,11 @@ [[ ${issue.severity} ]] [[ ${issue.severity} ]] [[ ${issue.severity} ]] - [[ ${issue.message} ]] + +
[[ ${issue.message} ]]
+
[[ ${issue.path.get()} ]]
+
Keine Angabe
+ From 7ae34719fd0e34d7065c5a8eb4ccd58efc2370d8 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 4 Apr 2025 14:34:31 +0200 Subject: [PATCH 41/54] feat: add new MTB endpoint path (#93) --- README.md | 17 +- .../processor/input/MtbFileRestController.kt | 4 +- .../input/MtbFileRestControllerTest.kt | 194 +++++++++++------- 3 files changed, 133 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 5d76a96..8575aff 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,16 @@ Die Erkennung von Duplikaten ist normalerweise immer aktiv, kann jedoch über de Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung direkt an das bwHC-Backend gesendet. +Ein HTTP Request kann, angenommen die Installation erfolgte auf dem Host `dnpm.example.com` an nachfolgende URLs gesendet werden: + +| HTTP-Request | URL | Consent-Status im Datensatz | Bemerkung | +|--------------|-----------------------------------------|-----------------------------|---------------------------------------------------------------------------------| +| POST | `https://dnpm.example.com/mtb` | ACTIVE | Die Anwendung verarbeitet den eingehenden Datensatz | +| POST | `https://dnpm.example.com/mtb` | REJECT | Die Anwendung sendet einen Lösch-Request für die im Datensatz angegebene Pat-ID | +| DELETE | `https://dnpm.example.com/mtb/12345678` | - | Die Anwendung sendet einen Lösch-Request für Pat-ID `12345678` | + +Anstelle des Pfads `/mtb` kann auch, wie in Version 0.9 und älter üblich, `/mtbfile` verwendet werden. + ### Datenübermittlung mit Apache Kafka Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung an Apache Kafka übergeben. @@ -42,6 +52,9 @@ In Versionen des ETL-Processors **nach Version 0.10** werden die folgenden Konfi * `APP_KAFKA_TOPIC`: Nutzen Sie nun die Konfigurationsoption `APP_KAFKA_OUTPUT_TOPIC` * `APP_KAFKA_RESPONSE_TOPIC`: Nutzen Sie nun die Konfigurationsoption `APP_KAFKA_OUTPUT_RESPONSE_TOPIC` +Der Pfad zum Versenden von MTB-Daten ist nun offiziell `/mtb`. +In Versionen **nach Version 0.10** wird die Unterstützung des Pfads `/mtbfile` entfernt. + ### Pseudonymisierung der Patienten-ID Wenn eine URI zu einer gPAS-Instanz (Version >= 2023.1.0) angegeben ist, wird diese verwendet. @@ -161,7 +174,7 @@ zur Nutzung des MTB-File-Endpunkts eine HTTP-Basic-Authentifizierung voraussetze ![Tokenverwaltung](docs/tokens.png) -In diesem Fall können den Endpunkt für das Onkostar-Plugin **[onkostar-plugin-dnpmexport](https://github.com/CCC-MF/onkostar-plugin-dnpmexport)** wie folgt konfigurieren: +In diesem Fall kann der Endpunkt für das Onkostar-Plugin **[onkostar-plugin-dnpmexport](https://github.com/CCC-MF/onkostar-plugin-dnpmexport)** wie folgt konfiguriert werden: ``` https://testonkostar:MTg1NTL...NGU4@etl.example.com/mtbfile @@ -266,7 +279,7 @@ Dieses Vorgehen empfiehlt sich, wenn Sie gespeicherte Records nachgelagert für ### Antworten und Statusauswertung -Anfragen and bwHC-Backend aus Versionen bis 0.9.x wurden wie folgt behandelt: +Anfragen an das bwHC-Backend aus Versionen bis 0.9.x wurden wie folgt behandelt: | HTTP-Response | Status | |----------------|-----------| diff --git a/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt b/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt index ded485c..123a84f 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -28,7 +28,7 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @RestController -@RequestMapping(path = ["mtbfile"]) +@RequestMapping(path = ["mtbfile", "mtb"]) class MtbFileRestController( private val requestProcessor: RequestProcessor, ) { diff --git a/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt index 3e5b53a..ade27b4 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* import dev.dnpm.etl.processor.services.RequestProcessor import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock @@ -40,24 +41,122 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders @ExtendWith(MockitoExtension::class) class MtbFileRestControllerTest { - private lateinit var mockMvc: MockMvc - - private lateinit var requestProcessor: RequestProcessor - private val objectMapper = ObjectMapper() - @BeforeEach - fun setup( - @Mock requestProcessor: RequestProcessor - ) { - this.requestProcessor = requestProcessor - val controller = MtbFileRestController(requestProcessor) - this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build() + @Nested + inner class BwhcRequests { + + private lateinit var mockMvc: MockMvc + + private lateinit var requestProcessor: RequestProcessor + + @BeforeEach + fun setup( + @Mock requestProcessor: RequestProcessor + ) { + this.requestProcessor = requestProcessor + val controller = MtbFileRestController(requestProcessor) + this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build() + } + + @Test + fun shouldProcessPostRequest() { + mockMvc.post("/mtbfile") { + content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.ACTIVE)) + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { + isAccepted() + } + } + + verify(requestProcessor, times(1)).processMtbFile(any()) + } + + @Test + fun shouldProcessPostRequestWithRejectedConsent() { + mockMvc.post("/mtbfile") { + content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.REJECTED)) + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { + isAccepted() + } + } + + verify(requestProcessor, times(1)).processDeletion(anyValueClass()) + } + + @Test + fun shouldProcessDeleteRequest() { + mockMvc.delete("/mtbfile/TEST_12345678").andExpect { + status { + isAccepted() + } + } + + verify(requestProcessor, times(1)).processDeletion(anyValueClass()) + } } - @Test - fun shouldProcessMtbFilePostRequest() { - val mtbFile = MtbFile.builder() + @Nested + inner class BwhcRequestsWithAlias { + + private lateinit var mockMvc: MockMvc + + private lateinit var requestProcessor: RequestProcessor + + @BeforeEach + fun setup( + @Mock requestProcessor: RequestProcessor + ) { + this.requestProcessor = requestProcessor + val controller = MtbFileRestController(requestProcessor) + this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build() + } + + @Test + fun shouldProcessPostRequest() { + mockMvc.post("/mtb") { + content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.ACTIVE)) + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { + isAccepted() + } + } + + verify(requestProcessor, times(1)).processMtbFile(any()) + } + + @Test + fun shouldProcessPostRequestWithRejectedConsent() { + mockMvc.post("/mtb") { + content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.REJECTED)) + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { + isAccepted() + } + } + + verify(requestProcessor, times(1)).processDeletion(anyValueClass()) + } + + @Test + fun shouldProcessDeleteRequest() { + mockMvc.delete("/mtb/TEST_12345678").andExpect { + status { + isAccepted() + } + } + + verify(requestProcessor, times(1)).processDeletion(anyValueClass()) + } + } + + companion object { + fun bwhcMtbFileContent(consentStatus: Consent.Status) = MtbFile.builder() .withPatient( Patient.builder() .withId("TEST_12345678") @@ -68,7 +167,7 @@ class MtbFileRestControllerTest { .withConsent( Consent.builder() .withId("1") - .withStatus(Consent.Status.ACTIVE) + .withStatus(consentStatus) .withPatient("TEST_12345678") .build() ) @@ -80,66 +179,5 @@ class MtbFileRestControllerTest { .build() ) .build() - - mockMvc.post("/mtbfile") { - content = objectMapper.writeValueAsString(mtbFile) - contentType = MediaType.APPLICATION_JSON - }.andExpect { - status { - isAccepted() - } - } - - verify(requestProcessor, times(1)).processMtbFile(any()) } - - @Test - fun shouldProcessMtbFilePostRequestWithRejectedConsent() { - 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.REJECTED) - .withPatient("TEST_12345678") - .build() - ) - .withEpisode( - Episode.builder() - .withId("1") - .withPatient("TEST_12345678") - .withPeriod(PeriodStart("2023-08-08")) - .build() - ) - .build() - - mockMvc.post("/mtbfile") { - content = objectMapper.writeValueAsString(mtbFile) - contentType = MediaType.APPLICATION_JSON - }.andExpect { - status { - isAccepted() - } - } - - verify(requestProcessor, times(1)).processDeletion(anyValueClass()) - } - - @Test - fun shouldProcessMtbFileDeleteRequest() { - mockMvc.delete("/mtbfile/TEST_12345678").andExpect { - status { - isAccepted() - } - } - - verify(requestProcessor, times(1)).processDeletion(anyValueClass()) - } - -} \ No newline at end of file +} From 586d388e570efe2f816479f892c4acdd0be09573 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 4 Apr 2025 14:36:30 +0200 Subject: [PATCH 42/54] docs: add info about DNPM:DIP support --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 8575aff..27abf07 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ Siehe hierzu auch: https://github.com/CCC-MF/kafka-to-bwhc ## Konfiguration +### Wichtige Änderungen in Version 0.10 + +Ab Version 0.10 wird [DNPM:DIP](https://github.com/dnpm-dip) unterstützt und als Standardendpunkt verwendet. +Soll noch das alte bwHC-Backend verwendet werden, so ist die Umgebungsvariable `APP_REST_IS_BWHC` auf `true` zu setzen. + ### Breaking Changes nach Version 0.10 In Versionen des ETL-Processors **nach Version 0.10** werden die folgenden Konfigurationsoptionen entfernt: From 9307fc0dad55bcfb562a78e9295fe45c209c6d7a Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 4 Apr 2025 14:49:37 +0200 Subject: [PATCH 43/54] docs: change etl image and highlight important information --- README.md | 10 +++++----- docs/etl.png | Bin 122673 -> 117875 bytes 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 27abf07..455c5dd 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,9 @@ Ein HTTP Request kann, angenommen die Installation erfolgte auf dem Host `dnpm.e | HTTP-Request | URL | Consent-Status im Datensatz | Bemerkung | |--------------|-----------------------------------------|-----------------------------|---------------------------------------------------------------------------------| -| POST | `https://dnpm.example.com/mtb` | ACTIVE | Die Anwendung verarbeitet den eingehenden Datensatz | -| POST | `https://dnpm.example.com/mtb` | REJECT | Die Anwendung sendet einen Lösch-Request für die im Datensatz angegebene Pat-ID | -| DELETE | `https://dnpm.example.com/mtb/12345678` | - | Die Anwendung sendet einen Lösch-Request für Pat-ID `12345678` | +| `POST` | `https://dnpm.example.com/mtb` | `ACTIVE` | Die Anwendung verarbeitet den eingehenden Datensatz | +| `POST` | `https://dnpm.example.com/mtb` | `REJECT` | Die Anwendung sendet einen Lösch-Request für die im Datensatz angegebene Pat-ID | +| `DELETE` | `https://dnpm.example.com/mtb/12345678` | - | Die Anwendung sendet einen Lösch-Request für Pat-ID `12345678` | Anstelle des Pfads `/mtb` kann auch, wie in Version 0.9 und älter üblich, `/mtbfile` verwendet werden. @@ -43,12 +43,12 @@ Siehe hierzu auch: https://github.com/CCC-MF/kafka-to-bwhc ## Konfiguration -### Wichtige Änderungen in Version 0.10 +### 🔥 Wichtige Änderungen in Version 0.10 Ab Version 0.10 wird [DNPM:DIP](https://github.com/dnpm-dip) unterstützt und als Standardendpunkt verwendet. Soll noch das alte bwHC-Backend verwendet werden, so ist die Umgebungsvariable `APP_REST_IS_BWHC` auf `true` zu setzen. -### Breaking Changes nach Version 0.10 +### 🔥 Breaking Changes nach Version 0.10 In Versionen des ETL-Processors **nach Version 0.10** werden die folgenden Konfigurationsoptionen entfernt: diff --git a/docs/etl.png b/docs/etl.png index 360e7803026e0909f3a395d6826b4e8fac65cf96..bd9f37995052c036392e3fd705a4c49fecf7023f 100644 GIT binary patch literal 117875 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 literal 122673 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 From a4d0b73d2b3675c42e9e734c347172a8ae0ff5b2 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 4 Apr 2025 14:55:14 +0200 Subject: [PATCH 44/54] docs: update some information in README.md --- README.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 455c5dd..ac7742d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# ETL-Processor for bwHC data [![Run Tests](https://github.com/pcvolkmer/etl-processor/actions/workflows/test.yml/badge.svg)](https://github.com/pcvolkmer/etl-processor/actions/workflows/test.yml) +# ETL-Processor for DNPM:DIP [![Run Tests](https://github.com/pcvolkmer/etl-processor/actions/workflows/test.yml/badge.svg)](https://github.com/pcvolkmer/etl-processor/actions/workflows/test.yml) -Diese Anwendung versendet ein bwHC-MTB-File an das bwHC-Backend und pseudonymisiert die Patienten-ID. +Diese Anwendung versendet ein bwHC-MTB-File im bwHC-Datenmodell 1.0 an DNPM:DIP und pseudonymisiert die Patienten-ID. ## Einordnung innerhalb einer DNPM-ETL-Strecke @@ -9,7 +9,7 @@ Diese Anwendung erlaubt das Entgegennehmen von HTTP/REST-Anfragen aus dem Onkost Der Inhalt einer Anfrage, wenn ein bwHC-MTBFile, wird pseudonymisiert und auf Duplikate geprüft. Duplikate werden verworfen, Änderungen werden weitergeleitet. -Löschanfragen werden immer als Löschanfrage an das bwHC-backend weitergeleitet. +Löschanfragen werden immer als Löschanfrage an DNPM:DIP weitergeleitet. Zudem ist eine minimalistische Weboberfläche integriert, die einen Einblick in den aktuellen Zustand der Anwendung gewährt. @@ -22,7 +22,7 @@ Die Erkennung von Duplikaten ist normalerweise immer aktiv, kann jedoch über de ### Datenübermittlung über HTTP/REST -Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung direkt an das bwHC-Backend gesendet. +Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung direkt an DNPM:DIP gesendet. Ein HTTP Request kann, angenommen die Installation erfolgte auf dem Host `dnpm.example.com` an nachfolgende URLs gesendet werden: @@ -163,7 +163,7 @@ Sie bekommen dabei wieder die Standardrolle zugewiesen. #### Auswirkungen auf den dargestellten Inhalt Nur Administratoren haben Zugriff auf den Konfigurationsbereich, nur angemeldete Benutzer können die anonymisierte oder -pseudonymisierte Patienten-ID sowie den Qualitätsbericht des bwHC-Backends einsehen. +pseudonymisierte Patienten-ID sowie den Qualitätsbericht von DNPM:DIP einsehen. Wurde kein Administrator-Account konfiguriert, sind diese Inhalte generell nicht verfügbar. @@ -192,7 +192,7 @@ Alternativ kann eine Authentifizierung über Benutzername/Passwort oder OIDC erf ### 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. +der von DNPM:DIP 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. @@ -212,13 +212,13 @@ Werden sowohl REST als auch Kafka-Endpunkt konfiguriert, wird nur der REST-Endpu #### REST -Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an das bwHC-Backend gesendet wird: +Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an DNPM:DIP gesendet wird: -* `APP_REST_URI`: URI der zu benutzenden API der bwHC-Backend-Instanz. Zum Beispiel: +* `APP_REST_URI`: URI der zu benutzenden API der Backend-Instanz. Zum Beispiel: * `http://localhost:9000/bwhc/etl/api` für **bwHC Backend** * `http://localhost:9000/api` für **dnpm:dip** -* `APP_REST_USERNAME`: Basic-Auth-Benutzername für bwHC-Backend -* `APP_REST_PASSWORD`: Basic-Auth-Passwort für bwHC-Backend +* `APP_REST_USERNAME`: Basic-Auth-Benutzername für den REST-Endpunkt +* `APP_REST_PASSWORD`: Basic-Auth-Passwort für den REST-Endpunkt * `APP_REST_IS_BWHC`: `true` für **bwHC Backend**, weglassen oder `false` für **dnpm:dip** #### Kafka-Topics @@ -236,7 +236,7 @@ Wird keine Rückantwort über Apache Kafka empfangen und es gibt keine weitere M Weitere Einstellungen können über die Parameter von Spring Kafka konfiguriert werden. -Lässt sich keine Verbindung zu dem bwHC-Backend aufbauen, wird eine Rückantwort mit Status-Code `900` erwartet, welchen es +Lässt sich keine Verbindung zu dem Backend aufbauen, wird eine Rückantwort mit Status-Code `900` erwartet, welchen es für HTTP nicht gibt. Wird die Umgebungsvariable `APP_KAFKA_INPUT_TOPIC` gesetzt, kann eine Nachricht auch über dieses Kafka-Topic an den ETL-Prozessor übermittelt werden. @@ -273,7 +273,7 @@ kafka-configs.sh --bootstrap-server localhost:9092 --alter --topic test --add-co Da als Key eines Records die (pseudonymisierte) Patienten-ID verwendet wird, stehen mit obiger Konfiguration der Kafka-Topics nach 10 Sekunden nur noch der jeweils letzte Eintrag für den entsprechenden Key zur Verfügung. -Da der Key sowohl für die Records in Richtung bwHC-Backend für die Rückantwort identisch aufgebaut ist, lassen sich so +Da der Key sowohl für die Records in Richtung DNPM:DIP, als auch für die Rückantwort identisch aufgebaut ist, lassen sich so auch im Falle eines Consent-Widerspruchs die enthaltenen Daten als auch die Offenlegung durch Verifikationsdaten in der Antwort effektiv verhindern, da diese nach 10 Sekunden gelöscht werden. From 46015c5b66be185aa71b4b46d3bd7a665599f35b Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 4 Apr 2025 15:33:49 +0200 Subject: [PATCH 45/54] chore: update to Spring Boot 3.4 --- build.gradle.kts | 12 +++++------- .../dnpm/etl/processor/web/ConfigControllerTest.kt | 4 ++-- .../dev/dnpm/etl/processor/web/HomeControllerTest.kt | 4 ++-- .../dnpm/etl/processor/web/LoginControllerTest.kt | 4 ++-- .../etl/processor/web/StatisticsControllerTest.kt | 2 +- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index acd68cf..864135b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage plugins { war - id("org.springframework.boot") version "3.3.10" + id("org.springframework.boot") version "3.4.4" id("io.spring.dependency-management") version "1.1.7" kotlin("jvm") version "1.9.25" kotlin("plugin.spring") version "1.9.25" @@ -13,12 +13,11 @@ plugins { } group = "dev.dnpm" -version = "0.10.0-SNAPSHOT" +version = "0.11.0-SNAPSHOT" var versions = mapOf( "bwhc-dto-java" to "0.4.0", "hapi-fhir" to "7.6.0", - "commons-compress" to "1.26.2", "mockito-kotlin" to "5.4.0", "archunit" to "1.3.0", // Webjars @@ -99,10 +98,8 @@ dependencies { integrationTestImplementation("org.testcontainers:junit-jupiter") integrationTestImplementation("org.testcontainers:postgresql") integrationTestImplementation("com.tngtech.archunit:archunit:${versions["archunit"]}") - integrationTestImplementation("net.sourceforge.htmlunit:htmlunit") + integrationTestImplementation("org.htmlunit:htmlunit") integrationTestImplementation("org.springframework:spring-webflux") - // Override dependency version from org.testcontainers:junit-jupiter - CVE-2024-26308, CVE-2024-25710 - integrationTestImplementation("org.apache.commons:commons-compress:${versions["commons-compress"]}") } tasks.withType { @@ -119,8 +116,9 @@ tasks.withType { } } -task("integrationTest") { +tasks.register("integrationTest") { description = "Runs integration tests" + group = "verification" testClassesDirs = sourceSets["integrationTest"].output.classesDirs classpath = sourceSets["integrationTest"].runtimeClasspath diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt index af4650d..8fbcd95 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt @@ -19,8 +19,6 @@ package dev.dnpm.etl.processor.web -import com.gargoylesoftware.htmlunit.WebClient -import com.gargoylesoftware.htmlunit.html.HtmlPage import dev.dnpm.etl.processor.config.AppConfiguration import dev.dnpm.etl.processor.config.AppSecurityConfiguration import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult @@ -34,6 +32,8 @@ import dev.dnpm.etl.processor.security.TokenService import dev.dnpm.etl.processor.services.TransformationService import dev.dnpm.etl.processor.security.UserRoleService import org.assertj.core.api.Assertions.assertThat +import org.htmlunit.WebClient +import org.htmlunit.html.HtmlPage import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt index 82835b4..5d20f30 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt @@ -19,8 +19,6 @@ package dev.dnpm.etl.processor.web -import com.gargoylesoftware.htmlunit.WebClient -import com.gargoylesoftware.htmlunit.html.HtmlPage import dev.dnpm.etl.processor.* import dev.dnpm.etl.processor.config.AppConfiguration import dev.dnpm.etl.processor.config.AppSecurityConfiguration @@ -30,6 +28,8 @@ import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestType import dev.dnpm.etl.processor.services.RequestService import org.assertj.core.api.Assertions.assertThat +import org.htmlunit.WebClient +import org.htmlunit.html.HtmlPage import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt index 0471543..f494e72 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt @@ -19,12 +19,12 @@ package dev.dnpm.etl.processor.web -import com.gargoylesoftware.htmlunit.WebClient -import com.gargoylesoftware.htmlunit.html.HtmlPage import dev.dnpm.etl.processor.config.AppConfiguration import dev.dnpm.etl.processor.config.AppSecurityConfiguration import dev.dnpm.etl.processor.security.TokenService import org.assertj.core.api.Assertions.assertThat +import org.htmlunit.WebClient +import org.htmlunit.html.HtmlPage import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsControllerTest.kt index 424a0e3..b55a702 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsControllerTest.kt @@ -19,9 +19,9 @@ package dev.dnpm.etl.processor.web -import com.gargoylesoftware.htmlunit.WebClient import dev.dnpm.etl.processor.config.AppConfiguration import dev.dnpm.etl.processor.config.AppSecurityConfiguration +import org.htmlunit.WebClient import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith From b78dc3519b70aa59b53c7b086cdfc1ead60fefd2 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 4 Apr 2025 16:13:07 +0200 Subject: [PATCH 46/54] refactor: replace deprecated MockBean annotations (#95) --- .../processor/EtlProcessorApplicationTests.kt | 6 +-- .../processor/config/AppConfigurationTest.kt | 51 ++++++++++--------- .../input/MtbFileRestControllerTest.kt | 6 +-- .../monitoring/RequestRepositoryTest.kt | 4 +- .../services/RequestServiceIntegrationTest.kt | 4 +- .../etl/processor/web/ConfigControllerTest.kt | 36 +++++++------ .../etl/processor/web/HomeControllerTest.kt | 6 +-- .../etl/processor/web/LoginControllerTest.kt | 6 +-- .../web/StatisticsRestControllerTest.kt | 6 +-- 9 files changed, 68 insertions(+), 57 deletions(-) diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt index 67d2d05..736bdf8 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt @@ -33,10 +33,10 @@ import org.mockito.kotlin.* 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.mock.mockito.MockBean import org.springframework.context.ApplicationContext import org.springframework.http.MediaType import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.post @@ -45,7 +45,7 @@ import org.testcontainers.junit.jupiter.Testcontainers @Testcontainers @ExtendWith(SpringExtension::class) @SpringBootTest -@MockBean(MtbFileSender::class) +@MockitoBean(types = [MtbFileSender::class]) @TestPropertySource( properties = [ "app.rest.uri=http://example.com", @@ -73,7 +73,7 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() { ) inner class TransformationTest { - @MockBean + @MockitoBean private lateinit var mtbFileSender: MtbFileSender @Autowired diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt index c7454ed..af62bf3 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt @@ -26,9 +26,9 @@ import dev.dnpm.etl.processor.output.KafkaMtbFileSender import dev.dnpm.etl.processor.output.RestMtbFileSender import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator -import dev.dnpm.etl.processor.services.RequestProcessor import dev.dnpm.etl.processor.security.TokenRepository import dev.dnpm.etl.processor.security.TokenService +import dev.dnpm.etl.processor.services.RequestProcessor import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -36,24 +36,25 @@ import org.junit.jupiter.api.assertThrows import org.springframework.beans.factory.NoSuchBeanDefinitionException import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.boot.test.mock.mockito.MockBeans import org.springframework.context.ApplicationContext import org.springframework.retry.support.RetryTemplate import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.provisioning.InMemoryUserDetailsManager import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.bean.override.mockito.MockitoBean @SpringBootTest -@ContextConfiguration(classes = [ - AppConfiguration::class, - AppSecurityConfiguration::class, - KafkaAutoConfiguration::class, - AppKafkaConfiguration::class, - AppRestConfiguration::class -]) -@MockBean(ObjectMapper::class) +@ContextConfiguration( + classes = [ + AppConfiguration::class, + AppSecurityConfiguration::class, + KafkaAutoConfiguration::class, + AppKafkaConfiguration::class, + AppRestConfiguration::class + ] +) +@MockitoBean(types = [ObjectMapper::class]) @TestPropertySource( properties = [ "app.pseudonymize.generator=BUILDIN", @@ -86,7 +87,7 @@ class AppConfigurationTest { "app.kafka.group-id=test" ] ) - @MockBean(RequestRepository::class) + @MockitoBean(types = [RequestRepository::class]) inner class AppConfigurationKafkaTest(private val context: ApplicationContext) { @Test @@ -145,7 +146,7 @@ class AppConfigurationTest { "app.kafka.group-id=test" ] ) - @MockBean(RequestProcessor::class) + @MockitoBean(types = [RequestProcessor::class]) inner class AppConfigurationUsingKafkaInputTest(private val context: ApplicationContext) { @Test @@ -248,11 +249,13 @@ class AppConfigurationTest { "app.security.enable-tokens=true" ] ) - @MockBeans(value = [ - MockBean(InMemoryUserDetailsManager::class), - MockBean(PasswordEncoder::class), - MockBean(TokenRepository::class) - ]) + @MockitoBean( + types = [ + InMemoryUserDetailsManager::class, + PasswordEncoder::class, + TokenRepository::class + ] + ) inner class AppConfigurationTokenEnabledTest(private val context: ApplicationContext) { @Test @@ -263,11 +266,13 @@ class AppConfigurationTest { } @Nested - @MockBeans(value = [ - MockBean(InMemoryUserDetailsManager::class), - MockBean(PasswordEncoder::class), - MockBean(TokenRepository::class) - ]) + @MockitoBean( + types = [ + InMemoryUserDetailsManager::class, + PasswordEncoder::class, + TokenRepository::class + ] + ) inner class AppConfigurationTokenDisabledTest(private val context: ApplicationContext) { @Test diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt index 670020f..85b1f1f 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt @@ -37,13 +37,13 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.http.MediaType import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.delete @@ -57,7 +57,7 @@ import org.springframework.test.web.servlet.post AppSecurityConfiguration::class ] ) -@MockBean(TokenRepository::class, RequestProcessor::class) +@MockitoBean(types = [TokenRepository::class, RequestProcessor::class]) @TestPropertySource( properties = [ "app.pseudonymize.generator=BUILDIN", @@ -156,7 +156,7 @@ class MtbFileRestControllerTest { } @Nested - @MockBean(UserRoleRepository::class, ClientRegistrationRepository::class) + @MockitoBean(types = [UserRoleRepository::class, ClientRegistrationRepository::class]) @TestPropertySource( properties = [ "app.pseudonymize.generator=BUILDIN", diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/monitoring/RequestRepositoryTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/monitoring/RequestRepositoryTest.kt index bef124c..428bca9 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/monitoring/RequestRepositoryTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/monitoring/RequestRepositoryTest.kt @@ -27,8 +27,8 @@ import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase -import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.transaction.annotation.Transactional import org.testcontainers.junit.jupiter.Testcontainers @@ -39,7 +39,7 @@ import java.time.Instant @DataJdbcTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @Transactional -@MockBean(MtbFileSender::class) +@MockitoBean(types = [MtbFileSender::class]) @TestPropertySource( properties = [ "app.pseudonymize.generator=buildin", diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt index 47ac301..9fcdc16 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt @@ -31,8 +31,8 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.transaction.annotation.Transactional import org.testcontainers.junit.jupiter.Testcontainers @@ -42,7 +42,7 @@ import java.time.Instant @ExtendWith(SpringExtension::class) @SpringBootTest @Transactional -@MockBean(MtbFileSender::class) +@MockitoBean(types = [MtbFileSender::class]) @TestPropertySource( properties = [ "app.pseudonymize.generator=buildin", diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt index 8fbcd95..9f3ae62 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt @@ -27,10 +27,10 @@ import dev.dnpm.etl.processor.monitoring.RestConnectionCheckService import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.pseudonym.Generator import dev.dnpm.etl.processor.security.Role -import dev.dnpm.etl.processor.services.RequestProcessor import dev.dnpm.etl.processor.security.TokenService -import dev.dnpm.etl.processor.services.TransformationService import dev.dnpm.etl.processor.security.UserRoleService +import dev.dnpm.etl.processor.services.RequestProcessor +import dev.dnpm.etl.processor.services.TransformationService import org.assertj.core.api.Assertions.assertThat import org.htmlunit.WebClient import org.htmlunit.html.HtmlPage @@ -46,7 +46,6 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.http.MediaType.TEXT_EVENT_STREAM @@ -55,6 +54,7 @@ import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequ import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.test.web.servlet.* @@ -81,14 +81,16 @@ abstract class MockSink : Sinks.Many "app.pseudonymize.generator=BUILDIN" ] ) -@MockBean(name = "configsUpdateProducer", classes = [MockSink::class]) -@MockBean( - Generator::class, - MtbFileSender::class, - RequestProcessor::class, - TransformationService::class, - GPasConnectionCheckService::class, - RestConnectionCheckService::class, +@MockitoBean(name = "configsUpdateProducer", types = [MockSink::class]) +@MockitoBean( + types = [ + Generator::class, + MtbFileSender::class, + RequestProcessor::class, + TransformationService::class, + GPasConnectionCheckService::class, + RestConnectionCheckService::class + ] ) class ConfigControllerTest { @@ -143,8 +145,10 @@ class ConfigControllerTest { "app.security.admin-user=admin" ] ) - @MockBean( - TokenService::class + @MockitoBean( + types = [ + TokenService::class + ] ) inner class WithTokensEnabled { private lateinit var tokenService: TokenService @@ -252,8 +256,10 @@ class ConfigControllerTest { "app.security.admin-password={noop}very-secret" ] ) - @MockBean( - UserRoleService::class + @MockitoBean( + types = [ + UserRoleService::class + ] ) inner class WithUserRolesEnabled { private lateinit var userRoleService: UserRoleService diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt index 5d20f30..829f9a1 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt @@ -40,13 +40,13 @@ import org.mockito.kotlin.any import org.mockito.kotlin.whenever import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.data.domain.Page import org.springframework.data.domain.PageImpl import org.springframework.data.domain.Pageable import org.springframework.security.test.context.support.WithMockUser import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get @@ -71,8 +71,8 @@ import java.util.* "app.security.admin-password={noop}very-secret" ] ) -@MockBean( - RequestService::class +@MockitoBean( + types = [RequestService::class] ) class HomeControllerTest { diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt index f494e72..54ad6e8 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt @@ -31,9 +31,9 @@ import org.junit.jupiter.api.extension.ExtendWith import org.mockito.junit.jupiter.MockitoExtension import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get @@ -56,8 +56,8 @@ import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder "app.security.enable-tokens=true" ] ) -@MockBean( - TokenService::class, +@MockitoBean( + types = [TokenService::class] ) class LoginControllerTest { diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt index 8164f15..f0c3b63 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt @@ -41,10 +41,10 @@ import org.mockito.kotlin.doAnswer import org.mockito.kotlin.whenever import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.http.MediaType.TEXT_EVENT_STREAM import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.test.web.servlet.MockMvc @@ -74,8 +74,8 @@ import java.time.temporal.ChronoUnit "app.security.admin-password={noop}very-secret" ] ) -@MockBean( - RequestService::class +@MockitoBean( + types = [RequestService::class] ) class StatisticsRestControllerTest { From 9d4786fae3904b27b99e5fc90f0b1f764af0c961 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 4 Apr 2025 16:39:47 +0200 Subject: [PATCH 47/54] refactor: update use of deprecated methods (#96) --- .../config/AppSecurityConfiguration.kt | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt index ddcf202..762c7d8 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -87,9 +87,14 @@ class AppSecurityConfiguration( @Bean @ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true") - fun filterChainOidc(http: HttpSecurity, passwordEncoder: PasswordEncoder, userRoleRepository: UserRoleRepository, sessionRegistry: SessionRegistry): SecurityFilterChain { + fun filterChainOidc( + http: HttpSecurity, + passwordEncoder: PasswordEncoder, + userRoleRepository: UserRoleRepository, + sessionRegistry: SessionRegistry + ): SecurityFilterChain { http { - authorizeRequests { + authorizeHttpRequests { authorize("/configs/**", hasRole("ADMIN")) authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN", "USER")) authorize("/report/**", hasAnyRole("ADMIN", "USER")) @@ -127,13 +132,22 @@ class AppSecurityConfiguration( @Bean @ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true") - fun grantedAuthoritiesMapper(userRoleRepository: UserRoleRepository, appSecurityConfigProperties: SecurityConfigProperties): GrantedAuthoritiesMapper { + fun grantedAuthoritiesMapper( + userRoleRepository: UserRoleRepository, + appSecurityConfigProperties: SecurityConfigProperties + ): GrantedAuthoritiesMapper { return GrantedAuthoritiesMapper { grantedAuthority -> grantedAuthority.filterIsInstance() .onEach { val userRole = userRoleRepository.findByUsername(it.userInfo.preferredUsername) if (userRole.isEmpty) { - userRoleRepository.save(UserRole(null, it.userInfo.preferredUsername, appSecurityConfigProperties.defaultNewUserRole)) + userRoleRepository.save( + UserRole( + null, + it.userInfo.preferredUsername, + appSecurityConfigProperties.defaultNewUserRole + ) + ) } } .map { @@ -147,7 +161,7 @@ class AppSecurityConfiguration( @ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "false", matchIfMissing = true) fun filterChain(http: HttpSecurity, passwordEncoder: PasswordEncoder): SecurityFilterChain { http { - authorizeRequests { + authorizeHttpRequests { authorize("/configs/**", hasRole("ADMIN")) authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN")) authorize("/report/**", hasRole("ADMIN")) From 66cc818755c54b746b87e45fe1d13804aedbf41d Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 4 Apr 2025 17:06:09 +0200 Subject: [PATCH 48/54] feat: remove SSL-CA-Location config (#99) --- README.md | 9 -- .../pseudonym/GpasPseudonymGeneratorTest.kt | 11 +- .../processor/config/AppConfigProperties.kt | 6 +- .../etl/processor/config/AppConfiguration.kt | 100 +----------------- 4 files changed, 7 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index ac7742d..d966358 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,6 @@ Soll noch das alte bwHC-Backend verwendet werden, so ist die Umgebungsvariable In Versionen des ETL-Processors **nach Version 0.10** werden die folgenden Konfigurationsoptionen entfernt: -* `APP_PSEUDONYMIZE_GPAS_SSLCALOCATION`: Nutzen Sie hier, wie unter [_Integration eines eigenen Root CA - Zertifikats_](#integration-eines-eigenen-root-ca-zertifikats) beschrieben, das Einbinden eigener Zertifikate. * `APP_KAFKA_TOPIC`: Nutzen Sie nun die Konfigurationsoption `APP_KAFKA_OUTPUT_TOPIC` * `APP_KAFKA_RESPONSE_TOPIC`: Nutzen Sie nun die Konfigurationsoption `APP_KAFKA_OUTPUT_RESPONSE_TOPIC` @@ -90,13 +88,6 @@ Wurde die Verwendung von gPAS konfiguriert, so sind weitere Angaben zu konfiguri * `APP_PSEUDONYMIZE_GPAS_TARGET`: gPas Domänenname * `APP_PSEUDONYMIZE_GPAS_USERNAME`: gPas Basic-Auth Benutzername * `APP_PSEUDONYMIZE_GPAS_PASSWORD`: gPas Basic-Auth Passwort -* ~~`APP_PSEUDONYMIZE_GPAS_SSLCALOCATION`~~: **Veraltet** - Root Zertifikat für gPas, falls es dediziert hinzugefügt werden muss. - **Wird in nach Version 0.10 entfernt** - -Der Konfigurationsparameter `APP_PSEUDONYMIZE_GPAS_SSLCALOCATION` sollte nicht mehr verwendet werden und wird nach -Version 0.10 entfernt. -Stattdessen sollte das Root Zertifikat wie unter [_Integration eines eigenen Root CA -Zertifikats_](#integration-eines-eigenen-root-ca-zertifikats) beschrieben eingebunden werden. ### Anmeldung mit einem Passwort diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt index da0c55c..2e539e9 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -47,10 +47,9 @@ class GpasPseudonymGeneratorTest { fun setup() { val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() val gPasConfigProperties = GPasConfigProperties( - "http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate", + "https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate", "test", null, - null, null ) @@ -63,7 +62,7 @@ class GpasPseudonymGeneratorTest { fun shouldReturnExpectedPseudonym() { this.mockRestServiceServer.expect { method(HttpMethod.POST) - requestTo("http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") + requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") }.andRespond { withStatus(HttpStatus.OK).body(getDummyResponseBody("1234", "test", "test1234ABCDEF567890")) .createResponse(it) @@ -76,7 +75,7 @@ class GpasPseudonymGeneratorTest { fun shouldThrowExceptionIfGpasNotAvailable() { this.mockRestServiceServer.expect { method(HttpMethod.POST) - requestTo("http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") + requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") }.andRespond { withException(IOException("Simulated IO error")).createResponse(it) } @@ -88,7 +87,7 @@ class GpasPseudonymGeneratorTest { fun shouldThrowExceptionIfGpasDoesNotReturn2xxResponse() { this.mockRestServiceServer.expect { method(HttpMethod.POST) - requestTo("http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") + requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") }.andRespond { withStatus(HttpStatus.FOUND) .header(HttpHeaders.LOCATION, "https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt index 7c192c8..7a077c3 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -56,10 +56,6 @@ data class GPasConfigProperties( val target: String = "etl-processor", val username: String?, val password: String?, - @get:DeprecatedConfigurationProperty( - reason = "Deprecated in favor of including Root CA" - ) - val sslCaLocation: String? ) { companion object { const val NAME = "app.pseudonymize.gpas" diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt index 66af288..9002d15 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -32,12 +32,6 @@ import dev.dnpm.etl.processor.security.TokenRepository import dev.dnpm.etl.processor.security.TokenService import dev.dnpm.etl.processor.services.Transformation import dev.dnpm.etl.processor.services.TransformationService -import org.apache.hc.client5.http.impl.classic.HttpClients -import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager -import org.apache.hc.client5.http.socket.ConnectionSocketFactory -import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory -import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory -import org.apache.hc.core5.http.config.RegistryBuilder import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty @@ -45,7 +39,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory import org.springframework.retry.RetryCallback import org.springframework.retry.RetryContext import org.springframework.retry.RetryListener @@ -58,13 +51,6 @@ import org.springframework.security.provisioning.InMemoryUserDetailsManager import org.springframework.web.client.HttpClientErrorException import org.springframework.web.client.RestTemplate import reactor.core.publisher.Sinks -import java.io.BufferedInputStream -import java.io.FileInputStream -import java.security.KeyStore -import java.security.cert.CertificateFactory -import java.security.cert.X509Certificate -import javax.net.ssl.SSLContext -import javax.net.ssl.TrustManagerFactory import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration @@ -90,18 +76,6 @@ class AppConfiguration { @ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS") @Bean fun gpasPseudonymGenerator(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate, restTemplate: RestTemplate): Generator { - try { - if (!configProperties.sslCaLocation.isNullOrBlank()) { - return GpasPseudonymGenerator( - configProperties, - retryTemplate, - createCustomGpasRestTemplate(configProperties) - ) - } - } catch (e: Exception) { - throw RuntimeException(e) - } - return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate) } @@ -115,81 +89,9 @@ class AppConfiguration { @ConditionalOnMissingBean @Bean fun gpasPseudonymGeneratorOnDeprecatedProperty(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate, restTemplate: RestTemplate): Generator { - try { - if (!configProperties.sslCaLocation.isNullOrBlank()) { - return GpasPseudonymGenerator( - configProperties, - retryTemplate, - createCustomGpasRestTemplate(configProperties) - ) - } - } catch (e: Exception) { - throw RuntimeException(e) - } - return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate) } - private fun createCustomGpasRestTemplate(configProperties: GPasConfigProperties): RestTemplate { - fun getSslContext(certificateLocation: String): SSLContext? { - val ks = KeyStore.getInstance(KeyStore.getDefaultType()) - - val fis = FileInputStream(certificateLocation) - val ca = CertificateFactory.getInstance("X.509") - .generateCertificate(BufferedInputStream(fis)) as X509Certificate - - ks.load(null, null) - ks.setCertificateEntry(1.toString(), ca) - - val tmf = TrustManagerFactory.getInstance( - TrustManagerFactory.getDefaultAlgorithm() - ) - tmf.init(ks) - - val sslContext = SSLContext.getInstance("TLS") - sslContext.init(null, tmf.trustManagers, null) - - return sslContext - } - - fun getCustomRestTemplate(customSslContext: SSLContext): RestTemplate { - val sslsf = SSLConnectionSocketFactory(customSslContext) - val socketFactoryRegistry = RegistryBuilder.create() - .register("https", sslsf).register("http", PlainConnectionSocketFactory()).build() - - val connectionManager = BasicHttpClientConnectionManager( - socketFactoryRegistry - ) - val httpClient = HttpClients.custom() - .setConnectionManager(connectionManager).build() - - val requestFactory = HttpComponentsClientHttpRequestFactory( - httpClient - ) - return RestTemplate(requestFactory) - } - - try { - if (!configProperties.sslCaLocation.isNullOrBlank()) { - val customSslContext = getSslContext(configProperties.sslCaLocation) - logger.warn( - String.format( - "%s has been initialized with SSL certificate %s. This is deprecated in favor of including Root CA.", - this.javaClass.name, configProperties.sslCaLocation - ) - ) - - if (customSslContext != null) { - return getCustomRestTemplate(customSslContext) - } - } - } catch (e: Exception) { - throw RuntimeException(e) - } - - throw RuntimeException("Custom SSL configuration for gPAS not usable") - } - @ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "BUILDIN") @ConditionalOnMissingBean @Bean From 48b1e62e2241db42b787ab192cdd695f6ac64601 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Fri, 4 Apr 2025 17:31:50 +0200 Subject: [PATCH 49/54] feat: remove obsolete config params (#101) --- README.md | 12 ++---- .../processor/config/AppConfigurationTest.kt | 38 +------------------ .../processor/config/AppConfigProperties.kt | 18 +-------- .../etl/processor/config/AppConfiguration.kt | 14 ------- .../processor/config/AppKafkaConfiguration.kt | 4 +- .../processor/output/KafkaMtbFileSender.kt | 6 +-- 6 files changed, 12 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index d966358..dd465c1 100644 --- a/README.md +++ b/README.md @@ -66,13 +66,11 @@ Ist diese nicht gesetzt. wird intern eine Anonymisierung der Patienten-ID vorgen * `APP_PSEUDONYMIZE_PREFIX`: Standortbezogenes Präfix - `UNKNOWN`, wenn nicht gesetzt * `APP_PSEUDONYMIZE_GENERATOR`: `BUILDIN` oder `GPAS` - `BUILDIN`, wenn nicht gesetzt -**Hinweise**: +**Hinweis** -* Der alte Konfigurationsparameter `APP_PSEUDONYMIZER` mit den Werten `GPAS` oder `BUILDIN` sollte nicht mehr verwendet - werden. -* Die Pseudonymisierung erfolgt im ETL-Prozessor nur für die Patienten-ID. - Andere IDs werden mithilfe des standortbezogenen Präfixes (erneut) anonymisiert, um für den aktuellen Kontext nicht - vergleichbare IDs bereitzustellen. +Die Pseudonymisierung erfolgt im ETL-Prozessor nur für die Patienten-ID. +Andere IDs werden mithilfe des standortbezogenen Präfixes (erneut) anonymisiert, um für den aktuellen Kontext nicht +vergleichbare IDs bereitzustellen. #### Eingebaute Anonymisierung @@ -217,9 +215,7 @@ Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an DNP Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an ein Kafka-Topic übermittelt wird: * `APP_KAFKA_OUTPUT_TOPIC`: Zu verwendendes Topic zum Versenden von Anfragen. - Ersetzt ~~`APP_KAFKA_TOPIC`~~, **welches nach Version 0.10 entfernt wird**. * `APP_KAFKA_OUTPUT_RESPONSE_TOPIC`: Topic mit Antworten über den Erfolg des Versendens. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_response". - Ersetzt ~~`APP_KAFKA_RESPONSE_TOPIC`~~, **welches nach Version 0.10 entfernt wird**. * `APP_KAFKA_GROUP_ID`: Kafka GroupID des Consumers. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_group". * `APP_KAFKA_SERVERS`: Zu verwendende Kafka-Bootstrap-Server als kommagetrennte Liste diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt index af62bf3..39a0997 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt @@ -182,40 +182,7 @@ class AppConfigurationTest { @Nested @TestPropertySource( properties = [ - "app.pseudonymize.generator=", - "app.pseudonymizer=buildin", - ] - ) - inner class AppConfigurationPseudonymizerBuildinTest(private val context: ApplicationContext) { - - @Test - fun shouldUseConfiguredGenerator() { - assertThat(context.getBean(AnonymizingGenerator::class.java)).isNotNull - } - - } - - @Nested - @TestPropertySource( - properties = [ - "app.pseudonymize.generator=", - "app.pseudonymizer=gpas", - ] - ) - inner class AppConfigurationPseudonymizerGpasTest(private val context: ApplicationContext) { - - @Test - fun shouldUseConfiguredGenerator() { - assertThat(context.getBean(GpasPseudonymGenerator::class.java)).isNotNull - } - - } - - @Nested - @TestPropertySource( - properties = [ - "app.pseudonymize.generator=buildin", - "app.pseudonymizer=", + "app.pseudonymize.generator=buildin" ] ) inner class AppConfigurationPseudonymizeGeneratorBuildinTest(private val context: ApplicationContext) { @@ -230,8 +197,7 @@ class AppConfigurationTest { @Nested @TestPropertySource( properties = [ - "app.pseudonymize.generator=gpas", - "app.pseudonymizer=", + "app.pseudonymize.generator=gpas" ] ) inner class AppConfigurationPseudonymizeGeneratorGpasTest(private val context: ApplicationContext) { diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt index 7a077c3..331c8b5 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt @@ -21,16 +21,10 @@ package dev.dnpm.etl.processor.config import dev.dnpm.etl.processor.security.Role import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.boot.context.properties.DeprecatedConfigurationProperty @ConfigurationProperties(AppConfigProperties.NAME) data class AppConfigProperties( var bwhcUri: String?, - @get:DeprecatedConfigurationProperty( - reason = "Deprecated in favor of 'app.pseudonymize.generator'", - replacement = "app.pseudonymize.generator" - ) - var pseudonymizer: PseudonymGenerator = PseudonymGenerator.BUILDIN, var transformations: List = listOf(), var maxRetryAttempts: Int = 3, var duplicationDetection: Boolean = true @@ -78,18 +72,8 @@ data class RestTargetProperties( data class KafkaProperties( val inputTopic: String?, val outputTopic: String = "etl-processor", - @get:DeprecatedConfigurationProperty( - reason = "Deprecated", - replacement = "outputTopic" - ) - val topic: String = outputTopic, val outputResponseTopic: String = "${outputTopic}_response", - @get:DeprecatedConfigurationProperty( - reason = "Deprecated", - replacement = "outputResponseTopic" - ) - val responseTopic: String = outputResponseTopic, - val groupId: String = "${topic}_group", + val groupId: String = "${outputTopic}_group", val servers: String = "" ) { companion object { diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt index 9002d15..c8f3fba 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt @@ -85,20 +85,6 @@ class AppConfiguration { return AnonymizingGenerator() } - @ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS") - @ConditionalOnMissingBean - @Bean - fun gpasPseudonymGeneratorOnDeprecatedProperty(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate, restTemplate: RestTemplate): Generator { - return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate) - } - - @ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "BUILDIN") - @ConditionalOnMissingBean - @Bean - fun buildinPseudonymGeneratorOnDeprecatedProperty(): Generator { - return AnonymizingGenerator() - } - @Bean fun pseudonymizeService( generator: Generator, diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppKafkaConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppKafkaConfiguration.kt index 80c66d2..de11cbb 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppKafkaConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppKafkaConfiguration.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -71,7 +71,7 @@ class AppKafkaConfiguration { kafkaProperties: KafkaProperties, kafkaResponseProcessor: KafkaResponseProcessor ): KafkaMessageListenerContainer { - val containerProperties = ContainerProperties(kafkaProperties.responseTopic) + val containerProperties = ContainerProperties(kafkaProperties.outputResponseTopic) containerProperties.messageListener = kafkaResponseProcessor return KafkaMessageListenerContainer(consumerFactory, containerProperties) } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt index 4838689..6391e99 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt @@ -42,7 +42,7 @@ class KafkaMtbFileSender( return try { return retryTemplate.execute { val result = kafkaTemplate.send( - kafkaProperties.topic, + kafkaProperties.outputTopic, key(request), objectMapper.writeValueAsString(Data(request.requestId, request.mtbFile)) ) @@ -72,7 +72,7 @@ class KafkaMtbFileSender( return try { return retryTemplate.execute { val result = kafkaTemplate.send( - kafkaProperties.topic, + kafkaProperties.outputTopic, key(request), objectMapper.writeValueAsString(Data(request.requestId, dummyMtbFile)) ) @@ -91,7 +91,7 @@ class KafkaMtbFileSender( } override fun endpoint(): String { - return "${this.kafkaProperties.servers} (${this.kafkaProperties.topic}/${this.kafkaProperties.responseTopic})" + return "${this.kafkaProperties.servers} (${this.kafkaProperties.outputTopic}/${this.kafkaProperties.outputResponseTopic})" } private fun key(request: MtbFileSender.MtbFileRequest): String { From 7d97365aea81391ae74d64c815261a059bb48c73 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sun, 6 Apr 2025 13:36:30 +0200 Subject: [PATCH 50/54] feat: add endpoint for DNPM-Datamodel V2 using content negotiation (#104) This simply adds an REST endpoint without proper implementation. The goal is to accept DNPM V2 JSON data. --- build.gradle.kts | 11 + .../dev/dnpm/etl/processor/extensions.kt | 35 + .../etl/processor/input/KafkaInputListener.kt | 37 +- .../processor/input/MtbFileRestController.kt | 13 +- .../processor/input/KafkaInputListenerTest.kt | 84 +- .../input/MtbFileRestControllerTest.kt | 36 + .../resources/mv64e-mtb-fake-patient.json | 2243 +++++++++++++++++ 7 files changed, 2446 insertions(+), 13 deletions(-) create mode 100644 src/main/kotlin/dev/dnpm/etl/processor/extensions.kt create mode 100644 src/test/resources/mv64e-mtb-fake-patient.json diff --git a/build.gradle.kts b/build.gradle.kts index 864135b..c093fdb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,6 +17,7 @@ version = "0.11.0-SNAPSHOT" var versions = mapOf( "bwhc-dto-java" to "0.4.0", + "mtb-dto" to "0.1.0-SNAPSHOT", "hapi-fhir" to "7.6.0", "mockito-kotlin" to "5.4.0", "archunit" to "1.3.0", @@ -48,9 +49,18 @@ configurations { compileOnly { extendsFrom(configurations.annotationProcessor.get()) } + + all { + resolutionStrategy { + cacheChangingModulesFor(5, "minutes") + } + } } repositories { + maven { + url = uri("https://git.dnpm.dev/api/packages/public-snapshots/maven") + } maven { url = uri("https://git.dnpm.dev/api/packages/public/maven") } @@ -72,6 +82,7 @@ dependencies { implementation("commons-codec:commons-codec") implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") implementation("de.ukw.ccc:bwhc-dto-java:${versions["bwhc-dto-java"]}") + implementation("dev.pcvolkmer.mv64e:mtb-dto:${versions["mtb-dto"]}") { isChanging = true } implementation("ca.uhn.hapi.fhir:hapi-fhir-base:${versions["hapi-fhir"]}") implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${versions["hapi-fhir"]}") implementation("org.apache.httpcomponents.client5:httpclient5") diff --git a/src/main/kotlin/dev/dnpm/etl/processor/extensions.kt b/src/main/kotlin/dev/dnpm/etl/processor/extensions.kt new file mode 100644 index 0000000..060ecb2 --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/extensions.kt @@ -0,0 +1,35 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2025 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 . + */ + +package dev.dnpm.etl.processor + +import org.springframework.http.MediaType + +/** + * Custom MediaTypes + * + * @since 0.11.0 + */ +object CustomMediaType { + val APPLICATION_VND_DNPM_V2_MTB_JSON = MediaType("application", "vnd.dnpm.v2.mtb+json") + const val APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE = "application/vnd.dnpm.v2.mtb+json" + + val APPLICATION_VND_DNPM_V2_RD_JSON = MediaType("application", "vnd.dnpm.v2.rd+json") + const val APPLICATION_VND_DNPM_V2_RD_JSON_VALUE = "application/vnd.dnpm.v2.rd+json" +} diff --git a/src/main/kotlin/dev/dnpm/etl/processor/input/KafkaInputListener.kt b/src/main/kotlin/dev/dnpm/etl/processor/input/KafkaInputListener.kt index 2aff8cb..e797390 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/input/KafkaInputListener.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/input/KafkaInputListener.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -22,11 +22,13 @@ package dev.dnpm.etl.processor.input import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.MtbFile +import dev.dnpm.etl.processor.CustomMediaType import dev.dnpm.etl.processor.PatientId import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.services.RequestProcessor import org.apache.kafka.clients.consumer.ConsumerRecord import org.slf4j.LoggerFactory +import org.springframework.http.MediaType import org.springframework.kafka.listener.MessageListener class KafkaInputListener( @@ -35,10 +37,29 @@ class KafkaInputListener( ) : MessageListener { private val logger = LoggerFactory.getLogger(KafkaInputListener::class.java) - override fun onMessage(data: ConsumerRecord) { - val mtbFile = objectMapper.readValue(data.value(), MtbFile::class.java) + override fun onMessage(record: ConsumerRecord) { + when (guessMimeType(record)) { + MediaType.APPLICATION_JSON_VALUE -> handleBwhcMessage(record) + CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE -> handleDnpmV2Message(record) + else -> { + /* ignore other messages */ + } + } + } + + private fun guessMimeType(record: ConsumerRecord): String { + if (record.headers().headers("contentType").toList().isEmpty()) { + // Fallback if no contentType set (old behavior) + return MediaType.APPLICATION_JSON_VALUE + } + + return record.headers().headers("contentType")?.firstOrNull()?.value().contentToString() + } + + private fun handleBwhcMessage(record: ConsumerRecord) { + val mtbFile = objectMapper.readValue(record.value(), MtbFile::class.java) val patientId = PatientId(mtbFile.patient.id) - val firstRequestIdHeader = data.headers().headers("requestId")?.firstOrNull() + val firstRequestIdHeader = record.headers().headers("requestId")?.firstOrNull() val requestId = if (null != firstRequestIdHeader) { RequestId(String(firstRequestIdHeader.value())) } else { @@ -61,4 +82,10 @@ class KafkaInputListener( } } } -} \ No newline at end of file + + private fun handleDnpmV2Message(record: ConsumerRecord) { + // Do not handle DNPM-V2 for now + logger.warn("Ignoring MTB File in DNPM V2 format: Not implemented yet") + } + +} diff --git a/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt b/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt index 123a84f..432711a 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt @@ -21,9 +21,13 @@ package dev.dnpm.etl.processor.input import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.MtbFile +import dev.dnpm.etl.processor.CustomMediaType import dev.dnpm.etl.processor.PatientId import dev.dnpm.etl.processor.services.RequestProcessor +import dev.pcvolkmer.mv64e.mtb.Mtb import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -40,7 +44,7 @@ class MtbFileRestController( return ResponseEntity.ok("Test") } - @PostMapping + @PostMapping( consumes = [ MediaType.APPLICATION_JSON_VALUE ] ) fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity { if (mtbFile.consent.status == Consent.Status.ACTIVE) { logger.debug("Accepted MTB File for processing") @@ -53,6 +57,11 @@ class MtbFileRestController( return ResponseEntity.accepted().build() } + @PostMapping( consumes = [ CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE] ) + fun mtbFile(@RequestBody mtbFile: Mtb): ResponseEntity { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build() + } + @DeleteMapping(path = ["{patientId}"]) fun deleteData(@PathVariable patientId: String): ResponseEntity { logger.debug("Accepted patient ID to process deletion") @@ -60,4 +69,4 @@ class MtbFileRestController( return ResponseEntity.accepted().build() } -} \ No newline at end of file +} diff --git a/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt index b54a02e..10900a8 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.Patient +import dev.dnpm.etl.processor.CustomMediaType import dev.dnpm.etl.processor.services.RequestProcessor import org.apache.kafka.clients.consumer.ConsumerRecord import org.apache.kafka.common.header.internals.RecordHeader @@ -63,7 +64,15 @@ class KafkaInputListenerTest { .withConsent(Consent.builder().withStatus(Consent.Status.ACTIVE).build()) .build() - kafkaInputListener.onMessage(ConsumerRecord("testtopic", 0, 0, "", this.objectMapper.writeValueAsString(mtbFile))) + kafkaInputListener.onMessage( + ConsumerRecord( + "testtopic", + 0, + 0, + "", + this.objectMapper.writeValueAsString(mtbFile) + ) + ) verify(requestProcessor, times(1)).processMtbFile(any()) } @@ -75,7 +84,15 @@ class KafkaInputListenerTest { .withConsent(Consent.builder().withStatus(Consent.Status.REJECTED).build()) .build() - kafkaInputListener.onMessage(ConsumerRecord("testtopic", 0, 0, "", this.objectMapper.writeValueAsString(mtbFile))) + kafkaInputListener.onMessage( + ConsumerRecord( + "testtopic", + 0, + 0, + "", + this.objectMapper.writeValueAsString(mtbFile) + ) + ) verify(requestProcessor, times(1)).processDeletion(anyValueClass()) } @@ -89,7 +106,19 @@ class KafkaInputListenerTest { val headers = RecordHeaders(listOf(RecordHeader("requestId", UUID.randomUUID().toString().toByteArray()))) kafkaInputListener.onMessage( - ConsumerRecord("testtopic", 0, 0, -1L, TimestampType.NO_TIMESTAMP_TYPE, -1, -1, "", this.objectMapper.writeValueAsString(mtbFile), headers, Optional.empty()) + ConsumerRecord( + "testtopic", + 0, + 0, + -1L, + TimestampType.NO_TIMESTAMP_TYPE, + -1, + -1, + "", + this.objectMapper.writeValueAsString(mtbFile), + headers, + Optional.empty() + ) ) verify(requestProcessor, times(1)).processMtbFile(any(), anyValueClass()) @@ -104,9 +133,52 @@ class KafkaInputListenerTest { val headers = RecordHeaders(listOf(RecordHeader("requestId", UUID.randomUUID().toString().toByteArray()))) kafkaInputListener.onMessage( - ConsumerRecord("testtopic", 0, 0, -1L, TimestampType.NO_TIMESTAMP_TYPE, -1, -1, "", this.objectMapper.writeValueAsString(mtbFile), headers, Optional.empty()) + ConsumerRecord( + "testtopic", + 0, + 0, + -1L, + TimestampType.NO_TIMESTAMP_TYPE, + -1, + -1, + "", + this.objectMapper.writeValueAsString(mtbFile), + headers, + Optional.empty() + ) ) verify(requestProcessor, times(1)).processDeletion(anyValueClass(), anyValueClass()) } -} \ No newline at end of file + @Test + fun shouldNotProcessDnpmV2Request() { + val mtbFile = MtbFile.builder() + .withPatient(Patient.builder().withId("DUMMY_12345678").build()) + .withConsent(Consent.builder().withStatus(Consent.Status.REJECTED).build()) + .build() + + val headers = RecordHeaders( + listOf( + RecordHeader("requestId", UUID.randomUUID().toString().toByteArray()), + RecordHeader("contentType", CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE.toByteArray()) + ) + ) + kafkaInputListener.onMessage( + ConsumerRecord( + "testtopic", + 0, + 0, + -1L, + TimestampType.NO_TIMESTAMP_TYPE, + -1, + -1, + "", + this.objectMapper.writeValueAsString(mtbFile), + headers, + Optional.empty() + ) + ) + verify(requestProcessor, times(0)).processDeletion(anyValueClass(), anyValueClass()) + } + +} diff --git a/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt index ade27b4..faaf778 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt @@ -21,6 +21,7 @@ package dev.dnpm.etl.processor.input import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* +import dev.dnpm.etl.processor.CustomMediaType import dev.dnpm.etl.processor.services.RequestProcessor import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested @@ -32,6 +33,7 @@ import org.mockito.Mockito.verify import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any import org.mockito.kotlin.anyValueClass +import org.springframework.core.io.ClassPathResource import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.delete @@ -155,6 +157,40 @@ class MtbFileRestControllerTest { } } + @Nested + inner class RequestsForDnpmDataModel21 { + + private lateinit var mockMvc: MockMvc + + private lateinit var requestProcessor: RequestProcessor + + @BeforeEach + fun setup( + @Mock requestProcessor: RequestProcessor + ) { + this.requestProcessor = requestProcessor + val controller = MtbFileRestController(requestProcessor) + this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build() + } + + @Test + fun shouldRespondPostRequest() { + val mtbFileContent = ClassPathResource("mv64e-mtb-fake-patient.json").inputStream.readAllBytes().toString(Charsets.UTF_8) + + mockMvc.post("/mtb") { + content = mtbFileContent + contentType = CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON + }.andExpect { + status { + isNotImplemented() + } + } + + verify(requestProcessor, times(0)).processMtbFile(any()) + } + + } + companion object { fun bwhcMtbFileContent(consentStatus: Consent.Status) = MtbFile.builder() .withPatient( diff --git a/src/test/resources/mv64e-mtb-fake-patient.json b/src/test/resources/mv64e-mtb-fake-patient.json new file mode 100644 index 0000000..c82d951 --- /dev/null +++ b/src/test/resources/mv64e-mtb-fake-patient.json @@ -0,0 +1,2243 @@ +{ + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "gender" : { + "code" : "female", + "display" : "Weiblich", + "system" : "Gender" + }, + "birthDate" : "1956-02-25", + "dateOfDeath" : "2007-02-25", + "healthInsurance" : { + "type" : { + "code" : "GKV", + "display" : "gesetzliche Krankenversicherung", + "system" : "http://fhir.de/CodeSystem/versicherungsart-de-basis" + }, + "reference" : { + "id" : "1234567890", + "system" : "https://www.dguv.de/arge-ik", + "display" : "AOK", + "type" : "HealthInsurance" + } + }, + "address" : { + "municipalityCode" : "12345" + }, + "age" : { + "value" : 51, + "unit" : "Years" + }, + "vitalStatus" : { + "code" : "deceased", + "display" : "Verstorben", + "system" : "dnpm-dip/patient/vital-status" + } + }, + "episodesOfCare" : [ { + "id" : "a95f44a6-5dbb-4acd-9d52-05db10f8410b", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "period" : { + "start" : "2024-10-03" + }, + "diagnoses" : [ { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "type" : "MTBDiagnosis" + } ] + } ], + "diagnoses" : [ { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "recordedOn" : "2004-01-25", + "type" : { + "history" : [ { + "value" : { + "code" : "main", + "display" : "Hauptdiagnose", + "system" : "dnpm-dip/mtb/diagnosis/type" + }, + "date" : "2004-01-25" + } ] + }, + "code" : { + "code" : "C69.0", + "display" : "Bösartige Neubildung: Konjunktiva", + "system" : "http://fhir.de/CodeSystem/bfarm/icd-10-gm", + "version" : "2025" + }, + "topography" : { + "code" : "C69.0", + "display" : "Konjunktiva", + "system" : "urn:oid:2.16.840.1.113883.6.43.1", + "version" : "Zweite Revision" + }, + "grading" : { + "history" : [ { + "date" : "2004-01-25", + "codes" : [ { + "code" : "U", + "display" : "U = unbekannt", + "system" : "https://www.basisdatensatz.de/feld/161/grading" + }, { + "code" : "4", + "display" : "Glioblastoma", + "system" : "dnpm-dip/mtb/who-grading-cns-tumors", + "version" : "2021" + } ] + } ] + }, + "staging" : { + "history" : [ { + "date" : "2004-01-25", + "method" : { + "code" : "clinical", + "display" : "Klinisch", + "system" : "dnpm-dip/mtb/tumor-staging/method" + }, + "tnmClassification" : { + "tumor" : { + "code" : "T1", + "system" : "UICC" + }, + "nodes" : { + "code" : "N2", + "system" : "UICC" + }, + "metastasis" : { + "code" : "Mx", + "system" : "UICC" + } + }, + "otherClassifications" : [ { + "code" : "metastasized", + "display" : "Metastasiert", + "system" : "dnpm-dip/mtb/diagnosis/kds-tumor-spread" + } ] + } ] + }, + "guidelineTreatmentStatus" : { + "code" : "non-exhausted", + "display" : "Leitlinien nicht ausgeschöpft", + "system" : "dnpm-dip/mtb/diagnosis/guideline-treatment-status" + }, + "notes" : [ "Notes on the tumor diagnosis..." ] + } ], + "guidelineTherapies" : [ { + "id" : "a3a6a53f-d531-4f46-8697-9052d98cc9e5", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "therapyLine" : 2, + "intent" : { + "code" : "S", + "display" : "Sonstiges", + "system" : "dnpm-dip/therapy/intent" + }, + "category" : { + "code" : "I", + "display" : "Intraopterativ", + "system" : "dnpm-dip/therapy/category" + }, + "recordedOn" : "2025-04-03", + "status" : { + "code" : "stopped", + "display" : "Abgebrochen", + "system" : "dnpm-dip/therapy/status" + }, + "statusReason" : { + "code" : "progression", + "display" : "Progression", + "system" : "dnpm-dip/therapy/status-reason" + }, + "period" : { + "start" : "2023-08-03", + "end" : "2024-01-18" + }, + "medication" : [ { + "code" : "L01EX24", + "display" : "Surufatinib", + "system" : "http://fhir.de/CodeSystem/bfarm/atc", + "version" : "2024" + } ], + "notes" : [ "Notes on the therapy..." ] + } ], + "guidelineProcedures" : [ { + "id" : "ff5148ce-94ab-487f-a2a4-ebc5e1ea8a53", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "therapyLine" : 1, + "intent" : { + "code" : "K", + "display" : "Kurativ", + "system" : "dnpm-dip/therapy/intent" + }, + "code" : { + "code" : "surgery", + "display" : "OP", + "system" : "dnpm-dip/mtb/procedure/type" + }, + "status" : { + "code" : "completed", + "display" : "Abgeschlossen", + "system" : "dnpm-dip/therapy/status" + }, + "statusReason" : { + "code" : "chronic-remission", + "display" : "Anhaltende Remission", + "system" : "dnpm-dip/therapy/status-reason" + }, + "recordedOn" : "2025-04-03", + "period" : { + "start" : "2024-10-03" + }, + "notes" : [ "Notes on the therapeutic procedure..." ] + }, { + "id" : "461105eb-c3c6-4fd4-bcd3-799e7eaf281d", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "therapyLine" : 8, + "intent" : { + "code" : "K", + "display" : "Kurativ", + "system" : "dnpm-dip/therapy/intent" + }, + "code" : { + "code" : "nuclear-medicine", + "display" : "Nuklearmedizinische Therapie", + "system" : "dnpm-dip/mtb/procedure/type" + }, + "status" : { + "code" : "stopped", + "display" : "Abgebrochen", + "system" : "dnpm-dip/therapy/status" + }, + "statusReason" : { + "code" : "progression", + "display" : "Progression", + "system" : "dnpm-dip/therapy/status-reason" + }, + "recordedOn" : "2025-04-03", + "period" : { + "start" : "2024-10-03" + }, + "notes" : [ "Notes on the therapeutic procedure..." ] + } ], + "performanceStatus" : [ { + "id" : "2b1522a8-9628-4e66-8769-e1f329bf37c5", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "effectiveDate" : "2025-04-03", + "value" : { + "code" : "3", + "display" : "ECOG 3", + "system" : "ECOG-Performance-Status" + } + } ], + "specimens" : [ { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "diagnosis" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "type" : "MTBDiagnosis" + }, + "type" : { + "code" : "FFPE", + "display" : "FFPE", + "system" : "dnpm-dip/mtb/tumor-specimen/type" + }, + "collection" : { + "date" : "2025-04-03", + "method" : { + "code" : "unknown", + "display" : "Unbekannt", + "system" : "dnpm-dip/mtb/tumor-specimen/collection/method" + }, + "localization" : { + "code" : "unknown", + "display" : "Unbekannt", + "system" : "dnpm-dip/mtb/tumor-specimen/collection/localization" + } + } + } ], + "priorDiagnosticReports" : [ { + "id" : "e3d6eb01-6afb-4cb2-8682-b5f67565a701", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "performer" : { + "id" : "xyz", + "display" : "Molekular-Pathologie UKx", + "type" : "Institute" + }, + "issuedOn" : "2025-04-03", + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "type" : { + "code" : "other", + "display" : "Other", + "system" : "dnpm-dip/mtb/molecular-diagnostics/type" + }, + "results" : [ "Result of diagnostics..." ] + } ], + "histologyReports" : [ { + "id" : "49154f97-84a9-4a8c-8f52-b5dcbf6973ce", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "issuedOn" : "2025-04-03", + "results" : { + "tumorMorphology" : { + "id" : "af23d218-7c03-4950-984c-a5c35295b696", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "value" : { + "code" : "8935/1", + "display" : "Stromatumor o.n.A.", + "system" : "urn:oid:2.16.840.1.113883.6.43.1", + "version" : "Zweite Revision" + }, + "notes" : "Notes..." + }, + "tumorCellContent" : { + "id" : "f45c7add-f441-4786-aff3-917bad76b140", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "method" : { + "code" : "histologic", + "display" : "Histologisch", + "system" : "dnpm-dip/mtb/tumor-cell-content/method" + }, + "value" : 0.8229387003304868 + } + } + } ], + "ihcReports" : [ { + "id" : "dfc2429b-4677-4c04-8359-2e8bd68e8006", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "issuedOn" : "2025-04-03", + "journalId" : "9dc66c04-ad2c-4d4c-9e38-b524e4e59c4a", + "blockIds" : [ "34c921a8-d047-414d-a1b6-b0bd24c6b771" ], + "results" : { + "proteinExpression" : [ { + "id" : "ca7b6082-f4ac-4be2-a28f-d1d73ad3eff3", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "protein" : { + "code" : "HGNC:391", + "display" : "AKT1", + "system" : "https://www.genenames.org/" + }, + "value" : { + "code" : "2+", + "display" : "2+", + "system" : "dnpm-dip/mtb/ihc/protein-expression/result" + }, + "tpsScore" : 64, + "icScore" : { + "code" : "3", + "display" : ">= 10%", + "system" : "dnpm-dip/mtb/ihc/protein-expression/ic-score" + }, + "tcScore" : { + "code" : "6", + "display" : ">= 75%", + "system" : "dnpm-dip/mtb/ihc/protein-expression/tc-score" + } + }, { + "id" : "824afa8e-332f-498f-9e98-5e03ba072857", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "protein" : { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, + "value" : { + "code" : "unknown", + "display" : "untersucht, kein Ergebnis", + "system" : "dnpm-dip/mtb/ihc/protein-expression/result" + }, + "tpsScore" : 67, + "icScore" : { + "code" : "2", + "display" : ">= 5%", + "system" : "dnpm-dip/mtb/ihc/protein-expression/ic-score" + }, + "tcScore" : { + "code" : "4", + "display" : ">= 25%", + "system" : "dnpm-dip/mtb/ihc/protein-expression/tc-score" + } + }, { + "id" : "697544ba-c91b-498c-825d-4768db65f064", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "protein" : { + "code" : "HGNC:21597", + "display" : "ACAD10", + "system" : "https://www.genenames.org/" + }, + "value" : { + "code" : "3+", + "display" : "3+", + "system" : "dnpm-dip/mtb/ihc/protein-expression/result" + }, + "tpsScore" : 99, + "icScore" : { + "code" : "3", + "display" : ">= 10%", + "system" : "dnpm-dip/mtb/ihc/protein-expression/ic-score" + }, + "tcScore" : { + "code" : "1", + "display" : ">= 1%", + "system" : "dnpm-dip/mtb/ihc/protein-expression/tc-score" + } + } ], + "msiMmr" : [ ] + } + } ], + "ngsReports" : [ { + "id" : "3a17112d-3dd2-468a-8eb5-d2acd2439b47", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "issuedOn" : "2025-04-03", + "type" : { + "code" : "genome-long-read", + "display" : "Genome long-read", + "system" : "dnpm-dip/ngs/type" + }, + "metadata" : [ { + "kitType" : "Kit Type", + "kitManufacturer" : "Manufacturer", + "sequencer" : "Sequencer", + "referenceGenome" : "HG19", + "pipeline" : "https://github.com/pipeline-project" + } ], + "results" : { + "tumorCellContent" : { + "id" : "1d0df7a7-b298-450d-99f6-be2eaee4c3f2", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "method" : { + "code" : "bioinformatic", + "display" : "Bioinformatisch", + "system" : "dnpm-dip/mtb/tumor-cell-content/method" + }, + "value" : 0.4814437947770913 + }, + "tmb" : { + "id" : "e2b42e18-1c99-4d7f-a049-ebf71e3fc2f6", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "value" : { + "value" : 282329, + "unit" : "Mutations per megabase" + }, + "interpretation" : { + "code" : "low", + "display" : "Niedrig", + "system" : "dnpm-dip/mtb/ngs/tmb/interpretation" + } + }, + "brcaness" : { + "id" : "9246f72b-790a-4c6b-aa8d-d00f0cda7a00", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "value" : 0.5, + "confidenceRange" : { + "min" : 0.4, + "max" : 0.6 + } + }, + "hrdScore" : { + "id" : "7a89c96e-f4c3-4f74-b7e7-69676a750ab6", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "value" : 0.7825761496253648, + "components" : { + "lst" : 0.845193455817853, + "loh" : 0.12405816770424238, + "tai" : 0.8345960469445086 + }, + "interpretation" : { + "code" : "high", + "display" : "Hoch", + "system" : "dnpm-dip/mtb/ngs/hrd-score/interpretation" + } + }, + "simpleVariants" : [ { + "id" : "a7a6d971-ccaf-489b-9d6c-3dce0fad63aa", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "aad4cdeb-d085-41d9-a3af-02e4e165ccc1", + "system" : "https://www.ncbi.nlm.nih.gov/snp" + }, { + "value" : "a302ca16-8c98-4697-85c1-6e0a2623ca12", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "chromosome" : "chr22", + "gene" : { + "code" : "HGNC:21597", + "display" : "ACAD10", + "system" : "https://www.genenames.org/" + }, + "localization" : [ { + "code" : "coding-region", + "display" : "Coding region", + "system" : "dnpm-dip/variant/localization" + } ], + "transcriptId" : { + "value" : "468fbe60-ff1f-4151-a8d4-4bd2bd53e9ad", + "system" : "https://www.ensembl.org" + }, + "exonId" : "10", + "position" : { + "start" : 442 + }, + "altAllele" : "G", + "refAllele" : "A", + "dnaChange" : { + "code" : "c.442A>G", + "system" : "https://hgvs-nomenclature.org" + }, + "proteinChange" : { + "code" : "p.Val7del", + "system" : "https://hgvs-nomenclature.org" + }, + "readDepth" : 7, + "allelicFrequency" : 0.05075371444497867, + "interpretation" : { + "code" : "3", + "display" : "Uncertain significance", + "system" : "https://www.ncbi.nlm.nih.gov/clinvar" + } + }, { + "id" : "cfe756be-a9d1-4726-ad1f-16d18c40e1e4", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "3549065d-1a19-4f52-8b5a-7acdc0052981", + "system" : "https://www.ncbi.nlm.nih.gov/snp" + }, { + "value" : "915a64b9-dfd5-4d8f-ba53-5760c452b153", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "chromosome" : "chr19", + "gene" : { + "code" : "HGNC:34", + "display" : "ABCA4", + "system" : "https://www.genenames.org/" + }, + "localization" : [ { + "code" : "regulatory-region", + "display" : "Regulatory region", + "system" : "dnpm-dip/variant/localization" + } ], + "transcriptId" : { + "value" : "30d82280-ce5d-4477-97f8-8dfb33491662", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "5", + "position" : { + "start" : 124 + }, + "altAllele" : "G", + "refAllele" : "A", + "dnaChange" : { + "code" : "c.124A>G", + "system" : "https://hgvs-nomenclature.org" + }, + "proteinChange" : { + "code" : "p.Gly2_Met46del", + "system" : "https://hgvs-nomenclature.org" + }, + "readDepth" : 20, + "allelicFrequency" : 0.623433864043018, + "interpretation" : { + "code" : "1", + "display" : "Benign", + "system" : "https://www.ncbi.nlm.nih.gov/clinvar" + } + }, { + "id" : "d6088d5a-3059-40a3-ae44-22ff4a63fe20", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "59c813ad-f057-4d3e-92f0-f64d198e7a9e", + "system" : "https://www.ncbi.nlm.nih.gov/snp" + }, { + "value" : "f47ba852-77d2-4495-af83-ebbbade46041", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "chromosome" : "chr6", + "gene" : { + "code" : "HGNC:1100", + "display" : "BRCA1", + "system" : "https://www.genenames.org/" + }, + "localization" : [ { + "code" : "splicing-region", + "display" : "splicing region", + "system" : "dnpm-dip/variant/localization" + } ], + "transcriptId" : { + "value" : "7dc56f62-01fe-48de-8425-f2407a5f6797", + "system" : "https://www.ensembl.org" + }, + "exonId" : "9", + "position" : { + "start" : 586 + }, + "altAllele" : "G", + "refAllele" : "C", + "dnaChange" : { + "code" : "c.586C>G", + "system" : "https://hgvs-nomenclature.org" + }, + "proteinChange" : { + "code" : "p.Cys28_Lys29delinsTrp", + "system" : "https://hgvs-nomenclature.org" + }, + "readDepth" : 11, + "allelicFrequency" : 0.7808371811689188, + "interpretation" : { + "code" : "1", + "display" : "Benign", + "system" : "https://www.ncbi.nlm.nih.gov/clinvar" + } + }, { + "id" : "01a40602-1992-44ee-86cf-af4b9f8ede17", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "0c12c379-ec6b-48d2-ab7d-f3ef1e832782", + "system" : "https://www.ncbi.nlm.nih.gov/snp" + }, { + "value" : "e0d20f40-37c8-4203-825c-f8c1c7aabbc9", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "chromosome" : "chr11", + "gene" : { + "code" : "HGNC:25829", + "display" : "ABRAXAS1", + "system" : "https://www.genenames.org/" + }, + "localization" : [ { + "code" : "coding-region", + "display" : "Coding region", + "system" : "dnpm-dip/variant/localization" + } ], + "transcriptId" : { + "value" : "45566daf-2799-45bf-836b-227ad57f1e13", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "11", + "position" : { + "start" : 340 + }, + "altAllele" : "A", + "refAllele" : "G", + "dnaChange" : { + "code" : "c.340G>A", + "system" : "https://hgvs-nomenclature.org" + }, + "proteinChange" : { + "code" : "p.Trp24Cys", + "system" : "https://hgvs-nomenclature.org" + }, + "readDepth" : 23, + "allelicFrequency" : 0.350726510140109, + "interpretation" : { + "code" : "2", + "display" : "Likely benign", + "system" : "https://www.ncbi.nlm.nih.gov/clinvar" + } + }, { + "id" : "b548d991-4270-4b60-96e9-d4d1897e2f3f", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "87a18418-1e1e-4546-9316-c14907c53122", + "system" : "https://www.ncbi.nlm.nih.gov/snp" + }, { + "value" : "7ab23f44-806e-480e-ba8a-2974789b7acc", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "chromosome" : "chr9", + "gene" : { + "code" : "HGNC:1777", + "display" : "CDK6", + "system" : "https://www.genenames.org/" + }, + "localization" : [ { + "code" : "splicing-region", + "display" : "splicing region", + "system" : "dnpm-dip/variant/localization" + } ], + "transcriptId" : { + "value" : "444c908a-a87b-401f-9446-f152b70abff6", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "3", + "position" : { + "start" : 82 + }, + "altAllele" : "C", + "refAllele" : "T", + "dnaChange" : { + "code" : "c.82T>C", + "system" : "https://hgvs-nomenclature.org" + }, + "proteinChange" : { + "code" : "p.Cys28delinsTrpVal", + "system" : "https://hgvs-nomenclature.org" + }, + "readDepth" : 9, + "allelicFrequency" : 0.7308207727279626, + "interpretation" : { + "code" : "1", + "display" : "Benign", + "system" : "https://www.ncbi.nlm.nih.gov/clinvar" + } + }, { + "id" : "b9b37461-cad8-4aa9-ab9a-765cc390ae93", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "c9343e8a-a961-478d-abf7-af8eca753195", + "system" : "https://www.ncbi.nlm.nih.gov/snp" + }, { + "value" : "fe9cf415-da2c-48ee-8239-1c0fff308477", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "chromosome" : "chr22", + "gene" : { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, + "localization" : [ { + "code" : "coding-region", + "display" : "Coding region", + "system" : "dnpm-dip/variant/localization" + } ], + "transcriptId" : { + "value" : "e089a986-861c-4987-a2d4-4127cd94cfcc", + "system" : "https://www.ensembl.org" + }, + "exonId" : "5", + "position" : { + "start" : 350 + }, + "altAllele" : "A", + "refAllele" : "G", + "dnaChange" : { + "code" : "c.350G>A", + "system" : "https://hgvs-nomenclature.org" + }, + "proteinChange" : { + "code" : "p.Trp24Cys", + "system" : "https://hgvs-nomenclature.org" + }, + "readDepth" : 20, + "allelicFrequency" : 0.6561532201278295, + "interpretation" : { + "code" : "2", + "display" : "Likely benign", + "system" : "https://www.ncbi.nlm.nih.gov/clinvar" + } + } ], + "copyNumberVariants" : [ { + "id" : "674dcb35-ae1f-4c9a-bf96-d718631b0b76", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "chromosome" : "chr13", + "localization" : [ { + "code" : "splicing-region", + "display" : "splicing region", + "system" : "dnpm-dip/variant/localization" + } ], + "startRange" : { + "start" : 7504, + "end" : 7546 + }, + "endRange" : { + "start" : 8028, + "end" : 8078 + }, + "totalCopyNumber" : 7, + "relativeCopyNumber" : 0.11223346698282377, + "cnA" : 0.4978945009603952, + "cnB" : 0.4387588889519498, + "reportedAffectedGenes" : [ { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:1097", + "display" : "BRAF", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:9967", + "display" : "RET", + "system" : "https://www.genenames.org/" + } ], + "reportedFocality" : "partial q-arm", + "type" : { + "code" : "high-level-gain", + "display" : "High-level-gain", + "system" : "dnpm-dip/mtb/ngs-report/cnv/type" + }, + "copyNumberNeutralLoH" : [ { + "code" : "HGNC:25662", + "display" : "AAGAB", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:11998", + "display" : "TP53", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:1753", + "display" : "CDH13", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + } ] + }, { + "id" : "0ccc8b6a-7692-405e-afb3-9ba79f6a6dc6", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "chromosome" : "chr4", + "localization" : [ { + "code" : "splicing-region", + "display" : "splicing region", + "system" : "dnpm-dip/variant/localization" + } ], + "startRange" : { + "start" : 29821, + "end" : 29863 + }, + "endRange" : { + "start" : 30310, + "end" : 30360 + }, + "totalCopyNumber" : 2, + "relativeCopyNumber" : 0.004237951938893092, + "cnA" : 0.4120221366346364, + "cnB" : 0.021984357963086842, + "reportedAffectedGenes" : [ { + "code" : "HGNC:11998", + "display" : "TP53", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:25829", + "display" : "ABRAXAS1", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:1753", + "display" : "CDH13", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:886", + "display" : "ATRX", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3942", + "display" : "MTOR", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:9967", + "display" : "RET", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3689", + "display" : "FGFR2", + "system" : "https://www.genenames.org/" + } ], + "reportedFocality" : "partial q-arm", + "type" : { + "code" : "low-level-gain", + "display" : "Low-level-gain", + "system" : "dnpm-dip/mtb/ngs-report/cnv/type" + }, + "copyNumberNeutralLoH" : [ { + "code" : "HGNC:1097", + "display" : "BRAF", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3690", + "display" : "FGFR3", + "system" : "https://www.genenames.org/" + } ] + }, { + "id" : "5eec91bc-94e1-462e-8686-df33216192eb", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "chromosome" : "chrX", + "localization" : [ { + "code" : "splicing-region", + "display" : "splicing region", + "system" : "dnpm-dip/variant/localization" + } ], + "startRange" : { + "start" : 18371, + "end" : 18413 + }, + "endRange" : { + "start" : 19283, + "end" : 19333 + }, + "totalCopyNumber" : 3, + "relativeCopyNumber" : 0.795318484180268, + "cnA" : 0.86546686869607, + "cnB" : 0.7216652781170053, + "reportedAffectedGenes" : [ { + "code" : "HGNC:33", + "display" : "ABCA3", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:6973", + "display" : "MDM2", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:1100", + "display" : "BRCA1", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:76", + "display" : "ABL1", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:21298", + "display" : "AACS", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:6407", + "display" : "KRAS", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:34", + "display" : "ABCA4", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3236", + "display" : "EGFR", + "system" : "https://www.genenames.org/" + } ], + "reportedFocality" : "partial q-arm", + "type" : { + "code" : "low-level-gain", + "display" : "Low-level-gain", + "system" : "dnpm-dip/mtb/ngs-report/cnv/type" + }, + "copyNumberNeutralLoH" : [ { + "code" : "HGNC:33", + "display" : "ABCA3", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:25829", + "display" : "ABRAXAS1", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3690", + "display" : "FGFR3", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:25662", + "display" : "AAGAB", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:76", + "display" : "ABL1", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:886", + "display" : "ATRX", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:18615", + "display" : "BRAFP1", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:34", + "display" : "ABCA4", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3236", + "display" : "EGFR", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:21597", + "display" : "ACAD10", + "system" : "https://www.genenames.org/" + } ] + }, { + "id" : "7a162258-d213-4243-be7e-59244f4561e9", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "chromosome" : "chr9", + "localization" : [ { + "code" : "intronic", + "display" : "Intronic", + "system" : "dnpm-dip/variant/localization" + } ], + "startRange" : { + "start" : 23025, + "end" : 23067 + }, + "endRange" : { + "start" : 23220, + "end" : 23270 + }, + "totalCopyNumber" : 1, + "relativeCopyNumber" : 0.3220959397254798, + "cnA" : 0.11998983501009763, + "cnB" : 0.08203835493839595, + "reportedAffectedGenes" : [ { + "code" : "HGNC:33", + "display" : "ABCA3", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3690", + "display" : "FGFR3", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:1097", + "display" : "BRAF", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:25662", + "display" : "AAGAB", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:1100", + "display" : "BRCA1", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:34", + "display" : "ABCA4", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3236", + "display" : "EGFR", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:21597", + "display" : "ACAD10", + "system" : "https://www.genenames.org/" + } ], + "reportedFocality" : "partial q-arm", + "type" : { + "code" : "loss", + "display" : "Loss", + "system" : "dnpm-dip/mtb/ngs-report/cnv/type" + }, + "copyNumberNeutralLoH" : [ { + "code" : "HGNC:25662", + "display" : "AAGAB", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:886", + "display" : "ATRX", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3689", + "display" : "FGFR2", + "system" : "https://www.genenames.org/" + } ] + } ], + "dnaFusions" : [ { + "id" : "bfbb4eb3-fecf-4be6-a0b6-39ed3ab9f54c", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "localization" : [ { + "code" : "regulatory-region", + "display" : "Regulatory region", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "chromosome" : "chr9", + "gene" : { + "code" : "HGNC:21597", + "display" : "ACAD10", + "system" : "https://www.genenames.org/" + }, + "position" : 788 + }, + "fusionPartner3prime" : { + "chromosome" : "chr19", + "gene" : { + "code" : "HGNC:1753", + "display" : "CDH13", + "system" : "https://www.genenames.org/" + }, + "position" : 384 + }, + "reportedNumReads" : 7 + }, { + "id" : "e99862ce-c098-4aa1-932c-5bfb7de2bb54", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "localization" : [ { + "code" : "splicing-region", + "display" : "splicing region", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "chromosome" : "chr10", + "gene" : { + "code" : "HGNC:1100", + "display" : "BRCA1", + "system" : "https://www.genenames.org/" + }, + "position" : 426 + }, + "fusionPartner3prime" : { + "chromosome" : "chrY", + "gene" : { + "code" : "HGNC:76", + "display" : "ABL1", + "system" : "https://www.genenames.org/" + }, + "position" : 587 + }, + "reportedNumReads" : 8 + }, { + "id" : "362f5786-4521-409a-b63f-69aad335abcb", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "localization" : [ { + "code" : "intronic", + "display" : "Intronic", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "chromosome" : "chrX", + "gene" : { + "code" : "HGNC:1777", + "display" : "CDK6", + "system" : "https://www.genenames.org/" + }, + "position" : 421 + }, + "fusionPartner3prime" : { + "chromosome" : "chr15", + "gene" : { + "code" : "HGNC:3942", + "display" : "MTOR", + "system" : "https://www.genenames.org/" + }, + "position" : 618 + }, + "reportedNumReads" : 3 + }, { + "id" : "d9cc0ae1-1f22-4545-bcfc-3fc93faf1c7b", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "localization" : [ { + "code" : "regulatory-region", + "display" : "Regulatory region", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "chromosome" : "chr16", + "gene" : { + "code" : "HGNC:6407", + "display" : "KRAS", + "system" : "https://www.genenames.org/" + }, + "position" : 727 + }, + "fusionPartner3prime" : { + "chromosome" : "chr22", + "gene" : { + "code" : "HGNC:6973", + "display" : "MDM2", + "system" : "https://www.genenames.org/" + }, + "position" : 955 + }, + "reportedNumReads" : 6 + }, { + "id" : "7acb8b5a-28e5-49e8-9af0-8f0b07dd7928", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "localization" : [ { + "code" : "intergenic", + "display" : "Intergenic", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "chromosome" : "chr8", + "gene" : { + "code" : "HGNC:6407", + "display" : "KRAS", + "system" : "https://www.genenames.org/" + }, + "position" : 910 + }, + "fusionPartner3prime" : { + "chromosome" : "chr6", + "gene" : { + "code" : "HGNC:33", + "display" : "ABCA3", + "system" : "https://www.genenames.org/" + }, + "position" : 567 + }, + "reportedNumReads" : 7 + } ], + "rnaFusions" : [ { + "id" : "809f015f-8e17-45ae-82fe-1d2642a379c0", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "a961e828-b8eb-46a7-b92b-077b188d22eb", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "regulatory-region", + "display" : "Regulatory region", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "7f69ae21-f0ae-4f10-965b-116b5a3a4306", + "system" : "https://www.ensembl.org" + }, + "exonId" : "3", + "gene" : { + "code" : "HGNC:76", + "display" : "ABL1", + "system" : "https://www.genenames.org/" + }, + "position" : 939, + "strand" : "-" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "3e4978f1-a4e6-4822-bca2-6d65bfa903d0", + "system" : "https://www.ensembl.org" + }, + "exonId" : "5", + "gene" : { + "code" : "HGNC:1097", + "display" : "BRAF", + "system" : "https://www.genenames.org/" + }, + "position" : 898, + "strand" : "+" + }, + "effect" : "Effect", + "reportedNumReads" : 9 + }, { + "id" : "e2686fdf-5aee-4a1c-a9f9-b5117d586a56", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "3530f838-6a51-4f34-84ff-a978182da6a6", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "intronic", + "display" : "Intronic", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "ccf31cee-09ab-4bfc-a92b-f48f5523989e", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "9", + "gene" : { + "code" : "HGNC:21597", + "display" : "ACAD10", + "system" : "https://www.genenames.org/" + }, + "position" : 272, + "strand" : "-" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "71d1e8fc-9296-4d46-9d1f-a36e1f382d35", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "7", + "gene" : { + "code" : "HGNC:34", + "display" : "ABCA4", + "system" : "https://www.genenames.org/" + }, + "position" : 848, + "strand" : "+" + }, + "effect" : "Effect", + "reportedNumReads" : 8 + }, { + "id" : "139c8db9-edde-4bd0-ac25-4b7b1729f5cc", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "a1324b2a-96de-46a1-86a7-a575fb29b41a", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "regulatory-region", + "display" : "Regulatory region", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "20da1339-f72c-4481-a844-b690a0b950e5", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "6", + "gene" : { + "code" : "HGNC:3689", + "display" : "FGFR2", + "system" : "https://www.genenames.org/" + }, + "position" : 996, + "strand" : "+" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "96d13df2-6551-41f7-94b5-c7da14dd5ce0", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "6", + "gene" : { + "code" : "HGNC:18615", + "display" : "BRAFP1", + "system" : "https://www.genenames.org/" + }, + "position" : 814, + "strand" : "-" + }, + "effect" : "Effect", + "reportedNumReads" : 7 + }, { + "id" : "bf815c71-c890-40a3-84fb-714f31814c59", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "a6e3dd2f-1d7c-404d-953c-710873f846dd", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "splicing-region", + "display" : "splicing region", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "d7d2344f-6651-4527-aa93-cb5b93f05aee", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "6", + "gene" : { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, + "position" : 292, + "strand" : "-" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "4092f456-1d4a-43e0-84a9-f70c3c14cf6b", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "5", + "gene" : { + "code" : "HGNC:6973", + "display" : "MDM2", + "system" : "https://www.genenames.org/" + }, + "position" : 925, + "strand" : "+" + }, + "effect" : "Effect", + "reportedNumReads" : 7 + }, { + "id" : "5436e5f8-db2d-4947-a88a-1ab6b07e5faa", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "b83aa40a-fd2f-49c3-a34f-1411cc32783f", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "intergenic", + "display" : "Intergenic", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "f9481fda-4a3f-442d-ad3f-e6adccfc4e73", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "5", + "gene" : { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, + "position" : 951, + "strand" : "+" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "18488fee-209c-4abe-9896-f49007bcc648", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "9", + "gene" : { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, + "position" : 944, + "strand" : "-" + }, + "effect" : "Effect", + "reportedNumReads" : 6 + }, { + "id" : "a206e483-f18c-4656-924b-0f79969da5ab", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "adae872d-f6a7-4c3f-a7be-c4aad3f57694", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "regulatory-region", + "display" : "Regulatory region", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "1a5c5afe-c41d-4300-b483-9a55a2ca0ac7", + "system" : "https://www.ensembl.org" + }, + "exonId" : "10", + "gene" : { + "code" : "HGNC:3942", + "display" : "MTOR", + "system" : "https://www.genenames.org/" + }, + "position" : 778, + "strand" : "+" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "16ca230d-78b7-4dfe-9025-616b2d1e0e0e", + "system" : "https://www.ensembl.org" + }, + "exonId" : "10", + "gene" : { + "code" : "HGNC:34", + "display" : "ABCA4", + "system" : "https://www.genenames.org/" + }, + "position" : 216, + "strand" : "+" + }, + "effect" : "Effect", + "reportedNumReads" : 7 + }, { + "id" : "3345abf6-6afa-4069-8b45-390c5ceda24c", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "1b970bcf-766a-472e-a210-4b245c6b697d", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "intergenic", + "display" : "Intergenic", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "030c35d3-eafb-4980-9d26-a6d124e5b411", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "8", + "gene" : { + "code" : "HGNC:33", + "display" : "ABCA3", + "system" : "https://www.genenames.org/" + }, + "position" : 496, + "strand" : "+" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "4e673024-533a-4931-9ac0-f069d139d0ad", + "system" : "https://www.ensembl.org" + }, + "exonId" : "11", + "gene" : { + "code" : "HGNC:3689", + "display" : "FGFR2", + "system" : "https://www.genenames.org/" + }, + "position" : 525, + "strand" : "+" + }, + "effect" : "Effect", + "reportedNumReads" : 6 + }, { + "id" : "74254963-0987-4123-ba2c-c57e9de9afd7", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "28050793-0c1d-4d3b-abac-3d569d26a154", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "splicing-region", + "display" : "splicing region", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "b6536e66-bc89-4288-812f-436aa4d5a9a2", + "system" : "https://www.ensembl.org" + }, + "exonId" : "10", + "gene" : { + "code" : "HGNC:1100", + "display" : "BRCA1", + "system" : "https://www.genenames.org/" + }, + "position" : 51, + "strand" : "-" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "1289121c-c6fa-4179-b7fe-5c1f6326c2fa", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "3", + "gene" : { + "code" : "HGNC:21298", + "display" : "AACS", + "system" : "https://www.genenames.org/" + }, + "position" : 905, + "strand" : "-" + }, + "effect" : "Effect", + "reportedNumReads" : 5 + }, { + "id" : "476bf705-ea3e-4a4e-8e9b-29a8fd79446c", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "ed80a31e-8f15-4e4f-8c78-654bc16e56a2", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "intronic", + "display" : "Intronic", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "6ec1314f-0301-4448-adad-da588a340928", + "system" : "https://www.ensembl.org" + }, + "exonId" : "3", + "gene" : { + "code" : "HGNC:21298", + "display" : "AACS", + "system" : "https://www.genenames.org/" + }, + "position" : 335, + "strand" : "+" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "869e0853-097c-4c03-81f2-5d2b1f702e83", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "6", + "gene" : { + "code" : "HGNC:1753", + "display" : "CDH13", + "system" : "https://www.genenames.org/" + }, + "position" : 927, + "strand" : "+" + }, + "effect" : "Effect", + "reportedNumReads" : 5 + } ], + "rnaSeqs" : [ ] + } + } ], + "carePlans" : [ { + "id" : "ddecb45f-d328-4a31-9f0b-504dd74a09bb", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "issuedOn" : "2025-04-03", + "statusReason" : { + "code" : "targeted-diagnostics-recommended", + "display" : "Zieldiagnostik empfohlen", + "system" : "dnpm-dip/mtb/careplan/status-reason" + }, + "geneticCounselingRecommendation" : { + "id" : "caee4091-8b35-4be8-954e-e6d7f4d6f8b2", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "issuedOn" : "2025-04-03", + "reason" : { + "code" : "unknown", + "display" : "Unbekannt", + "system" : "dnpm-dip/mtb/recommendation/genetic-counseling/reason" + } + }, + "medicationRecommendations" : [ { + "id" : "083a04e9-05f3-4b37-9e7d-e0bf703a8c3c", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "issuedOn" : "2025-04-03", + "priority" : { + "code" : "2", + "display" : "2", + "system" : "dnpm-dip/recommendation/priority" + }, + "levelOfEvidence" : { + "grading" : { + "code" : "m1B", + "display" : "m1B", + "system" : "dnpm-dip/mtb/level-of-evidence/grading" + }, + "addendums" : [ { + "code" : "is", + "display" : "is", + "system" : "dnpm-dip/mtb/level-of-evidence/addendum" + } ], + "publications" : [ { + "id" : "884742948", + "system" : "https://pubmed.ncbi.nlm.nih.gov", + "type" : "Publication" + } ] + }, + "category" : { + "code" : "IM", + "display" : "Immun-/Antikörpertherapie", + "system" : "dnpm-dip/mtb/recommendation/systemic-therapy/category" + }, + "medication" : [ { + "code" : "L01EN01", + "display" : "Erdafitinib", + "system" : "http://fhir.de/CodeSystem/bfarm/atc", + "version" : "2024" + } ], + "useType" : { + "code" : "in-label", + "display" : "In-label Use", + "system" : "dnpm-dip/mtb/recommendation/systemic-therapy/use-type" + }, + "supportingVariants" : [ { + "variant" : { + "id" : "7acb8b5a-28e5-49e8-9af0-8f0b07dd7928", + "type" : "Variant" + }, + "gene" : { + "code" : "HGNC:6407", + "display" : "KRAS", + "system" : "https://www.genenames.org/" + } + } ] + }, { + "id" : "5650387b-2f3b-4f77-863c-9af4d02294fb", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "issuedOn" : "2025-04-03", + "priority" : { + "code" : "1", + "display" : "1", + "system" : "dnpm-dip/recommendation/priority" + }, + "levelOfEvidence" : { + "grading" : { + "code" : "m2C", + "display" : "m2C", + "system" : "dnpm-dip/mtb/level-of-evidence/grading" + }, + "addendums" : [ { + "code" : "Z", + "display" : "Z", + "system" : "dnpm-dip/mtb/level-of-evidence/addendum" + } ], + "publications" : [ { + "id" : "1566646481", + "system" : "https://pubmed.ncbi.nlm.nih.gov", + "type" : "Publication" + } ] + }, + "category" : { + "code" : "HO", + "display" : "Hormontherapie", + "system" : "dnpm-dip/mtb/recommendation/systemic-therapy/category" + }, + "medication" : [ { + "code" : "L01FX33", + "display" : "Tarlatamab", + "system" : "http://fhir.de/CodeSystem/bfarm/atc", + "version" : "2024" + } ], + "useType" : { + "code" : "off-label", + "display" : "Off-bel Use", + "system" : "dnpm-dip/mtb/recommendation/systemic-therapy/use-type" + }, + "supportingVariants" : [ { + "variant" : { + "id" : "0ccc8b6a-7692-405e-afb3-9ba79f6a6dc6", + "type" : "Variant" + }, + "gene" : { + "code" : "HGNC:11998", + "display" : "TP53", + "system" : "https://www.genenames.org/" + } + } ] + } ], + "procedureRecommendations" : [ { + "id" : "1e76d94c-a57a-4b3f-9d6b-4af303c05199", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "issuedOn" : "2025-04-03", + "priority" : { + "code" : "3", + "display" : "3", + "system" : "dnpm-dip/recommendation/priority" + }, + "levelOfEvidence" : { + "grading" : { + "code" : "m2C", + "display" : "m2C", + "system" : "dnpm-dip/mtb/level-of-evidence/grading" + }, + "addendums" : [ { + "code" : "iv", + "display" : "iv", + "system" : "dnpm-dip/mtb/level-of-evidence/addendum" + } ], + "publications" : [ { + "id" : "9936302", + "system" : "https://pubmed.ncbi.nlm.nih.gov", + "type" : "Publication" + } ] + }, + "code" : { + "code" : "SO", + "display" : "Sonstiges", + "system" : "dnpm-dip/mtb/recommendation/procedure/category" + }, + "supportingVariants" : [ { + "variant" : { + "id" : "01a40602-1992-44ee-86cf-af4b9f8ede17", + "type" : "Variant" + }, + "gene" : { + "code" : "HGNC:25829", + "display" : "ABRAXAS1", + "system" : "https://www.genenames.org/" + } + } ] + } ], + "studyEnrollmentRecommendations" : [ { + "id" : "c617652e-d118-4033-9678-ea8eade11abb", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "issuedOn" : "2025-04-03", + "levelOfEvidence" : { + "code" : "m1B", + "display" : "m1B", + "system" : "dnpm-dip/mtb/level-of-evidence/grading" + }, + "priority" : { + "code" : "1", + "display" : "1", + "system" : "dnpm-dip/recommendation/priority" + }, + "study" : [ { + "id" : "fe066270-e69e-4fc7-95b1-59a3e8456a11", + "system" : "EUDAMED", + "type" : "Study" + } ], + "supportingVariants" : [ { + "variant" : { + "id" : "7acb8b5a-28e5-49e8-9af0-8f0b07dd7928", + "type" : "Variant" + }, + "gene" : { + "code" : "HGNC:6407", + "display" : "KRAS", + "system" : "https://www.genenames.org/" + } + } ] + } ], + "histologyReevaluationRequests" : [ { + "id" : "d9565325-726d-4fc8-bfa9-0fa4c0b53d7b", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "issuedOn" : "2025-04-03" + } ], + "rebiopsyRequests" : [ { + "id" : "858ce32e-3dea-4cdf-b85a-ae9fa84d113b", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "tumorEntity" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "type" : "MTBDiagnosis" + }, + "issuedOn" : "2025-04-03" + } ], + "notes" : [ "Protocol of the MTB conference..." ] + } ], + "followUps" : [ { + "date" : "2006-12-10" + } ], + "claims" : [ { + "id" : "d7afc9e8-5342-443d-9e13-0a88b4dd6037", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "recommendation" : { + "id" : "083a04e9-05f3-4b37-9e7d-e0bf703a8c3c", + "type" : "MTBMedicationRecommendation" + }, + "issuedOn" : "2025-04-03", + "stage" : { + "code" : "initial-claim", + "display" : "Erstantrag", + "system" : "dnpm-dip/mtb/claim/stage" + } + }, { + "id" : "2ef6b3c7-ce7e-4415-8a43-bd7a3a67be95", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "recommendation" : { + "id" : "5650387b-2f3b-4f77-863c-9af4d02294fb", + "type" : "MTBMedicationRecommendation" + }, + "issuedOn" : "2025-04-03", + "stage" : { + "code" : "revocation", + "display" : "Widerspruch", + "system" : "dnpm-dip/mtb/claim/stage" + } + } ], + "claimResponses" : [ { + "id" : "d9085718-77f6-4ace-b6da-c9358c853ff0", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "claim" : { + "id" : "d7afc9e8-5342-443d-9e13-0a88b4dd6037", + "type" : "Claim" + }, + "issuedOn" : "2025-04-03", + "status" : { + "code" : "rejected", + "display" : "Abgelehnt", + "system" : "dnpm-dip/mtb/claim-response/status" + }, + "statusReason" : { + "code" : "unknown", + "display" : "Unbekant", + "system" : "dnpm-dip/mtb/claim-response/status-reason" + } + }, { + "id" : "d93a0943-628d-47c6-9eda-4fba38df42be", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "claim" : { + "id" : "2ef6b3c7-ce7e-4415-8a43-bd7a3a67be95", + "type" : "Claim" + }, + "issuedOn" : "2025-04-03", + "status" : { + "code" : "rejected", + "display" : "Abgelehnt", + "system" : "dnpm-dip/mtb/claim-response/status" + }, + "statusReason" : { + "code" : "approval-revocation", + "display" : "Rücknahme der Zulassung", + "system" : "dnpm-dip/mtb/claim-response/status-reason" + } + } ], + "systemicTherapies" : [ { + "history" : [ { + "id" : "6b150d30-1c82-4663-833f-6a12ac582ca4", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "intent" : { + "code" : "S", + "display" : "Sonstiges", + "system" : "dnpm-dip/therapy/intent" + }, + "category" : { + "code" : "I", + "display" : "Intraopterativ", + "system" : "dnpm-dip/therapy/category" + }, + "basedOn" : { + "id" : "083a04e9-05f3-4b37-9e7d-e0bf703a8c3c", + "type" : "MTBMedicationRecommendation" + }, + "recordedOn" : "2025-04-03", + "status" : { + "code" : "stopped", + "display" : "Abgebrochen", + "system" : "dnpm-dip/therapy/status" + }, + "statusReason" : { + "code" : "progression", + "display" : "Progression", + "system" : "dnpm-dip/therapy/status-reason" + }, + "recommendationFulfillmentStatus" : { + "code" : "complete", + "display" : "Komplett", + "system" : "dnpm-dip/therapy/recommendation-fulfillment-status" + }, + "period" : { + "start" : "2006-10-15", + "end" : "2007-02-25" + }, + "medication" : [ { + "code" : "L01EN01", + "display" : "Erdafitinib", + "system" : "http://fhir.de/CodeSystem/bfarm/atc", + "version" : "2024" + } ], + "notes" : [ "Notes on the therapy..." ] + } ] + }, { + "history" : [ { + "id" : "27b6eddc-1a4f-4d68-a027-190a2c38754b", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "intent" : { + "code" : "X", + "display" : "Keine Angabe", + "system" : "dnpm-dip/therapy/intent" + }, + "category" : { + "code" : "A", + "display" : "Adjuvant", + "system" : "dnpm-dip/therapy/category" + }, + "basedOn" : { + "id" : "5650387b-2f3b-4f77-863c-9af4d02294fb", + "type" : "MTBMedicationRecommendation" + }, + "recordedOn" : "2025-04-03", + "status" : { + "code" : "stopped", + "display" : "Abgebrochen", + "system" : "dnpm-dip/therapy/status" + }, + "statusReason" : { + "code" : "progression", + "display" : "Progression", + "system" : "dnpm-dip/therapy/status-reason" + }, + "recommendationFulfillmentStatus" : { + "code" : "partial", + "display" : "Partiell", + "system" : "dnpm-dip/therapy/recommendation-fulfillment-status" + }, + "period" : { + "start" : "2006-10-29", + "end" : "2007-02-25" + }, + "medication" : [ { + "code" : "L01FX33", + "display" : "Tarlatamab", + "system" : "http://fhir.de/CodeSystem/bfarm/atc", + "version" : "2024" + } ], + "notes" : [ "Notes on the therapy..." ] + } ] + } ], + "responses" : [ { + "id" : "bbbbbaaf-8486-454c-ba59-28593519342c", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "therapy" : { + "id" : "6b150d30-1c82-4663-833f-6a12ac582ca4", + "type" : "MTBSystemicTherapy" + }, + "effectiveDate" : "2006-12-10", + "method" : { + "code" : "RECIST", + "display" : "Nach RECIST-Kriterien", + "system" : "dnpm-dip/mtb/response/method" + }, + "value" : { + "code" : "SD", + "display" : "Stable Disease", + "system" : "RECIST" + } + }, { + "id" : "6b93b820-4d03-4883-af96-cf504fa6798e", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "therapy" : { + "id" : "27b6eddc-1a4f-4d68-a027-190a2c38754b", + "type" : "MTBSystemicTherapy" + }, + "effectiveDate" : "2007-02-11", + "method" : { + "code" : "RECIST", + "display" : "Nach RECIST-Kriterien", + "system" : "dnpm-dip/mtb/response/method" + }, + "value" : { + "code" : "PD", + "display" : "Progressive Disease", + "system" : "RECIST" + } + } ] +} From c5c553f817d166d2ee5b68bec04d6c3794135de7 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sun, 6 Apr 2025 13:43:58 +0200 Subject: [PATCH 51/54] refactor: move CustomMediaType into types.kt (#105) --- .../dev/dnpm/etl/processor/extensions.kt | 35 ------------------- .../kotlin/dev/dnpm/etl/processor/types.kt | 18 ++++++++-- 2 files changed, 16 insertions(+), 37 deletions(-) delete mode 100644 src/main/kotlin/dev/dnpm/etl/processor/extensions.kt diff --git a/src/main/kotlin/dev/dnpm/etl/processor/extensions.kt b/src/main/kotlin/dev/dnpm/etl/processor/extensions.kt deleted file mode 100644 index 060ecb2..0000000 --- a/src/main/kotlin/dev/dnpm/etl/processor/extensions.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * This file is part of ETL-Processor - * - * Copyright (c) 2025 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 . - */ - -package dev.dnpm.etl.processor - -import org.springframework.http.MediaType - -/** - * Custom MediaTypes - * - * @since 0.11.0 - */ -object CustomMediaType { - val APPLICATION_VND_DNPM_V2_MTB_JSON = MediaType("application", "vnd.dnpm.v2.mtb+json") - const val APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE = "application/vnd.dnpm.v2.mtb+json" - - val APPLICATION_VND_DNPM_V2_RD_JSON = MediaType("application", "vnd.dnpm.v2.rd+json") - const val APPLICATION_VND_DNPM_V2_RD_JSON_VALUE = "application/vnd.dnpm.v2.rd+json" -} diff --git a/src/main/kotlin/dev/dnpm/etl/processor/types.kt b/src/main/kotlin/dev/dnpm/etl/processor/types.kt index b2f13ef..90fa7cb 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/types.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/types.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -19,6 +19,7 @@ package dev.dnpm.etl.processor +import org.springframework.http.MediaType import java.util.* class Fingerprint(val value: String) { @@ -46,4 +47,17 @@ value class PatientId(val value: String) @JvmInline value class PatientPseudonym(val value: String) -fun emptyPatientPseudonym() = PatientPseudonym("") \ No newline at end of file +fun emptyPatientPseudonym() = PatientPseudonym("") + +/** + * Custom MediaTypes + * + * @since 0.11.0 + */ +object CustomMediaType { + val APPLICATION_VND_DNPM_V2_MTB_JSON = MediaType("application", "vnd.dnpm.v2.mtb+json") + const val APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE = "application/vnd.dnpm.v2.mtb+json" + + val APPLICATION_VND_DNPM_V2_RD_JSON = MediaType("application", "vnd.dnpm.v2.rd+json") + const val APPLICATION_VND_DNPM_V2_RD_JSON_VALUE = "application/vnd.dnpm.v2.rd+json" +} From 8e3de6a220b9f48107e1f0af8193fd37102f9ae3 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sun, 6 Apr 2025 14:42:09 +0200 Subject: [PATCH 52/54] feat: add pseudonymization for patient IDs (#107) --- .../etl/processor/pseudonym/extensions.kt | 101 ++++- .../etl/processor/pseudonym/ExtensionsTest.kt | 383 +++++++++++------- 2 files changed, 328 insertions(+), 156 deletions(-) diff --git a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt index bf645f6..111494b 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt @@ -21,12 +21,12 @@ package dev.dnpm.etl.processor.pseudonym import de.ukw.ccc.bwhc.dto.MtbFile import dev.dnpm.etl.processor.PatientId +import dev.pcvolkmer.mv64e.mtb.Mtb import org.apache.commons.codec.digest.DigestUtils /** Replaces patient ID with generated patient pseudonym * * @param pseudonymizeService The pseudonymizeService to be used - * * @return The MTB file containing patient pseudonymes */ infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) { @@ -49,7 +49,11 @@ infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) { } this.lastGuidelineTherapies?.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 + } + } this.ngsReports?.forEach { it.patient = patientPseudonym } this.previousGuidelineTherapies?.forEach { it.patient = patientPseudonym } this.rebiopsyRequests?.forEach { it.patient = patientPseudonym } @@ -63,7 +67,6 @@ infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) { * Creates new hash of content IDs with given prefix except for patient IDs * * @param pseudonymizeService The pseudonymizeService to be used - * * @return The MTB file containing rehashed content IDs */ infix fun MtbFile.anonymizeContentWith(pseudonymizeService: PseudonymizeService) { @@ -120,8 +123,8 @@ infix fun MtbFile.anonymizeContentWith(pseudonymizeService: PseudonymizeService) id = id?.let { anonymize(it) } } } - this.geneticCounsellingRequests?.onEach { geneticCounsellingRequest -> - geneticCounsellingRequest?.apply { + this.geneticCounsellingRequests?.onEach { geneticCounsellingRequest -> + geneticCounsellingRequest?.apply { id = id?.let { anonymize(it) } } } @@ -223,4 +226,90 @@ infix fun MtbFile.anonymizeContentWith(pseudonymizeService: PseudonymizeService) id = id?.let { anonymize(it) } } } -} \ No newline at end of file +} + +/** Replaces patient ID with generated patient pseudonym + * + * @since 0.11.0 + * + * @param pseudonymizeService The pseudonymizeService to be used + * @return The MTB file containing patient pseudonymes + */ +infix fun Mtb.pseudonymizeWith(pseudonymizeService: PseudonymizeService) { + val patientPseudonym = pseudonymizeService.patientPseudonym(PatientId(this.patient.id)).value + + this.episodesOfCare?.forEach { it.patient.id = patientPseudonym } + this.carePlans?.forEach { + it.patient.id = patientPseudonym + it.rebiopsyRequests?.forEach { it.patient.id = patientPseudonym } + it.histologyReevaluationRequests?.forEach { it.patient.id = patientPseudonym } + it.medicationRecommendations.forEach { it.patient.id = patientPseudonym } + it.studyEnrollmentRecommendations?.forEach { it.patient.id = patientPseudonym } + it.procedureRecommendations?.forEach { it.patient.id = patientPseudonym } + it.geneticCounselingRecommendation.patient.id = patientPseudonym + } + this.diagnoses?.forEach { it.patient.id = patientPseudonym } + this.guidelineTherapies?.forEach { it.patient.id = patientPseudonym } + this.guidelineProcedures?.forEach { it.patient.id = patientPseudonym } + this.patient.id = patientPseudonym + this.claims?.forEach { it.patient.id = patientPseudonym } + this.claimResponses?.forEach { it.patient.id = patientPseudonym } + this.diagnoses?.forEach { it.patient.id = patientPseudonym } + this.histologyReports?.forEach { + it.patient.id = patientPseudonym + it.results.tumorMorphology?.patient?.id = patientPseudonym + it.results.tumorCellContent?.patient?.id = patientPseudonym + } + this.ngsReports?.forEach { + it.patient.id = patientPseudonym + it.results.simpleVariants?.forEach { it.patient.id = patientPseudonym } + it.results.copyNumberVariants?.forEach { it.patient.id = patientPseudonym } + it.results.dnaFusions?.forEach { it.patient.id = patientPseudonym } + it.results.rnaFusions?.forEach { it.patient.id = patientPseudonym } + it.results.tumorCellContent?.patient?.id = patientPseudonym + it.results.brcaness?.patient?.id = patientPseudonym + it.results.tmb?.patient?.id = patientPseudonym + it.results.hrdScore?.patient?.id = patientPseudonym + } + this.ihcReports?.forEach { + it.patient.id = patientPseudonym + it.results.msiMmr?.forEach { it.patient.id = patientPseudonym } + it.results.proteinExpression?.forEach { it.patient.id = patientPseudonym } + } + this.responses?.forEach { it.patient.id = patientPseudonym } + this.specimens?.forEach { it.patient.id = patientPseudonym } + this.priorDiagnosticReports?.forEach { it.patient.id = patientPseudonym } + this.performanceStatus.forEach { it.patient.id = patientPseudonym } + this.systemicTherapies.forEach { + it.history?.forEach { + it.patient.id = patientPseudonym + } + } +} + +/** + * Creates new hash of content IDs with given prefix except for patient IDs + * + * @since 0.11.0 + * + * @param pseudonymizeService The pseudonymizeService to be used + * @return The MTB file containing rehashed content IDs + */ +infix fun Mtb.anonymizeContentWith(pseudonymizeService: PseudonymizeService) { + val prefix = pseudonymizeService.prefix() + + fun anonymize(id: String): String { + val hash = DigestUtils.sha256Hex("$prefix-$id").substring(0, 41).lowercase() + return "$prefix$hash" + } + + this.episodesOfCare?.forEach { + it?.apply { + id = id?.let { + anonymize(it) + } + } + } + + // TODO all other properties +} diff --git a/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt index 0acf7db..d0ccb2b 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -21,7 +21,12 @@ package dev.dnpm.etl.processor.pseudonym import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* +import dev.pcvolkmer.mv64e.mtb.MTBEpisodeOfCare +import dev.pcvolkmer.mv64e.mtb.Mtb +import dev.pcvolkmer.mv64e.mtb.PeriodDate +import dev.pcvolkmer.mv64e.mtb.Reference import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith @@ -32,167 +37,245 @@ 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) - } + @Nested + inner class UsingBwhcDatamodel { - private fun MtbFile.serialized(): String { - return ObjectMapper().writeValueAsString(this) - } + val FAKE_MTB_FILE_PATH = "fake_MTBFile.json" + val CLEAN_PATIENT_ID = "5dad2f0b-49c6-47d8-a952-7b9e9e0f7549" - @Test - fun shouldNotContainCleanPatientId(@Mock pseudonymizeService: PseudonymizeService) { - doAnswer { - it.arguments[0] - "PSEUDO-ID" - }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) - - val mtbFile = fakeMtbFile() - - mtbFile.pseudonymizeWith(pseudonymizeService) - - assertThat(mtbFile.patient.id).isEqualTo("PSEUDO-ID") - assertThat(mtbFile.serialized()).doesNotContain(CLEAN_PATIENT_ID) - } - - @Test - fun shouldNotContainAnyUuidAfterRehashingOfIds(@Mock pseudonymizeService: PseudonymizeService) { - doAnswer { - it.arguments[0] - "PSEUDO-ID" - }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) - - doAnswer { - "TESTDOMAIN" - }.whenever(pseudonymizeService).prefix() - - val mtbFile = fakeMtbFile() - - mtbFile.pseudonymizeWith(pseudonymizeService) - mtbFile.anonymizeContentWith(pseudonymizeService) - - val pattern = "\"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\"".toRegex().toPattern() - val matcher = pattern.matcher(mtbFile.serialized()) - - assertThrows { - matcher.find() - matcher.group() - }.also { - assertThat(it.message).isEqualTo("No match found") + 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(anyValueClass()) + + val mtbFile = fakeMtbFile() + + mtbFile.pseudonymizeWith(pseudonymizeService) + + assertThat(mtbFile.patient.id).isEqualTo("PSEUDO-ID") + assertThat(mtbFile.serialized()).doesNotContain(CLEAN_PATIENT_ID) + } + + @Test + fun shouldNotContainAnyUuidAfterRehashingOfIds(@Mock pseudonymizeService: PseudonymizeService) { + doAnswer { + it.arguments[0] + "PSEUDO-ID" + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) + + doAnswer { + "TESTDOMAIN" + }.whenever(pseudonymizeService).prefix() + + val mtbFile = fakeMtbFile() + + mtbFile.pseudonymizeWith(pseudonymizeService) + mtbFile.anonymizeContentWith(pseudonymizeService) + + val pattern = "\"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\"".toRegex().toPattern() + val matcher = pattern.matcher(mtbFile.serialized()) + + assertThrows { + matcher.find() + matcher.group() + }.also { + assertThat(it.message).isEqualTo("No match found") + } + + } + + @Test + fun shouldRehashIdsWithPrefix(@Mock pseudonymizeService: PseudonymizeService) { + doAnswer { + it.arguments[0] + "PSEUDO-ID" + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) + + doAnswer { + "TESTDOMAIN" + }.whenever(pseudonymizeService).prefix() + + val mtbFile = MtbFile.builder() + .withPatient( + Patient.builder() + .withId("1") + .withBirthDate("2000-08-08") + .withGender(Patient.Gender.MALE) + .build() + ) + .withConsent( + Consent.builder() + .withId("1") + .withStatus(Consent.Status.ACTIVE) + .withPatient("123") + .build() + ) + .withEpisode( + Episode.builder() + .withId("1") + .withPatient("1") + .withPeriod(PeriodStart("2023-08-08")) + .build() + ) + .build() + + mtbFile.pseudonymizeWith(pseudonymizeService) + mtbFile.anonymizeContentWith(pseudonymizeService) + + + assertThat(mtbFile.episode.id) + // TESTDOMAIN + .isEqualTo("TESTDOMAIN44e20a53bbbf9f3ae39626d05df7014dcd77d6098") + } + + @Test + fun shouldNotThrowExceptionOnNullValues(@Mock pseudonymizeService: PseudonymizeService) { + doAnswer { + it.arguments[0] + "PSEUDO-ID" + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) + + doAnswer { + "TESTDOMAIN" + }.whenever(pseudonymizeService).prefix() + + val mtbFile = MtbFile.builder() + .withPatient( + Patient.builder() + .withId("1") + .withBirthDate("2000-08-08") + .withGender(Patient.Gender.MALE) + .build() + ) + .withConsent( + Consent.builder() + .withId("1") + .withStatus(Consent.Status.ACTIVE) + .withPatient("123") + .build() + ) + .withEpisode( + Episode.builder() + .withId("1") + .withPatient("1") + .withPeriod(PeriodStart("2023-08-08")) + .build() + ) + .withClaims(null) + .withDiagnoses(null) + .withCarePlans(null) + .withClaimResponses(null) + .withEcogStatus(null) + .withFamilyMemberDiagnoses(null) + .withGeneticCounsellingRequests(null) + .withHistologyReevaluationRequests(null) + .withHistologyReports(null) + .withLastGuidelineTherapies(null) + .withMolecularPathologyFindings(null) + .withMolecularTherapies(null) + .withNgsReports(null) + .withPreviousGuidelineTherapies(null) + .withRebiopsyRequests(null) + .withRecommendations(null) + .withResponses(null) + .withStudyInclusionRequests(null) + .withSpecimens(null) + .build() + + mtbFile.pseudonymizeWith(pseudonymizeService) + mtbFile.anonymizeContentWith(pseudonymizeService) + + assertThat(mtbFile.episode.id).isNotNull() + } } - @Test - fun shouldRehashIdsWithPrefix(@Mock pseudonymizeService: PseudonymizeService) { - doAnswer { - it.arguments[0] - "PSEUDO-ID" - }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) + @Nested + inner class UsingDnpmV2Datamodel { - doAnswer { - "TESTDOMAIN" - }.whenever(pseudonymizeService).prefix() + val FAKE_MTB_FILE_PATH = "mv64e-mtb-fake-patient.json" + val CLEAN_PATIENT_ID = "63f8fd7b-8127-4f3c-8843-aa9199e21c29" - val mtbFile = MtbFile.builder() - .withPatient( - Patient.builder() - .withId("1") - .withBirthDate("2000-08-08") - .withGender(Patient.Gender.MALE) - .build() - ) - .withConsent( - Consent.builder() - .withId("1") - .withStatus(Consent.Status.ACTIVE) - .withPatient("123") - .build() - ) - .withEpisode( - Episode.builder() - .withId("1") - .withPatient("1") - .withPeriod(PeriodStart("2023-08-08")) - .build() - ) - .build() + private fun fakeMtbFile(): Mtb { + val mtbFile = ClassPathResource(FAKE_MTB_FILE_PATH).inputStream + return ObjectMapper().readValue(mtbFile, Mtb::class.java) + } - mtbFile.pseudonymizeWith(pseudonymizeService) - mtbFile.anonymizeContentWith(pseudonymizeService) + private fun Mtb.serialized(): String { + return ObjectMapper().writeValueAsString(this) + } + @Test + fun shouldNotContainCleanPatientId(@Mock pseudonymizeService: PseudonymizeService) { + doAnswer { + it.arguments[0] + "PSEUDO-ID" + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) - assertThat(mtbFile.episode.id) - // TESTDOMAIN - .isEqualTo("TESTDOMAIN44e20a53bbbf9f3ae39626d05df7014dcd77d6098") + val mtbFile = fakeMtbFile() + + mtbFile.pseudonymizeWith(pseudonymizeService) + + assertThat(mtbFile.patient.id).isEqualTo("PSEUDO-ID") + assertThat(mtbFile.serialized()).doesNotContain(CLEAN_PATIENT_ID) + } + + @Test + fun shouldNotThrowExceptionOnNullValues(@Mock pseudonymizeService: PseudonymizeService) { + doAnswer { + it.arguments[0] + "PSEUDO-ID" + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) + + doAnswer { + "TESTDOMAIN" + }.whenever(pseudonymizeService).prefix() + + val mtbFile = Mtb.builder() + .withPatient( + dev.pcvolkmer.mv64e.mtb.Patient.builder() + .withId("1") + .withBirthDate("2000-08-08") + .withGender(null) + .build() + ) + .withEpisodesOfCare( + listOf( + MTBEpisodeOfCare.builder() + .withId("1") + .withPatient(Reference("1")) + .withPeriod(PeriodDate.builder().withStart("2023-08-08").build()) + .build() + ) + ) + .withClaims(null) + .withDiagnoses(null) + .withCarePlans(null) + .withClaimResponses(null) + .withHistologyReports(null) + .withNgsReports(null) + .withResponses(null) + .withSpecimens(null) + .build() + + mtbFile.pseudonymizeWith(pseudonymizeService) + mtbFile.anonymizeContentWith(pseudonymizeService) + + assertThat(mtbFile.episodesOfCare).hasSize(1) + assertThat(mtbFile.episodesOfCare.map { it.id }).isNotNull + } } - - @Test - fun shouldNotThrowExceptionOnNullValues(@Mock pseudonymizeService: PseudonymizeService) { - doAnswer { - it.arguments[0] - "PSEUDO-ID" - }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) - - doAnswer { - "TESTDOMAIN" - }.whenever(pseudonymizeService).prefix() - - val mtbFile = MtbFile.builder() - .withPatient( - Patient.builder() - .withId("1") - .withBirthDate("2000-08-08") - .withGender(Patient.Gender.MALE) - .build() - ) - .withConsent( - Consent.builder() - .withId("1") - .withStatus(Consent.Status.ACTIVE) - .withPatient("123") - .build() - ) - .withEpisode( - Episode.builder() - .withId("1") - .withPatient("1") - .withPeriod(PeriodStart("2023-08-08")) - .build() - ) - .withClaims(null) - .withDiagnoses(null) - .withCarePlans(null) - .withClaimResponses(null) - .withEcogStatus(null) - .withFamilyMemberDiagnoses(null) - .withGeneticCounsellingRequests(null) - .withHistologyReevaluationRequests(null) - .withHistologyReports(null) - .withLastGuidelineTherapies(null) - .withMolecularPathologyFindings(null) - .withMolecularTherapies(null) - .withNgsReports(null) - .withPreviousGuidelineTherapies(null) - .withRebiopsyRequests(null) - .withRecommendations(null) - .withResponses(null) - .withStudyInclusionRequests(null) - .withSpecimens(null) - .build() - - mtbFile.pseudonymizeWith(pseudonymizeService) - mtbFile.anonymizeContentWith(pseudonymizeService) - - - assertThat(mtbFile.episode.id).isNotNull() - } - -} \ No newline at end of file +} From c6b37fda69784a5d6058fe19ab87bf73e84c8b1c Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sun, 6 Apr 2025 22:17:46 +0200 Subject: [PATCH 53/54] feat: support multiple request content types (#109) --- .../processor/EtlProcessorApplicationTests.kt | 7 +- .../input/MtbFileRestControllerTest.kt | 12 +- .../processor/input/MtbFileRestController.kt | 9 +- .../processor/output/KafkaMtbFileSender.kt | 56 +-- .../etl/processor/output/MtbFileSender.kt | 12 +- .../dnpm/etl/processor/output/MtbRequest.kt | 59 +++ .../etl/processor/output/RestMtbFileSender.kt | 27 +- .../processor/services/RequestProcessor.kt | 54 ++- .../services/TransformationService.kt | 15 +- .../processor/input/KafkaInputListenerTest.kt | 4 +- .../input/MtbFileRestControllerTest.kt | 9 +- .../output/KafkaMtbFileSenderTest.kt | 365 ++++++++++++------ .../output/RestBwhcMtbFileSenderTest.kt | 17 +- .../output/RestDipMtbFileSenderTest.kt | 297 +++++++++----- .../services/RequestProcessorTest.kt | 31 +- 15 files changed, 643 insertions(+), 331 deletions(-) create mode 100644 src/main/kotlin/dev/dnpm/etl/processor/output/MtbRequest.kt diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt index 736bdf8..8984e60 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt @@ -23,6 +23,7 @@ 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.BwhcV1MtbFileRequest import dev.dnpm.etl.processor.output.MtbFileSender import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach @@ -91,7 +92,7 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() { fun mtbFileIsTransformed() { doAnswer { MtbFileSender.Response(RequestStatus.SUCCESS) - }.whenever(mtbFileSender).send(any()) + }.whenever(mtbFileSender).send(any()) val mtbFile = MtbFile.builder() .withPatient( @@ -134,9 +135,9 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() { } } - val captor = argumentCaptor() + val captor = argumentCaptor() verify(mtbFileSender).send(captor.capture()) - assertThat(captor.firstValue.mtbFile.diagnoses).hasSize(1).allMatch { diagnosis -> + assertThat(captor.firstValue.content.diagnoses).hasSize(1).allMatch { diagnosis -> diagnosis.icd10.version == "2014" } } diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt index 85b1f1f..f1b1476 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt @@ -91,7 +91,7 @@ class MtbFileRestControllerTest { status { isAccepted() } } - verify(requestProcessor, times(1)).processMtbFile(any()) + verify(requestProcessor, times(1)).processMtbFile(any()) } @Test @@ -104,7 +104,7 @@ class MtbFileRestControllerTest { status { isAccepted() } } - verify(requestProcessor, times(1)).processMtbFile(any()) + verify(requestProcessor, times(1)).processMtbFile(any()) } @Test @@ -117,7 +117,7 @@ class MtbFileRestControllerTest { status { isUnauthorized() } } - verify(requestProcessor, never()).processMtbFile(any()) + verify(requestProcessor, never()).processMtbFile(any()) } @Test @@ -130,7 +130,7 @@ class MtbFileRestControllerTest { status { isForbidden() } } - verify(requestProcessor, never()).processMtbFile(any()) + verify(requestProcessor, never()).processMtbFile(any()) } @Test @@ -177,7 +177,7 @@ class MtbFileRestControllerTest { status { isAccepted() } } - verify(requestProcessor, times(1)).processMtbFile(any()) + verify(requestProcessor, times(1)).processMtbFile(any()) } @Test @@ -190,7 +190,7 @@ class MtbFileRestControllerTest { status { isAccepted() } } - verify(requestProcessor, times(1)).processMtbFile(any()) + verify(requestProcessor, times(1)).processMtbFile(any()) } } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt b/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt index 432711a..e67a380 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt @@ -26,7 +26,6 @@ import dev.dnpm.etl.processor.PatientId import dev.dnpm.etl.processor.services.RequestProcessor import dev.pcvolkmer.mv64e.mtb.Mtb import org.slf4j.LoggerFactory -import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -47,10 +46,10 @@ class MtbFileRestController( @PostMapping( consumes = [ MediaType.APPLICATION_JSON_VALUE ] ) fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity { if (mtbFile.consent.status == Consent.Status.ACTIVE) { - logger.debug("Accepted MTB File for processing") + logger.debug("Accepted MTB File (bwHC V1) for processing") requestProcessor.processMtbFile(mtbFile) } else { - logger.debug("Accepted MTB File and process deletion") + logger.debug("Accepted MTB File (bwHC V1) and process deletion") val patientId = PatientId(mtbFile.patient.id) requestProcessor.processDeletion(patientId) } @@ -59,7 +58,9 @@ class MtbFileRestController( @PostMapping( consumes = [ CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE] ) fun mtbFile(@RequestBody mtbFile: Mtb): ResponseEntity { - return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build() + logger.debug("Accepted MTB File (DNPM V2) for processing") + requestProcessor.processMtbFile(mtbFile) + return ResponseEntity.accepted().build() } @DeleteMapping(path = ["{patientId}"]) diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt index 6391e99..c00b2fd 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -22,10 +22,12 @@ package dev.dnpm.etl.processor.output import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.MtbFile -import dev.dnpm.etl.processor.RequestId +import dev.dnpm.etl.processor.CustomMediaType import dev.dnpm.etl.processor.config.KafkaProperties import dev.dnpm.etl.processor.monitoring.RequestStatus +import org.apache.kafka.clients.producer.ProducerRecord import org.slf4j.LoggerFactory +import org.springframework.http.MediaType import org.springframework.kafka.core.KafkaTemplate import org.springframework.retry.support.RetryTemplate @@ -38,14 +40,20 @@ class KafkaMtbFileSender( private val logger = LoggerFactory.getLogger(KafkaMtbFileSender::class.java) - override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response { + override fun send(request: MtbFileRequest): MtbFileSender.Response { return try { return retryTemplate.execute { - val result = kafkaTemplate.send( - kafkaProperties.outputTopic, - key(request), - objectMapper.writeValueAsString(Data(request.requestId, request.mtbFile)) - ) + val record = + ProducerRecord(kafkaProperties.outputTopic, key(request), objectMapper.writeValueAsString(request)) + when (request) { + is BwhcV1MtbFileRequest -> record.headers() + .add("contentType", MediaType.APPLICATION_JSON_VALUE.toByteArray()) + + is DnpmV2MtbFileRequest -> record.headers() + .add("contentType", CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE.toByteArray()) + } + + val result = kafkaTemplate.send(record) if (result.get() != null) { logger.debug("Sent file via KafkaMtbFileSender") MtbFileSender.Response(RequestStatus.UNKNOWN) @@ -59,7 +67,7 @@ class KafkaMtbFileSender( } } - override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response { + override fun send(request: DeleteRequest): MtbFileSender.Response { val dummyMtbFile = MtbFile.builder() .withConsent( Consent.builder() @@ -71,12 +79,15 @@ class KafkaMtbFileSender( return try { return retryTemplate.execute { - val result = kafkaTemplate.send( - kafkaProperties.outputTopic, - key(request), - objectMapper.writeValueAsString(Data(request.requestId, dummyMtbFile)) - ) + val record = + ProducerRecord( + kafkaProperties.outputTopic, + key(request), + // Always use old BwhcV1FileRequest with Consent REJECT + objectMapper.writeValueAsString(BwhcV1MtbFileRequest(request.requestId, dummyMtbFile)) + ) + val result = kafkaTemplate.send(record) if (result.get() != null) { logger.debug("Sent deletion request via KafkaMtbFileSender") MtbFileSender.Response(RequestStatus.UNKNOWN) @@ -94,13 +105,12 @@ class KafkaMtbFileSender( return "${this.kafkaProperties.servers} (${this.kafkaProperties.outputTopic}/${this.kafkaProperties.outputResponseTopic})" } - private fun key(request: MtbFileSender.MtbFileRequest): String { - return "{\"pid\": \"${request.mtbFile.patient.id}\"}" + private fun key(request: MtbRequest): String { + return when (request) { + is BwhcV1MtbFileRequest -> "{\"pid\": \"${request.content.patient.id}\"}" + is DnpmV2MtbFileRequest -> "{\"pid\": \"${request.content.patient.id}\"}" + is DeleteRequest -> "{\"pid\": \"${request.patientId.value}\"}" + else -> throw IllegalArgumentException("Unsupported request type: ${request::class.simpleName}") + } } - - private fun key(request: MtbFileSender.DeleteRequest): String { - return "{\"pid\": \"${request.patientId.value}\"}" - } - - data class Data(val requestId: RequestId, val content: MtbFile) -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/MtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/MtbFileSender.kt index 8d994c5..285ce07 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/MtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/MtbFileSender.kt @@ -19,25 +19,17 @@ package dev.dnpm.etl.processor.output -import de.ukw.ccc.bwhc.dto.MtbFile -import dev.dnpm.etl.processor.PatientPseudonym -import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.monitoring.RequestStatus import org.springframework.http.HttpStatusCode interface MtbFileSender { - fun send(request: MtbFileRequest): Response + fun send(request: MtbFileRequest): Response fun send(request: DeleteRequest): Response fun endpoint(): String data class Response(val status: RequestStatus, val body: String = "") - - data class MtbFileRequest(val requestId: RequestId, val mtbFile: MtbFile) - - data class DeleteRequest(val requestId: RequestId, val patientId: PatientPseudonym) - } fun Int.asRequestStatus(): RequestStatus { @@ -51,4 +43,4 @@ fun Int.asRequestStatus(): RequestStatus { fun HttpStatusCode.asRequestStatus(): RequestStatus { return this.value().asRequestStatus() -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/MtbRequest.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/MtbRequest.kt new file mode 100644 index 0000000..9b500f0 --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/MtbRequest.kt @@ -0,0 +1,59 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2025 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 . + */ + +package dev.dnpm.etl.processor.output + +import de.ukw.ccc.bwhc.dto.MtbFile +import dev.dnpm.etl.processor.PatientPseudonym +import dev.dnpm.etl.processor.RequestId +import dev.pcvolkmer.mv64e.mtb.Mtb + +interface MtbRequest { + val requestId: RequestId +} + +sealed interface MtbFileRequest : MtbRequest { + override val requestId: RequestId + val content: T + + fun patientPseudonym(): PatientPseudonym +} + +data class BwhcV1MtbFileRequest( + override val requestId: RequestId, + override val content: MtbFile +) : MtbFileRequest { + override fun patientPseudonym(): PatientPseudonym { + return PatientPseudonym(content.patient.id) + } +} + +data class DnpmV2MtbFileRequest( + override val requestId: RequestId, + override val content: Mtb +) : MtbFileRequest { + override fun patientPseudonym(): PatientPseudonym { + return PatientPseudonym(content.patient.id) + } +} + +data class DeleteRequest( + override val requestId: RequestId, + val patientId: PatientPseudonym +) : MtbRequest diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt index 90e3629..78222b2 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -19,10 +19,11 @@ package dev.dnpm.etl.processor.output -import dev.dnpm.etl.processor.config.RestTargetProperties -import dev.dnpm.etl.processor.monitoring.RequestStatus +import dev.dnpm.etl.processor.CustomMediaType import dev.dnpm.etl.processor.PatientPseudonym +import dev.dnpm.etl.processor.config.RestTargetProperties import dev.dnpm.etl.processor.monitoring.ReportService +import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.asRequestStatus import org.slf4j.LoggerFactory import org.springframework.http.HttpEntity @@ -46,11 +47,11 @@ abstract class RestMtbFileSender( abstract fun deleteUrl(patientId: PatientPseudonym): String - override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response { + override fun send(request: MtbFileRequest): MtbFileSender.Response { try { return retryTemplate.execute { - val headers = getHttpHeaders() - val entityReq = HttpEntity(request.mtbFile, headers) + val headers = getHttpHeaders(request) + val entityReq = HttpEntity(request.content, headers) val response = restTemplate.postForEntity( sendUrl(), entityReq, @@ -76,10 +77,10 @@ abstract class RestMtbFileSender( return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung") } - override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response { + override fun send(request: DeleteRequest): MtbFileSender.Response { try { return retryTemplate.execute { - val headers = getHttpHeaders() + val headers = getHttpHeaders(request) val entityReq = HttpEntity(null, headers) restTemplate.delete( deleteUrl(request.patientId), @@ -102,11 +103,15 @@ abstract class RestMtbFileSender( return this.restTargetProperties.uri.orEmpty() } - private fun getHttpHeaders(): HttpHeaders { + private fun getHttpHeaders(request: MtbRequest): HttpHeaders { val username = restTargetProperties.username val password = restTargetProperties.password val headers = HttpHeaders() - headers.contentType = MediaType.APPLICATION_JSON + headers.contentType = when (request) { + is BwhcV1MtbFileRequest -> MediaType.APPLICATION_JSON + is DnpmV2MtbFileRequest -> CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON + else -> MediaType.APPLICATION_JSON + } if (username.isNullOrBlank() || password.isNullOrBlank()) { return headers @@ -116,4 +121,4 @@ abstract class RestMtbFileSender( return headers } -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt index 5b2c42a..f25452e 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -27,10 +27,11 @@ import dev.dnpm.etl.processor.monitoring.Report import dev.dnpm.etl.processor.monitoring.Request import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestType -import dev.dnpm.etl.processor.output.MtbFileSender +import dev.dnpm.etl.processor.output.* import dev.dnpm.etl.processor.pseudonym.PseudonymizeService import dev.dnpm.etl.processor.pseudonym.anonymizeContentWith import dev.dnpm.etl.processor.pseudonym.pseudonymizeWith +import dev.pcvolkmer.mv64e.mtb.Mtb import org.apache.commons.codec.binary.Base32 import org.apache.commons.codec.digest.DigestUtils import org.springframework.context.ApplicationEventPublisher @@ -55,29 +56,40 @@ class RequestProcessor( fun processMtbFile(mtbFile: MtbFile, requestId: RequestId) { val pid = PatientId(mtbFile.patient.id) - mtbFile pseudonymizeWith pseudonymizeService mtbFile anonymizeContentWith pseudonymizeService + val request = BwhcV1MtbFileRequest(requestId, transformationService.transform(mtbFile)) + saveAndSend(request, pid) + } - val request = MtbFileSender.MtbFileRequest(requestId, transformationService.transform(mtbFile)) + fun processMtbFile(mtbFile: Mtb) { + processMtbFile(mtbFile, randomRequestId()) + } - val patientPseudonym = PatientPseudonym(request.mtbFile.patient.id) + fun processMtbFile(mtbFile: Mtb, requestId: RequestId) { + val pid = PatientId(mtbFile.patient.id) + mtbFile pseudonymizeWith pseudonymizeService + mtbFile anonymizeContentWith pseudonymizeService + val request = DnpmV2MtbFileRequest(requestId, transformationService.transform(mtbFile)) + saveAndSend(request, pid) + } + private fun saveAndSend(request: MtbFileRequest, pid: PatientId) { requestService.save( Request( - requestId, - patientPseudonym, + request.requestId, + request.patientPseudonym(), pid, - fingerprint(request.mtbFile), + fingerprint(request), RequestType.MTB_FILE, RequestStatus.UNKNOWN ) ) - if (appConfigProperties.duplicationDetection && isDuplication(mtbFile)) { + if (appConfigProperties.duplicationDetection && isDuplication(request)) { applicationEventPublisher.publishEvent( ResponseEvent( - requestId, + request.requestId, Instant.now(), RequestStatus.DUPLICATION ) @@ -89,7 +101,7 @@ class RequestProcessor( applicationEventPublisher.publishEvent( ResponseEvent( - requestId, + request.requestId, Instant.now(), responseStatus.status, when (responseStatus.status) { @@ -100,8 +112,11 @@ class RequestProcessor( ) } - private fun isDuplication(pseudonymizedMtbFile: MtbFile): Boolean { - val patientPseudonym = PatientPseudonym(pseudonymizedMtbFile.patient.id) + private fun isDuplication(pseudonymizedMtbFileRequest: MtbFileRequest): Boolean { + val patientPseudonym = when (pseudonymizedMtbFileRequest) { + is BwhcV1MtbFileRequest -> PatientPseudonym(pseudonymizedMtbFileRequest.content.patient.id) + is DnpmV2MtbFileRequest -> PatientPseudonym(pseudonymizedMtbFileRequest.content.patient.id) + } val lastMtbFileRequestForPatient = requestService.lastMtbFileRequestForPatientPseudonym(patientPseudonym) @@ -109,7 +124,7 @@ class RequestProcessor( return null != lastMtbFileRequestForPatient && !isLastRequestDeletion - && lastMtbFileRequestForPatient.fingerprint == fingerprint(pseudonymizedMtbFile) + && lastMtbFileRequestForPatient.fingerprint == fingerprint(pseudonymizedMtbFileRequest) } fun processDeletion(patientId: PatientId) { @@ -131,7 +146,7 @@ class RequestProcessor( ) ) - val responseStatus = sender.send(MtbFileSender.DeleteRequest(requestId, patientPseudonym)) + val responseStatus = sender.send(DeleteRequest(requestId, patientPseudonym)) applicationEventPublisher.publishEvent( ResponseEvent( @@ -160,8 +175,11 @@ class RequestProcessor( } } - private fun fingerprint(mtbFile: MtbFile): Fingerprint { - return fingerprint(objectMapper.writeValueAsString(mtbFile)) + private fun fingerprint(request: MtbFileRequest): Fingerprint { + return when (request) { + is BwhcV1MtbFileRequest -> fingerprint(objectMapper.writeValueAsString(request.content)) + is DnpmV2MtbFileRequest -> fingerprint(objectMapper.writeValueAsString(request.content)) + } } private fun fingerprint(s: String): Fingerprint { @@ -172,4 +190,4 @@ class RequestProcessor( ) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/TransformationService.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/TransformationService.kt index 2a9dc5b..9447a84 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/TransformationService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/TransformationService.kt @@ -23,10 +23,21 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.jayway.jsonpath.JsonPath import com.jayway.jsonpath.PathNotFoundException import de.ukw.ccc.bwhc.dto.MtbFile +import dev.pcvolkmer.mv64e.mtb.Mtb class TransformationService(private val objectMapper: ObjectMapper, private val transformations: List) { fun transform(mtbFile: MtbFile): MtbFile { - var json = objectMapper.writeValueAsString(mtbFile) + val json = transform(objectMapper.writeValueAsString(mtbFile)) + return objectMapper.readValue(json, MtbFile::class.java) + } + + fun transform(mtbFile: Mtb): Mtb { + val json = transform(objectMapper.writeValueAsString(mtbFile)) + return objectMapper.readValue(json, Mtb::class.java) + } + + private fun transform(content: String): String { + var json = content transformations.forEach { transformation -> val jsonPath = JsonPath.parse(json) @@ -48,7 +59,7 @@ class TransformationService(private val objectMapper: ObjectMapper, private val json = jsonPath.jsonString() } - return objectMapper.readValue(json, MtbFile::class.java) + return json } fun getTransformations(): List { diff --git a/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt index 10900a8..f2abd27 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt @@ -74,7 +74,7 @@ class KafkaInputListenerTest { ) ) - verify(requestProcessor, times(1)).processMtbFile(any()) + verify(requestProcessor, times(1)).processMtbFile(any()) } @Test @@ -121,7 +121,7 @@ class KafkaInputListenerTest { ) ) - verify(requestProcessor, times(1)).processMtbFile(any(), anyValueClass()) + verify(requestProcessor, times(1)).processMtbFile(any(), anyValueClass()) } @Test diff --git a/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt index faaf778..4a33078 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt @@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* import dev.dnpm.etl.processor.CustomMediaType import dev.dnpm.etl.processor.services.RequestProcessor +import dev.pcvolkmer.mv64e.mtb.Mtb import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -72,7 +73,7 @@ class MtbFileRestControllerTest { } } - verify(requestProcessor, times(1)).processMtbFile(any()) + verify(requestProcessor, times(1)).processMtbFile(any()) } @Test @@ -128,7 +129,7 @@ class MtbFileRestControllerTest { } } - verify(requestProcessor, times(1)).processMtbFile(any()) + verify(requestProcessor, times(1)).processMtbFile(any()) } @Test @@ -182,11 +183,11 @@ class MtbFileRestControllerTest { contentType = CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON }.andExpect { status { - isNotImplemented() + isAccepted() } } - verify(requestProcessor, times(0)).processMtbFile(any()) + verify(requestProcessor, times(1)).processMtbFile(any()) } } diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSenderTest.kt index 655e29e..e5fb925 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSenderTest.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * Copyright (c) 2025 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 @@ -21,20 +21,25 @@ package dev.dnpm.etl.processor.output import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* +import de.ukw.ccc.bwhc.dto.Patient +import dev.dnpm.etl.processor.CustomMediaType import dev.dnpm.etl.processor.PatientPseudonym import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.config.KafkaProperties import dev.dnpm.etl.processor.monitoring.RequestStatus +import dev.pcvolkmer.mv64e.mtb.* +import org.apache.kafka.clients.producer.ProducerRecord 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.extension.ExtendWith import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource -import org.mockito.ArgumentMatchers.anyString import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.* +import org.springframework.http.MediaType import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.support.SendResult import org.springframework.retry.policy.SimpleRetryPolicy @@ -45,142 +50,231 @@ import java.util.concurrent.ExecutionException @ExtendWith(MockitoExtension::class) class KafkaMtbFileSenderTest { - private lateinit var kafkaTemplate: KafkaTemplate + @Nested + inner class BwhcV1Record { - private lateinit var kafkaMtbFileSender: KafkaMtbFileSender + private lateinit var kafkaTemplate: KafkaTemplate - private lateinit var objectMapper: ObjectMapper + private lateinit var kafkaMtbFileSender: KafkaMtbFileSender - @BeforeEach - fun setup( - @Mock kafkaTemplate: KafkaTemplate - ) { - val kafkaProperties = KafkaProperties("testtopic") - val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() + private lateinit var objectMapper: ObjectMapper - this.objectMapper = ObjectMapper() - this.kafkaTemplate = kafkaTemplate + @BeforeEach + fun setup( + @Mock kafkaTemplate: KafkaTemplate + ) { + val kafkaProperties = KafkaProperties("testtopic") + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() - this.kafkaMtbFileSender = KafkaMtbFileSender(kafkaTemplate, kafkaProperties, retryTemplate, objectMapper) - } + this.objectMapper = ObjectMapper() + this.kafkaTemplate = kafkaTemplate - @ParameterizedTest - @MethodSource("requestWithResponseSource") - fun shouldSendMtbFileRequestAndReturnExpectedState(testData: TestData) { - doAnswer { - if (null != testData.exception) { - throw testData.exception - } - completedFuture(SendResult(null, null)) - }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - - val response = kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile(Consent.Status.ACTIVE))) - assertThat(response.status).isEqualTo(testData.requestStatus) - } - - @ParameterizedTest - @MethodSource("requestWithResponseSource") - fun shouldSendDeleteRequestAndReturnExpectedState(testData: TestData) { - doAnswer { - if (null != testData.exception) { - throw testData.exception - } - completedFuture(SendResult(null, null)) - }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - - val response = kafkaMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) - assertThat(response.status).isEqualTo(testData.requestStatus) - } - - @Test - fun shouldSendMtbFileRequestWithCorrectKeyAndBody() { - doAnswer { - completedFuture(SendResult(null, null)) - }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - - kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile(Consent.Status.ACTIVE))) - - val captor = argumentCaptor() - verify(kafkaTemplate, times(1)).send(anyString(), captor.capture(), captor.capture()) - assertThat(captor.firstValue).isNotNull - assertThat(captor.firstValue).isEqualTo("{\"pid\": \"PID\"}") - assertThat(captor.secondValue).isNotNull - assertThat(captor.secondValue).isEqualTo(objectMapper.writeValueAsString(kafkaRecordData(TEST_REQUEST_ID, Consent.Status.ACTIVE))) - } - - @Test - fun shouldSendDeleteRequestWithCorrectKeyAndBody() { - doAnswer { - completedFuture(SendResult(null, null)) - }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - - kafkaMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) - - val captor = argumentCaptor() - verify(kafkaTemplate, times(1)).send(anyString(), captor.capture(), captor.capture()) - assertThat(captor.firstValue).isNotNull - assertThat(captor.firstValue).isEqualTo("{\"pid\": \"PID\"}") - assertThat(captor.secondValue).isNotNull - assertThat(captor.secondValue).isEqualTo(objectMapper.writeValueAsString(kafkaRecordData(TEST_REQUEST_ID, Consent.Status.REJECTED))) - } - - @ParameterizedTest - @MethodSource("requestWithResponseSource") - fun shouldRetryOnMtbFileKafkaSendError(testData: TestData) { - val kafkaProperties = KafkaProperties("testtopic") - val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() - this.kafkaMtbFileSender = KafkaMtbFileSender(this.kafkaTemplate, kafkaProperties, retryTemplate, this.objectMapper) - - doAnswer { - if (null != testData.exception) { - throw testData.exception - } - completedFuture(SendResult(null, null)) - }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - - kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile(Consent.Status.ACTIVE))) - - val expectedCount = when (testData.exception) { - // OK - No Retry - null -> times(1) - // Request failed - Retry max 3 times - else -> times(3) + this.kafkaMtbFileSender = KafkaMtbFileSender(kafkaTemplate, kafkaProperties, retryTemplate, objectMapper) } - verify(kafkaTemplate, expectedCount).send(anyString(), anyString(), anyString()) - } + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource") + fun shouldSendMtbFileRequestAndReturnExpectedState(testData: TestData) { + doAnswer { + if (null != testData.exception) { + throw testData.exception + } + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) - @ParameterizedTest - @MethodSource("requestWithResponseSource") - fun shouldRetryOnDeleteKafkaSendError(testData: TestData) { - val kafkaProperties = KafkaProperties("testtopic") - val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() - this.kafkaMtbFileSender = KafkaMtbFileSender(this.kafkaTemplate, kafkaProperties, retryTemplate, this.objectMapper) - - doAnswer { - if (null != testData.exception) { - throw testData.exception - } - completedFuture(SendResult(null, null)) - }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - - kafkaMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) - - val expectedCount = when (testData.exception) { - // OK - No Retry - null -> times(1) - // Request failed - Retry max 3 times - else -> times(3) + val response = kafkaMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1MtbFile(Consent.Status.ACTIVE))) + assertThat(response.status).isEqualTo(testData.requestStatus) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource") + fun shouldSendDeleteRequestAndReturnExpectedState(testData: TestData) { + doAnswer { + if (null != testData.exception) { + throw testData.exception + } + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) + + val response = kafkaMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) + assertThat(response.status).isEqualTo(testData.requestStatus) + } + + @Test + fun shouldSendMtbFileRequestWithCorrectKeyAndHeaderAndBody() { + doAnswer { + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) + + kafkaMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1MtbFile(Consent.Status.ACTIVE))) + + val captor = argumentCaptor>() + verify(kafkaTemplate, times(1)).send(captor.capture()) + assertThat(captor.firstValue.key()).isNotNull + assertThat(captor.firstValue.key()).isEqualTo("{\"pid\": \"PID\"}") + assertThat(captor.firstValue.headers().headers("contentType")).isNotNull + assertThat(captor.firstValue.headers().headers("contentType")?.firstOrNull()?.value()).isEqualTo(MediaType.APPLICATION_JSON_VALUE.toByteArray()) + assertThat(captor.firstValue.value()).isNotNull + assertThat(captor.firstValue.value()).isEqualTo(objectMapper.writeValueAsString(bwhcV1kafkaRecordData(TEST_REQUEST_ID, Consent.Status.ACTIVE))) + } + + @Test + fun shouldSendDeleteRequestWithCorrectKeyAndBody() { + doAnswer { + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) + + kafkaMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) + + val captor = argumentCaptor>() + verify(kafkaTemplate, times(1)).send(captor.capture()) + assertThat(captor.firstValue.key()).isNotNull + assertThat(captor.firstValue.key()).isEqualTo("{\"pid\": \"PID\"}") + assertThat(captor.firstValue.value()).isNotNull + assertThat(captor.firstValue.value()).isEqualTo(objectMapper.writeValueAsString(bwhcV1kafkaRecordData(TEST_REQUEST_ID, Consent.Status.REJECTED))) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource") + fun shouldRetryOnMtbFileKafkaSendError(testData: TestData) { + val kafkaProperties = KafkaProperties("testtopic") + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() + this.kafkaMtbFileSender = KafkaMtbFileSender(this.kafkaTemplate, kafkaProperties, retryTemplate, this.objectMapper) + + doAnswer { + if (null != testData.exception) { + throw testData.exception + } + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) + + kafkaMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1MtbFile(Consent.Status.ACTIVE))) + + val expectedCount = when (testData.exception) { + // OK - No Retry + null -> times(1) + // Request failed - Retry max 3 times + else -> times(3) + } + + verify(kafkaTemplate, expectedCount).send(any>()) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource") + fun shouldRetryOnDeleteKafkaSendError(testData: TestData) { + val kafkaProperties = KafkaProperties("testtopic") + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() + this.kafkaMtbFileSender = KafkaMtbFileSender(this.kafkaTemplate, kafkaProperties, retryTemplate, this.objectMapper) + + doAnswer { + if (null != testData.exception) { + throw testData.exception + } + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) + + kafkaMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) + + val expectedCount = when (testData.exception) { + // OK - No Retry + null -> times(1) + // Request failed - Retry max 3 times + else -> times(3) + } + + verify(kafkaTemplate, expectedCount).send(any>()) + } + + } + + @Nested + inner class DnpmV2Record { + + private lateinit var kafkaTemplate: KafkaTemplate + + private lateinit var kafkaMtbFileSender: KafkaMtbFileSender + + private lateinit var objectMapper: ObjectMapper + + @BeforeEach + fun setup( + @Mock kafkaTemplate: KafkaTemplate + ) { + val kafkaProperties = KafkaProperties("testtopic") + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() + + this.objectMapper = ObjectMapper() + this.kafkaTemplate = kafkaTemplate + + this.kafkaMtbFileSender = KafkaMtbFileSender(kafkaTemplate, kafkaProperties, retryTemplate, objectMapper) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource") + fun shouldSendMtbFileRequestAndReturnExpectedState(testData: TestData) { + doAnswer { + if (null != testData.exception) { + throw testData.exception + } + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) + + val response = kafkaMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile())) + assertThat(response.status).isEqualTo(testData.requestStatus) + } + + @Test + fun shouldSendMtbFileRequestWithCorrectKeyAndHeaderAndBody() { + doAnswer { + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) + + kafkaMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile())) + + val captor = argumentCaptor>() + verify(kafkaTemplate, times(1)).send(captor.capture()) + assertThat(captor.firstValue.key()).isNotNull + assertThat(captor.firstValue.key()).isEqualTo("{\"pid\": \"PID\"}") + assertThat(captor.firstValue.headers().headers("contentType")).isNotNull + assertThat(captor.firstValue.headers().headers("contentType")?.firstOrNull()?.value()).isEqualTo(CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE.toByteArray()) + assertThat(captor.firstValue.value()).isNotNull + assertThat(captor.firstValue.value()).isEqualTo(objectMapper.writeValueAsString(dnmpV2kafkaRecordData(TEST_REQUEST_ID))) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource") + fun shouldRetryOnMtbFileKafkaSendError(testData: TestData) { + val kafkaProperties = KafkaProperties("testtopic") + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() + this.kafkaMtbFileSender = KafkaMtbFileSender(this.kafkaTemplate, kafkaProperties, retryTemplate, this.objectMapper) + + doAnswer { + if (null != testData.exception) { + throw testData.exception + } + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) + + kafkaMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile())) + + val expectedCount = when (testData.exception) { + // OK - No Retry + null -> times(1) + // Request failed - Retry max 3 times + else -> times(3) + } + + verify(kafkaTemplate, expectedCount).send(any>()) } - verify(kafkaTemplate, expectedCount).send(anyString(), anyString(), anyString()) } companion object { val TEST_REQUEST_ID = RequestId("TestId") val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID") - fun mtbFile(consentStatus: Consent.Status): MtbFile { + fun bwhcV1MtbFile(consentStatus: Consent.Status): MtbFile { return if (consentStatus == Consent.Status.ACTIVE) { MtbFile.builder() .withPatient( @@ -215,8 +309,31 @@ class KafkaMtbFileSenderTest { }.build() } - fun kafkaRecordData(requestId: RequestId, consentStatus: Consent.Status): KafkaMtbFileSender.Data { - return KafkaMtbFileSender.Data(requestId, mtbFile(consentStatus)) + fun dnpmV2MtbFile(): Mtb = Mtb.builder() + .withPatient( + dev.pcvolkmer.mv64e.mtb.Patient.builder() + .withId("PID") + .withBirthDate("2000-08-08") + .withGender(CodingGender.builder().withCode(CodingGender.Code.MALE).build()) + .build() + ) + .withEpisodesOfCare( + listOf( + MTBEpisodeOfCare.builder() + .withId("1") + .withPatient(Reference("PID")) + .withPeriod(PeriodDate.builder().withStart("2023-08-08").build()) + .build() + ) + ) + .build() + + fun bwhcV1kafkaRecordData(requestId: RequestId, consentStatus: Consent.Status): MtbRequest { + return BwhcV1MtbFileRequest(requestId, bwhcV1MtbFile(consentStatus)) + } + + fun dnmpV2kafkaRecordData(requestId: RequestId): MtbRequest { + return DnpmV2MtbFileRequest(requestId, dnpmV2MtbFile()) } data class TestData(val requestStatus: RequestStatus, val exception: Throwable? = null) @@ -231,4 +348,4 @@ class KafkaMtbFileSenderTest { } } -} \ No newline at end of file +} diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt index ffbc65c..ead2496 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt @@ -30,16 +30,16 @@ import dev.dnpm.etl.processor.monitoring.RequestStatus import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource +import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus +import org.springframework.http.MediaType import org.springframework.retry.policy.SimpleRetryPolicy import org.springframework.retry.support.RetryTemplateBuilder import org.springframework.test.web.client.ExpectedCount import org.springframework.test.web.client.MockRestServiceServer -import org.springframework.test.web.client.match.MockRestRequestMatchers.method -import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo +import org.springframework.test.web.client.match.MockRestRequestMatchers.* import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus import org.springframework.web.client.RestTemplate @@ -73,7 +73,7 @@ class RestBwhcMtbFileSenderTest { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } - val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) + val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.body).isEqualTo(requestWithResponse.response.body) } @@ -84,11 +84,12 @@ class RestBwhcMtbFileSenderTest { this.mockRestServiceServer .expect(method(HttpMethod.POST)) .andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile")) + .andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) .andRespond { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } - val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile)) + val response = restMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, mtbFile)) assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.body).isEqualTo(requestWithResponse.response.body) } @@ -118,7 +119,7 @@ class RestBwhcMtbFileSenderTest { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } - val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile)) + val response = restMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, mtbFile)) assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.body).isEqualTo(requestWithResponse.response.body) } @@ -148,7 +149,7 @@ class RestBwhcMtbFileSenderTest { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } - val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) + val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.body).isEqualTo(requestWithResponse.response.body) } @@ -309,4 +310,4 @@ class RestBwhcMtbFileSenderTest { } -} \ No newline at end of file +} diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt index 005c0fd..b35fb47 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt @@ -22,6 +22,8 @@ package dev.dnpm.etl.processor.output import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.KotlinModule import de.ukw.ccc.bwhc.dto.* +import de.ukw.ccc.bwhc.dto.Patient +import dev.dnpm.etl.processor.CustomMediaType import dev.dnpm.etl.processor.PatientPseudonym import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.config.AppConfigProperties @@ -29,136 +31,206 @@ import dev.dnpm.etl.processor.config.AppConfiguration import dev.dnpm.etl.processor.config.RestTargetProperties import dev.dnpm.etl.processor.monitoring.ReportService import dev.dnpm.etl.processor.monitoring.RequestStatus -import dev.dnpm.etl.processor.output.RestBwhcMtbFileSenderTest.Companion +import dev.pcvolkmer.mv64e.mtb.* import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource +import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus +import org.springframework.http.MediaType import org.springframework.retry.backoff.NoBackOffPolicy import org.springframework.retry.policy.SimpleRetryPolicy import org.springframework.retry.support.RetryTemplateBuilder import org.springframework.test.web.client.ExpectedCount import org.springframework.test.web.client.MockRestServiceServer -import org.springframework.test.web.client.match.MockRestRequestMatchers.method -import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo +import org.springframework.test.web.client.match.MockRestRequestMatchers.* import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus import org.springframework.web.client.RestTemplate class RestDipMtbFileSenderTest { - private lateinit var mockRestServiceServer: MockRestServiceServer + @Nested + inner class BwhcV1ContentRequest { - private lateinit var restMtbFileSender: RestMtbFileSender + private lateinit var mockRestServiceServer: MockRestServiceServer - private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build())) + private lateinit var restMtbFileSender: RestMtbFileSender - @BeforeEach - fun setup() { - val restTemplate = RestTemplate() - val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false) - val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() + private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build())) - this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) + @BeforeEach + fun setup() { + val restTemplate = RestTemplate() + val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false) + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() - this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) - } + this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) - @ParameterizedTest - @MethodSource("deleteRequestWithResponseSource") - fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) { - this.mockRestServiceServer - .expect(method(HttpMethod.DELETE)) - .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient/${TEST_PATIENT_PSEUDONYM.value}")) - .andRespond { - withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) - } - - val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) - assertThat(response.status).isEqualTo(requestWithResponse.response.status) - assertThat(response.body).isEqualTo(requestWithResponse.response.body) - } - - @ParameterizedTest - @MethodSource("mtbFileRequestWithResponseSource") - fun shouldReturnExpectedResponseForMtbFilePost(requestWithResponse: RequestWithResponse) { - this.mockRestServiceServer - .expect(method(HttpMethod.POST)) - .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient-record")) - .andRespond { - withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) - } - - val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile)) - assertThat(response.status).isEqualTo(requestWithResponse.response.status) - assertThat(response.body).isEqualTo(requestWithResponse.response.body) - } - - @ParameterizedTest - @MethodSource("mtbFileRequestWithResponseSource") - fun shouldRetryOnMtbFileHttpRequestError(requestWithResponse: RequestWithResponse) { - val restTemplate = RestTemplate() - val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false) - val retryTemplate = AppConfiguration().retryTemplate(AppConfigProperties("http://localhost:9000")) - retryTemplate.setBackOffPolicy(NoBackOffPolicy()) - - this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) - this.restMtbFileSender = - RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) - - val expectedCount = when (requestWithResponse.httpStatus) { - // OK - No Retry - HttpStatus.OK, HttpStatus.CREATED, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.BAD_REQUEST -> ExpectedCount.max( - 1 - ) - // Request failed - Retry max 3 times - else -> ExpectedCount.max(3) + this.restMtbFileSender = + RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) } - this.mockRestServiceServer - .expect(expectedCount, method(HttpMethod.POST)) - .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient-record")) - .andRespond { - withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) - } + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#mtbFileRequestWithResponseSource") + fun shouldReturnExpectedResponseForMtbFilePost(requestWithResponse: RequestWithResponse) { + this.mockRestServiceServer + .expect(method(HttpMethod.POST)) + .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient-record")) + .andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + .andRespond { + withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + } - val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile)) - assertThat(response.status).isEqualTo(requestWithResponse.response.status) - assertThat(response.body).isEqualTo(requestWithResponse.response.body) - } - - @ParameterizedTest - @MethodSource("deleteRequestWithResponseSource") - fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) { - val restTemplate = RestTemplate() - val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false) - val retryTemplate = AppConfiguration().retryTemplate(AppConfigProperties("http://localhost:9000")) - retryTemplate.setBackOffPolicy(NoBackOffPolicy()) - - this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) - this.restMtbFileSender = - RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) - - val expectedCount = when (requestWithResponse.httpStatus) { - // OK - No Retry - HttpStatus.OK, HttpStatus.CREATED, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.BAD_REQUEST -> ExpectedCount.max( - 1 - ) - // Request failed - Retry max 3 times - else -> ExpectedCount.max(3) + val response = restMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1mtbFile)) + assertThat(response.status).isEqualTo(requestWithResponse.response.status) + assertThat(response.body).isEqualTo(requestWithResponse.response.body) } - this.mockRestServiceServer - .expect(expectedCount, method(HttpMethod.DELETE)) - .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient/${TEST_PATIENT_PSEUDONYM.value}")) - .andRespond { - withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#mtbFileRequestWithResponseSource") + fun shouldRetryOnMtbFileHttpRequestError(requestWithResponse: RequestWithResponse) { + val restTemplate = RestTemplate() + val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false) + val retryTemplate = AppConfiguration().retryTemplate(AppConfigProperties("http://localhost:9000")) + retryTemplate.setBackOffPolicy(NoBackOffPolicy()) + + this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) + this.restMtbFileSender = + RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) + + val expectedCount = when (requestWithResponse.httpStatus) { + // OK - No Retry + HttpStatus.OK, HttpStatus.CREATED, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.BAD_REQUEST -> ExpectedCount.max( + 1 + ) + // Request failed - Retry max 3 times + else -> ExpectedCount.max(3) } - val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) - assertThat(response.status).isEqualTo(requestWithResponse.response.status) - assertThat(response.body).isEqualTo(requestWithResponse.response.body) + this.mockRestServiceServer + .expect(expectedCount, method(HttpMethod.POST)) + .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient-record")) + .andRespond { + withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + } + + val response = restMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1mtbFile)) + assertThat(response.status).isEqualTo(requestWithResponse.response.status) + assertThat(response.body).isEqualTo(requestWithResponse.response.body) + } + + } + + @Nested + inner class DnpmV2ContentRequest { + + private lateinit var mockRestServiceServer: MockRestServiceServer + + private lateinit var restMtbFileSender: RestMtbFileSender + + private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build())) + + @BeforeEach + fun setup() { + val restTemplate = RestTemplate() + val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false) + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() + + this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) + + this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#mtbFileRequestWithResponseSource") + fun shouldReturnExpectedResponseForDnpmV2MtbFilePost(requestWithResponse: RequestWithResponse) { + this.mockRestServiceServer + .expect(method(HttpMethod.POST)) + .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient-record")) + .andExpect(header(HttpHeaders.CONTENT_TYPE, CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE)) + .andRespond { + withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + } + + val response = restMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile)) + assertThat(response.status).isEqualTo(requestWithResponse.response.status) + assertThat(response.body).isEqualTo(requestWithResponse.response.body) + } + + } + + @Nested + inner class DeleteRequest { + + private lateinit var mockRestServiceServer: MockRestServiceServer + + private lateinit var restMtbFileSender: RestMtbFileSender + + private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build())) + + @BeforeEach + fun setup() { + val restTemplate = RestTemplate() + val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false) + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() + + this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) + + this.restMtbFileSender = + RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#deleteRequestWithResponseSource") + fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) { + this.mockRestServiceServer + .expect(method(HttpMethod.DELETE)) + .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient/${TEST_PATIENT_PSEUDONYM.value}")) + .andRespond { + withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + } + + val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) + assertThat(response.status).isEqualTo(requestWithResponse.response.status) + assertThat(response.body).isEqualTo(requestWithResponse.response.body) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#deleteRequestWithResponseSource") + fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) { + val restTemplate = RestTemplate() + val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false) + val retryTemplate = AppConfiguration().retryTemplate(AppConfigProperties("http://localhost:9000")) + retryTemplate.setBackOffPolicy(NoBackOffPolicy()) + + this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) + this.restMtbFileSender = + RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) + + val expectedCount = when (requestWithResponse.httpStatus) { + // OK - No Retry + HttpStatus.OK, HttpStatus.CREATED, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.BAD_REQUEST -> ExpectedCount.max( + 1 + ) + // Request failed - Retry max 3 times + else -> ExpectedCount.max(3) + } + + this.mockRestServiceServer + .expect(expectedCount, method(HttpMethod.DELETE)) + .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient/${TEST_PATIENT_PSEUDONYM.value}")) + .andRespond { + withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + } + + val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) + assertThat(response.status).isEqualTo(requestWithResponse.response.status) + assertThat(response.body).isEqualTo(requestWithResponse.response.body) + } + } companion object { @@ -171,7 +243,7 @@ class RestDipMtbFileSenderTest { val TEST_REQUEST_ID = RequestId("TestId") val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID") - val mtbFile: MtbFile = MtbFile.builder() + val bwhcV1mtbFile: MtbFile = MtbFile.builder() .withPatient( Patient.builder() .withId("PID") @@ -195,6 +267,25 @@ class RestDipMtbFileSenderTest { ) .build() + val dnpmV2MtbFile: Mtb = Mtb.builder() + .withPatient( + dev.pcvolkmer.mv64e.mtb.Patient.builder() + .withId("PID") + .withBirthDate("2000-08-08") + .withGender(CodingGender.builder().withCode(CodingGender.Code.MALE).build()) + .build() + ) + .withEpisodesOfCare( + listOf( + MTBEpisodeOfCare.builder() + .withId("1") + .withPatient(Reference("PID")) + .withPeriod(PeriodDate.builder().withStart("2023-08-08").build()) + .build() + ) + ) + .build() + private const val ERROR_RESPONSE_BODY = "Sonstiger Fehler bei der Übertragung" /** @@ -311,4 +402,4 @@ class RestDipMtbFileSenderTest { } -} \ No newline at end of file +} diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt index 5578c7b..fe61852 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt @@ -21,14 +21,19 @@ package dev.dnpm.etl.processor.services import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* -import dev.dnpm.etl.processor.* +import dev.dnpm.etl.processor.Fingerprint +import dev.dnpm.etl.processor.PatientId +import dev.dnpm.etl.processor.PatientPseudonym import dev.dnpm.etl.processor.config.AppConfigProperties import dev.dnpm.etl.processor.monitoring.Request import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestType +import dev.dnpm.etl.processor.output.BwhcV1MtbFileRequest +import dev.dnpm.etl.processor.output.DeleteRequest import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.output.RestMtbFileSender import dev.dnpm.etl.processor.pseudonym.PseudonymizeService +import dev.dnpm.etl.processor.randomRequestId import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -109,7 +114,7 @@ class RequestProcessorTest { doAnswer { it.arguments[0] - }.whenever(transformationService).transform(any()) + }.whenever(transformationService).transform(any()) val mtbFile = MtbFile.builder() .withPatient( @@ -168,7 +173,7 @@ class RequestProcessorTest { doAnswer { it.arguments[0] - }.whenever(transformationService).transform(any()) + }.whenever(transformationService).transform(any()) val mtbFile = MtbFile.builder() .withPatient( @@ -223,7 +228,7 @@ class RequestProcessorTest { doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) - }.whenever(sender).send(any()) + }.whenever(sender).send(any()) doAnswer { it.arguments[0] as String @@ -231,7 +236,7 @@ class RequestProcessorTest { doAnswer { it.arguments[0] - }.whenever(transformationService).transform(any()) + }.whenever(transformationService).transform(any()) val mtbFile = MtbFile.builder() .withPatient( @@ -286,7 +291,7 @@ class RequestProcessorTest { doAnswer { MtbFileSender.Response(status = RequestStatus.ERROR) - }.whenever(sender).send(any()) + }.whenever(sender).send(any()) doAnswer { it.arguments[0] as String @@ -294,7 +299,7 @@ class RequestProcessorTest { doAnswer { it.arguments[0] - }.whenever(transformationService).transform(any()) + }.whenever(transformationService).transform(any()) val mtbFile = MtbFile.builder() .withPatient( @@ -336,7 +341,7 @@ class RequestProcessorTest { doAnswer { MtbFileSender.Response(status = RequestStatus.UNKNOWN) - }.whenever(sender).send(any()) + }.whenever(sender).send(any()) this.requestProcessor.processDeletion(TEST_PATIENT_ID) @@ -354,7 +359,7 @@ class RequestProcessorTest { doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) - }.whenever(sender).send(any()) + }.whenever(sender).send(any()) this.requestProcessor.processDeletion(TEST_PATIENT_ID) @@ -372,7 +377,7 @@ class RequestProcessorTest { doAnswer { MtbFileSender.Response(status = RequestStatus.ERROR) - }.whenever(sender).send(any()) + }.whenever(sender).send(any()) this.requestProcessor.processDeletion(TEST_PATIENT_ID) @@ -404,11 +409,11 @@ class RequestProcessorTest { doAnswer { it.arguments[0] - }.whenever(transformationService).transform(any()) + }.whenever(transformationService).transform(any()) doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) - }.whenever(sender).send(any()) + }.whenever(sender).send(any()) val mtbFile = MtbFile.builder() .withPatient( @@ -446,4 +451,4 @@ class RequestProcessorTest { val TEST_PATIENT_ID = PatientId("TEST_12345678901") } -} \ No newline at end of file +} From b939b2bf5730f13ac794a41bda6c150067817ef5 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sat, 26 Apr 2025 11:18:40 +0200 Subject: [PATCH 54/54] chore: update Spring Boot (#111) --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index c093fdb..c62029c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage plugins { war - id("org.springframework.boot") version "3.4.4" + id("org.springframework.boot") version "3.4.5" id("io.spring.dependency-management") version "1.1.7" kotlin("jvm") version "1.9.25" kotlin("plugin.spring") version "1.9.25"