diff options
| author | Paul-Christian Volkmer | 2026-09-21 11:43:03 +0200 |
|---|---|---|
| committer | GitHub | 2026-09-21 11:43:03 +0200 |
| commit | d9961a9baccaff511662b1aa1de18bdd02f94f5e (patch) | |
| tree | b4d47b8eed6aaceebaead375e8265e5f245bff82 /src/main/kotlin | |
| parent | b0fb01d12fcadf86cabbe71b16cce60fb88b0ca4 (diff) | |
feat: add switch/routing to send dedicated data to nNGM (#322)
Diffstat (limited to 'src/main/kotlin')
12 files changed, 513 insertions, 78 deletions
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 01bab32..fb93618 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt @@ -111,12 +111,36 @@ data class RestTargetProperties( val uri: String?, val username: String?, val password: String?, + val apiKey: String? = null, + val type: RestTargetType = RestTargetType.DIP, ) { companion object { const val NAME = "app.rest" } } +enum class RestTargetType { + DIP, + NNGM, +} + +@ConfigurationProperties(RoutingProperties.NAME) +data class RoutingProperties( + val nngm: SwitchTargetProperties? = null, +) { + companion object { + const val NAME = "app.routing" + } +} + +data class SwitchTargetProperties( + val uri: String, + val username: String?, + val password: String?, + val apiKey: String?, + val icd10: List<String> = listOf(), +) + @ConfigurationProperties(KafkaProperties.NAME) data class KafkaProperties( val inputTopic: String?, 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 d10a32f..70cfca9 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt @@ -24,10 +24,11 @@ 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.RestDipMtbFileSender +import dev.dnpm.etl.processor.output.RestMtbFileSender +import dev.dnpm.etl.processor.output.RestNngmMtbFileSender import org.slf4j.LoggerFactory -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean @@ -40,7 +41,6 @@ import reactor.core.publisher.Sinks @Configuration @EnableConfigurationProperties(value = [RestTargetProperties::class]) @ConditionalOnProperty(value = ["app.rest.uri"]) -@ConditionalOnMissingBean(MtbFileSender::class) @Order(-10) class AppRestConfiguration { private val logger = LoggerFactory.getLogger(AppRestConfiguration::class.java) @@ -51,12 +51,20 @@ class AppRestConfiguration { restTargetProperties: RestTargetProperties, retryTemplate: RetryTemplate, reportService: ReportService, - ): MtbFileSender { - logger.info("Selected 'RestDipMtbFileSender'") - return RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) - } + ): RestMtbFileSender = + when (restTargetProperties.type) { + RestTargetType.DIP -> { + logger.info("Selected 'RestDipMtbFileSender'") + RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) + } + RestTargetType.NNGM -> { + logger.info("Selected 'RestNngmMtbFileSender'") + RestNngmMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) + } + } @Bean + @ConditionalOnBean(RestDipMtbFileSender::class) fun restConnectionCheckService( restTemplate: RestTemplate, restTargetProperties: RestTargetProperties, diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppRoutedRestConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppRoutedRestConfiguration.kt new file mode 100644 index 0000000..f962a6c --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppRoutedRestConfiguration.kt @@ -0,0 +1,52 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken + * Copyright (c) 2025-2026 Paul-Christian Volkmer, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package dev.dnpm.etl.processor.config + +import dev.dnpm.etl.processor.monitoring.ReportService +import dev.dnpm.etl.processor.output.RoutedMtbFileSender +import dev.dnpm.etl.processor.output.RoutedRestNngmMtbFileSender +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.annotation.Order +import org.springframework.retry.support.RetryTemplate +import org.springframework.web.client.RestTemplate + +@Configuration +@EnableConfigurationProperties(value = [RoutingProperties::class]) +@Order(-10) +class AppRoutedRestConfiguration { + private val logger = LoggerFactory.getLogger(AppRoutedRestConfiguration::class.java) + + @Bean + @ConditionalOnProperty(prefix = "app.routing", name = ["nngm.uri"]) + fun switchRestMtbFileSender( + restTemplate: RestTemplate, + routingProperties: RoutingProperties, + retryTemplate: RetryTemplate, + reportService: ReportService, + ): RoutedMtbFileSender { + logger.info("Added switched 'RestNngmMtbFileSender' ... ") + return RoutedRestNngmMtbFileSender(restTemplate, routingProperties, retryTemplate, reportService) + } +} diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Destination.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Destination.kt new file mode 100644 index 0000000..e3d1915 --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Destination.kt @@ -0,0 +1,30 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken + * Copyright (c) 2026 Paul-Christian Volkmer, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package dev.dnpm.etl.processor.monitoring + +enum class Destination( + val value: String, +) { + UNKNOWN("unknown"), + KAFKA("kafka"), + DNPM_DIP("dnpm_dip"), + NNGM_API("nngm_api"), +} 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 b9359fc..96cdb5c 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt @@ -35,19 +35,23 @@ class ReportService(private val jsonMapper: JsonMapper) { return listOf() } return try { - jsonMapper.readValue(dataQualityReport, DataQualityReport::class.java).issues.sortedBy { + jsonMapper.readValue(dataQualityReport, DipDataQualityReport::class.java).issues.sortedBy { it.severity } } catch (_: JacksonException) { - val otherIssue = - Issue(Severity.ERROR, Optional.of("Not parsable data quality report '$dataQualityReport'")) - return listOf(otherIssue) + return try { + jsonMapper.readValue(dataQualityReport, NngmDataQualityReport::class.java).getIssues() + } catch (_: JacksonException) { + val otherIssue = + Issue(Severity.ERROR, Optional.of("Not parsable data quality report '$dataQualityReport'")) + return listOf(otherIssue) + } } catch (e: Exception) { throw e } } - private data class DataQualityReport( + private data class DipDataQualityReport( val issues: List<Issue> ) @@ -57,7 +61,7 @@ class ReportService(private val jsonMapper: JsonMapper) { val details: Optional<String> = Optional.empty(), val path: Optional<String> = Optional.empty(), ) { - fun getMessage() = message.orElse(details.orElse("No details available")) + fun getMessageText() = message.orElse(details.orElse("No details available")) } @EnumNaming(EnumNamingStrategies.LowerCaseStrategy::class) @@ -67,6 +71,22 @@ class ReportService(private val jsonMapper: JsonMapper) { WARNING("warning"), INFO("info"), } + + private data class NngmDataQualityReport( + val qualityChecksReport: QualityChecksReport, + ) { + fun getIssues(): List<Issue> { + val severity = if (this.qualityChecksReport.importAccepted) Severity.INFO else Severity.ERROR + return this.qualityChecksReport.qualityChecks + .split('|') + .map { Issue(severity, Optional.of(it)) } + } + } + + private data class QualityChecksReport( + val importAccepted: Boolean, + val qualityChecks: String + ) } fun List<Issue>.asRequestStatus(): RequestStatus { @@ -74,6 +94,7 @@ fun List<Issue>.asRequestStatus(): RequestStatus { return when (severity) { Severity.FATAL, Severity.ERROR -> RequestStatus.ERROR + Severity.WARNING -> RequestStatus.WARNING else -> RequestStatus.SUCCESS } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt index d62a503..290df76 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt @@ -55,6 +55,7 @@ data class Request( @Column("submission_accepted") var submissionAccepted: Boolean = false, @Column("followup_count") var followupCount: Int = 0, @Column("expected_followup_count") var expectedFollowupCount: Int = 0, + @Column("destination") var destination: Destination = Destination.UNKNOWN, ) { constructor( uuid: RequestId, 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 04e673a..1bcca5e 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt @@ -39,14 +39,12 @@ import org.springframework.web.client.exchange import org.springframework.web.util.UriComponentsBuilder class RestDipMtbFileSender( - private val restTemplate: RestTemplate, + restTemplate: RestTemplate, private val restTargetProperties: RestTargetProperties, - private val retryTemplate: RetryTemplate, - private val reportService: ReportService, -) : MtbFileSender { - private val logger = LoggerFactory.getLogger(RestDipMtbFileSender::class.java) - - fun sendUrl(): String = + retryTemplate: RetryTemplate, + reportService: ReportService, +) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) { + override fun sendUrl(): String = UriComponentsBuilder .fromUriString(restTargetProperties.uri.toString()) .pathSegment("mtb") @@ -54,7 +52,7 @@ class RestDipMtbFileSender( .pathSegment("patient-record") .toUriString() - fun deleteUrl(patientId: PatientPseudonym): String = + override fun deleteUrl(patientId: PatientPseudonym): String = UriComponentsBuilder .fromUriString(restTargetProperties.uri.toString()) .pathSegment("mtb") @@ -63,60 +61,9 @@ class RestDipMtbFileSender( .pathSegment(patientId.value) .toUriString() - override fun <T> send(request: MtbFileRequest<T>): MtbFileSender.Response { - try { - return retryTemplate.execute<MtbFileSender.Response, Exception> { - val headers = getHttpHeaders(request) - val entityReq = HttpEntity(request.content, headers) - val response = - restTemplate.exchange<String>(sendUrl(), HttpMethod.POST, entityReq) - if (!response.statusCode.is2xxSuccessful) { - logger.warn("Error sending to remote system: {}", response.body) - return@execute MtbFileSender.Response( - reportService.deserialize(response.body).asRequestStatus(), - "Status-Code: ${response.statusCode.value()}", - ) - } - logger.debug("Sent file via RestMtbFileSender") - 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( - reportService.deserialize(e.responseBodyAsString).asRequestStatus(), - e.responseBodyAsString, - ) - } - return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung") - } - - override fun send(request: DeleteRequest): MtbFileSender.Response { - try { - return retryTemplate.execute<MtbFileSender.Response, Exception> { - val headers = getHttpHeaders(request) - val entityReq = HttpEntity(null, headers) - restTemplate.delete(deleteUrl(request.patientId), entityReq, String::class.java) - logger.debug("Sent file via RestMtbFileSender") - return@execute MtbFileSender.Response(RequestStatus.SUCCESS) - } - } catch (e: IllegalArgumentException) { - logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!) - } catch (e: RestClientException) { - logger.info(restTargetProperties.uri!!.toString()) - logger.error("Cannot send data to remote system", e) - } - return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung") - } - override fun endpoint(): String = this.restTargetProperties.uri.orEmpty() - private fun getHttpHeaders(request: MtbRequest): HttpHeaders { + override fun getHttpHeaders(request: MtbRequest): HttpHeaders { val username = restTargetProperties.username val password = restTargetProperties.password val headers = HttpHeaders() diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt new file mode 100644 index 0000000..c88f606 --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt @@ -0,0 +1,107 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken + * Copyright (c) 2025-2026 Paul-Christian Volkmer, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package dev.dnpm.etl.processor.output + +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 +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +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 +import org.springframework.web.client.exchange +import org.springframework.web.util.UriComponentsBuilder + +abstract class RestMtbFileSender( + private val restTemplate: RestTemplate, + private val restTargetProperties: RestTargetProperties, + private val retryTemplate: RetryTemplate, + private val reportService: ReportService, +) : MtbFileSender { + private val logger = LoggerFactory.getLogger(RestMtbFileSender::class.java) + + abstract fun sendUrl(): String + + abstract fun deleteUrl(patientId: PatientPseudonym): String + + override fun <T> send(request: MtbFileRequest<T>): MtbFileSender.Response { + try { + return retryTemplate.execute<MtbFileSender.Response, Exception> { + val headers = getHttpHeaders(request) + val entityReq = HttpEntity(request.content, headers) + val response = + restTemplate.exchange<String>(sendUrl(), HttpMethod.POST, entityReq) + if (!response.statusCode.is2xxSuccessful) { + logger.warn("Error sending to remote system: {}", response.body) + return@execute MtbFileSender.Response( + reportService.deserialize(response.body).asRequestStatus(), + "Status-Code: ${response.statusCode.value()}", + ) + } + logger.debug("Sent file via ${this::class.java.simpleName}") + return@execute MtbFileSender.Response( + reportService.deserialize(response.body).asRequestStatus(), + response.body.orEmpty(), + ) + } + } catch (_: 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( + RequestStatus.ERROR, + e.responseBodyAsString, + ) + } + return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung") + } + + override fun send(request: DeleteRequest): MtbFileSender.Response { + try { + return retryTemplate.execute<MtbFileSender.Response, Exception> { + val headers = getHttpHeaders(request) + val entityReq = HttpEntity(null, headers) + restTemplate.delete(deleteUrl(request.patientId), entityReq, String::class.java) + logger.debug("Sent file via RestDipMtbFileSender") + return@execute MtbFileSender.Response(RequestStatus.SUCCESS) + } + } catch (_: IllegalArgumentException) { + logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!) + } catch (e: RestClientException) { + logger.info(restTargetProperties.uri!!.toString()) + logger.error("Cannot send data to remote system", e) + } + return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung") + } + + override fun endpoint(): String = this.restTargetProperties.uri.orEmpty() + + protected abstract fun getHttpHeaders(request: MtbRequest): HttpHeaders +} diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestNngmMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestNngmMtbFileSender.kt new file mode 100644 index 0000000..13722d2 --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestNngmMtbFileSender.kt @@ -0,0 +1,59 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken + * Copyright (c) 2025-2026 Paul-Christian Volkmer, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package dev.dnpm.etl.processor.output + +import dev.dnpm.etl.processor.PatientPseudonym +import dev.dnpm.etl.processor.config.RestTargetProperties +import dev.dnpm.etl.processor.monitoring.ReportService +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.retry.support.RetryTemplate +import org.springframework.web.client.RestTemplate +import org.springframework.web.util.UriComponentsBuilder + +class RestNngmMtbFileSender( + restTemplate: RestTemplate, + private val restTargetProperties: RestTargetProperties, + retryTemplate: RetryTemplate, + reportService: ReportService, +) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) { + override fun sendUrl(): String = + UriComponentsBuilder + .fromUriString(restTargetProperties.uri.toString()) + .toUriString() + + override fun deleteUrl(patientId: PatientPseudonym): String = throw UnsupportedOperationException("Not implemented") + + override fun endpoint(): String = this.restTargetProperties.uri.orEmpty() + + override fun getHttpHeaders(request: MtbRequest): HttpHeaders { + val apiKey = restTargetProperties.apiKey + val headers = HttpHeaders() + headers.contentType = MediaType.APPLICATION_JSON + + if (apiKey.isNullOrBlank()) { + return headers + } + + headers.set(HttpHeaders.AUTHORIZATION, "X-API-KEY $apiKey") + return headers + } +} diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RoutedMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RoutedMtbFileSender.kt new file mode 100644 index 0000000..1eab675 --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RoutedMtbFileSender.kt @@ -0,0 +1,31 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken + * Copyright (c) 2023-2026 Paul-Christian Volkmer, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package dev.dnpm.etl.processor.output + +import dev.dnpm.etl.processor.output.MtbFileSender.Response +import dev.pcvolkmer.mv64e.model.MtbDiagnosis +import dev.pcvolkmer.mv64e.model.PatientRecord + +interface RoutedMtbFileSender { + fun supportsDiagnosis(diagnosis: MtbDiagnosis): Boolean + + fun send(request: MtbFileRequest<PatientRecord>): Response +} diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RoutedRestNngmMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RoutedRestNngmMtbFileSender.kt new file mode 100644 index 0000000..957645e --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RoutedRestNngmMtbFileSender.kt @@ -0,0 +1,117 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken + * Copyright (c) 2025-2026 Paul-Christian Volkmer, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package dev.dnpm.etl.processor.output + +import dev.dnpm.etl.processor.config.RoutingProperties +import dev.dnpm.etl.processor.monitoring.ReportService +import dev.dnpm.etl.processor.monitoring.RequestStatus +import dev.dnpm.etl.processor.monitoring.asRequestStatus +import dev.pcvolkmer.mv64e.model.MtbDiagnosis +import dev.pcvolkmer.mv64e.model.PatientRecord +import org.slf4j.LoggerFactory +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.retry.support.RetryTemplate +import org.springframework.web.client.RestClientResponseException +import org.springframework.web.client.RestTemplate +import org.springframework.web.client.exchange +import org.springframework.web.util.UriComponentsBuilder + +class RoutedRestNngmMtbFileSender( + private val restTemplate: RestTemplate, + private val routingProperties: RoutingProperties, + private val retryTemplate: RetryTemplate, + private val reportService: ReportService, +) : RoutedMtbFileSender { + private val logger = LoggerFactory.getLogger(RoutedRestNngmMtbFileSender::class.java) + + fun sendUrl(): String = + UriComponentsBuilder + .fromUriString(routingProperties.nngm?.uri.toString()) + .toUriString() + + override fun supportsDiagnosis(diagnosis: MtbDiagnosis): Boolean { + val code = diagnosis.code?.code ?: return false + + if (code.isBlank()) { + return false + } + + this.routingProperties.nngm?.icd10?.forEach { + if (code.startsWith(it)) { + return true + } + } + return false + } + + override fun send(request: MtbFileRequest<PatientRecord>): MtbFileSender.Response { + try { + return retryTemplate.execute<MtbFileSender.Response, Exception> { + val entityReq = HttpEntity(request.content, getHttpHeaders()) + val response = + restTemplate.exchange<String>(sendUrl(), HttpMethod.POST, entityReq) + if (!response.statusCode.is2xxSuccessful) { + logger.warn("Error sending to remote system: {}", response.body) + return@execute MtbFileSender.Response( + reportService.deserialize(response.body).asRequestStatus(), + "Status-Code: ${response.statusCode.value()}", + ) + } + logger.debug("Sent file via RoutedRestNngmMtbFileSender") + return@execute MtbFileSender.Response( + reportService.deserialize(response.body).asRequestStatus(), + response.body.orEmpty(), + ) + } + } catch (_: IllegalArgumentException) { + logger.error("Not a valid URI to export to: '{}'", routingProperties.nngm?.uri!!) + } catch (e: RestClientResponseException) { + logger.info(routingProperties.nngm?.uri!!) + logger.error("Request data not accepted by remote system", e) + return MtbFileSender.Response( + reportService.deserialize(e.responseBodyAsString).asRequestStatus(), + e.responseBodyAsString, + ) + } + return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung") + } + + fun endpoint(): String = + this.routingProperties.nngm + ?.uri + .orEmpty() + + private fun getHttpHeaders(): HttpHeaders { + val apiKey = routingProperties.nngm?.apiKey + val headers = HttpHeaders() + headers.contentType = MediaType.APPLICATION_JSON + + if (apiKey.isNullOrBlank()) { + return headers + } + + headers.set(HttpHeaders.AUTHORIZATION, "X-API-KEY $apiKey") + return headers + } +} 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 320f828..6ce9d14 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt @@ -24,10 +24,7 @@ import dev.dnpm.etl.processor.* import dev.dnpm.etl.processor.config.AppConfigProperties import dev.dnpm.etl.processor.consent.TtpConsentStatus import dev.dnpm.etl.processor.monitoring.* -import dev.dnpm.etl.processor.output.DeleteRequest -import dev.dnpm.etl.processor.output.DnpmV2MtbFileRequest -import dev.dnpm.etl.processor.output.MtbFileRequest -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.addGenomDeTan import dev.dnpm.etl.processor.pseudonym.anonymizeContentWith @@ -49,6 +46,7 @@ class RequestProcessor( private val pseudonymizeService: PseudonymizeService, private val transformationService: TransformationService, private val sender: MtbFileSender, + private val switchedSenders: List<RoutedMtbFileSender>, private val requestService: RequestService, private val jsonMapper: JsonMapper, private val applicationEventPublisher: ApplicationEventPublisher, @@ -111,6 +109,18 @@ class RequestProcessor( } } + var destination = when (sender) { + is RestDipMtbFileSender -> Destination.DNPM_DIP + is RestNngmMtbFileSender -> Destination.NNGM_API + is KafkaMtbFileSender -> Destination.KAFKA + else -> Destination.UNKNOWN + } + + val routedSender = selectRoutedSender(request) + if (routedSender.isPresent && routedSender.get() is RoutedRestNngmMtbFileSender) { + destination = Destination.NNGM_API + } + val maxFollowUpCount = this.requestService.allRequestsByPatientPseudonym(request.patientPseudonym()) .maxByOrNull { it.followupCount } ?.followupCount ?: -1 @@ -132,6 +142,7 @@ class RequestProcessor( tan = Tan(request.content.metadata?.transferTAN.orEmpty()), followupCount = maxFollowUpCount, expectedFollowupCount = request.content.followUps?.size ?: 0, + destination = destination, ) ) // Exit - no further processing @@ -172,6 +183,7 @@ class RequestProcessor( tan = Tan(request.content.metadata?.transferTAN.orEmpty()), followupCount = maxFollowUpCount, expectedFollowupCount = request.content.followUps?.size ?: 0, + destination = destination, ) ) @@ -182,7 +194,11 @@ class RequestProcessor( return } - val responseStatus = sender.send(request) + val responseStatus = if (!routedSender.isEmpty) { + routedSender.get().send(request) + } else { + sender.send(request) + } applicationEventPublisher.publishEvent( ResponseEvent( @@ -199,6 +215,28 @@ class RequestProcessor( ) } + /* + * Selects MtbFileSender based on latest diagnosis ICD10 code + */ + private fun selectRoutedSender(request: MtbFileRequest<PatientRecord>): Optional<RoutedMtbFileSender> { + + when (request) { + is DnpmV2MtbFileRequest -> { + val latestDiagnosis = request.content.diagnoses.maxBy { it.recordedOn } ?: return Optional.empty() + + this.switchedSenders.forEach { sender -> + if (sender.supportsDiagnosis(latestDiagnosis)) { + return Optional.of(sender) + } + } + + } + } + + // Default + return Optional.empty() + } + private fun hasFollowUpAfterLastSuccessfulSubmission(request: DnpmV2MtbFileRequest): Boolean { val lastSuccessfulSubmission = this.requestService.allRequestsByPatientPseudonym(request.patientPseudonym()) .sortedBy { it.processedAt } |
