1
0
mirror of https://github.com/pcvolkmer/etl-processor.git synced 2025-07-17 21:02:54 +00:00

1 Commits

Author SHA1 Message Date
ec096d9c81 chore: bump version 2025-04-04 14:57:28 +02:00
40 changed files with 878 additions and 4767 deletions

261
README.md
View File

@ -1,48 +1,30 @@
# 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) # 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 im bwHC-Datenmodell 1.0 an DNPM:DIP und pseudonymisiert Diese Anwendung versendet ein bwHC-MTB-File im bwHC-Datenmodell 1.0 an DNPM:DIP und pseudonymisiert die Patienten-ID.
die Patienten-ID.
## Einordnung innerhalb einer DNPM-ETL-Strecke ## Einordnung innerhalb einer DNPM-ETL-Strecke
Diese Anwendung erlaubt das Entgegennehmen von HTTP/REST-Anfragen aus dem Onkostar-Plugin * Diese Anwendung erlaubt das Entgegennehmen von HTTP/REST-Anfragen aus dem Onkostar-Plugin **[onkostar-plugin-dnpmexport](https://github.com/CCC-MF/onkostar-plugin-dnpmexport)**.
*[onkostar-plugin-dnpmexport](https://github.com/CCC-MF/onkostar-plugin-dnpmexport)**.
Der Inhalt einer Anfrage, wenn ein bwHC-MTBFile, wird pseudonymisiert und auf Duplikate geprüft. Der Inhalt einer Anfrage, wenn ein bwHC-MTBFile, wird pseudonymisiert und auf Duplikate geprüft.
Duplikate werden verworfen, Änderungen werden weitergeleitet. Duplikate werden verworfen, Änderungen werden weitergeleitet.
Löschanfragen werden immer als Löschanfrage an DNPM:DIP 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 Zudem ist eine minimalistische Weboberfläche integriert, die einen Einblick in den aktuellen Zustand der Anwendung gewährt.
der Anwendung gewährt.
![Modell DNPM-ETL-Strecke](docs/etl.png) ![Modell DNPM-ETL-Strecke](docs/etl.png)
### Duplikaterkennung ### Duplikaterkennung
Die Erkennung von Duplikaten ist normalerweise immer aktiv, kann jedoch über den Die Erkennung von Duplikaten ist normalerweise immer aktiv, kann jedoch über den Konfigurationsparameter
Konfigurationsparameter
`APP_DUPLICATION_DETECTION=false` deaktiviert werden. `APP_DUPLICATION_DETECTION=false` deaktiviert werden.
### Modelvorhaben genomDE §64e
Zusätzlich zur Patienten Identifier Pseudonymisierung müssen Vorgangsummern generiert werden, die
jede Übertragung eindeutig identifizieren aber gleichzeitig dem Patienten zugeordnet werden können.
Dies lässt sich durch weitere Pseudonyme abbilden, allerdings werden pro Originalwert mehrere
Pseudonyme benötigt.
Zu diesem Zweck muss in gPas eine **Multi-Pseudonym-Domäne** konfiguriert werden (siehe auch
*APP_PSEUDONYMIZE_GPAS_CCDN*).
**WICHTIG:** Deaktivierte Pseudonymisierung ist nur für Tests nutzbar. Vorgangsummern sind zufällig
und werden anschließend verworfen.
### Datenübermittlung über HTTP/REST ### Datenübermittlung über HTTP/REST
Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung direkt an DNPM:DIP Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung direkt an DNPM:DIP gesendet.
gesendet.
Ein HTTP Request kann, angenommen die Installation erfolgte auf dem Host `dnpm.example.com` an Ein HTTP Request kann, angenommen die Installation erfolgte auf dem Host `dnpm.example.com` an nachfolgende URLs gesendet werden:
nachfolgende URLs gesendet werden:
| HTTP-Request | URL | Consent-Status im Datensatz | Bemerkung | | HTTP-Request | URL | Consent-Status im Datensatz | Bemerkung |
|--------------|-----------------------------------------|-----------------------------|---------------------------------------------------------------------------------| |--------------|-----------------------------------------|-----------------------------|---------------------------------------------------------------------------------|
@ -50,15 +32,12 @@ nachfolgende URLs gesendet werden:
| `POST` | `https://dnpm.example.com/mtb` | `REJECT` | Die Anwendung sendet einen Lösch-Request für die im Datensatz angegebene Pat-ID | | `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` | | `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 Anstelle des Pfads `/mtb` kann auch, wie in Version 0.9 und älter üblich, `/mtbfile` verwendet werden.
werden.
### Datenübermittlung mit Apache Kafka ### Datenübermittlung mit Apache Kafka
Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung an Apache Kafka Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung an Apache Kafka übergeben.
übergeben. Eine Antwort wird dabei ebenfalls mithilfe von Apache Kafka übermittelt und nach der Entgegennahme verarbeitet.
Eine Antwort wird dabei ebenfalls mithilfe von Apache Kafka übermittelt und nach der Entgegennahme
verarbeitet.
Siehe hierzu auch: https://github.com/CCC-MF/kafka-to-bwhc Siehe hierzu auch: https://github.com/CCC-MF/kafka-to-bwhc
@ -66,19 +45,17 @@ Siehe hierzu auch: https://github.com/CCC-MF/kafka-to-bwhc
### 🔥 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 Ab Version 0.10 wird [DNPM:DIP](https://github.com/dnpm-dip) unterstützt und als Standardendpunkt verwendet.
verwendet. Soll noch das alte bwHC-Backend verwendet werden, so ist die Umgebungsvariable `APP_REST_IS_BWHC` auf `true` zu setzen.
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 In Versionen des ETL-Processors **nach Version 0.10** werden die folgenden Konfigurationsoptionen entfernt:
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_TOPIC`: Nutzen Sie nun die Konfigurationsoption `APP_KAFKA_OUTPUT_TOPIC`
* `APP_KAFKA_RESPONSE_TOPIC`: Nutzen Sie nun die Konfigurationsoption * `APP_KAFKA_RESPONSE_TOPIC`: Nutzen Sie nun die Konfigurationsoption `APP_KAFKA_OUTPUT_RESPONSE_TOPIC`
`APP_KAFKA_OUTPUT_RESPONSE_TOPIC`
Der Pfad zum Versenden von MTB-Daten ist nun offiziell `/mtb`. 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. In Versionen **nach Version 0.10** wird die Unterstützung des Pfads `/mtbfile` entfernt.
@ -91,37 +68,39 @@ Ist diese nicht gesetzt. wird intern eine Anonymisierung der Patienten-ID vorgen
* `APP_PSEUDONYMIZE_PREFIX`: Standortbezogenes Präfix - `UNKNOWN`, wenn nicht gesetzt * `APP_PSEUDONYMIZE_PREFIX`: Standortbezogenes Präfix - `UNKNOWN`, wenn nicht gesetzt
* `APP_PSEUDONYMIZE_GENERATOR`: `BUILDIN` oder `GPAS` - `BUILDIN`, wenn nicht gesetzt * `APP_PSEUDONYMIZE_GENERATOR`: `BUILDIN` oder `GPAS` - `BUILDIN`, wenn nicht gesetzt
**Hinweis** **Hinweise**:
Die Pseudonymisierung erfolgt im ETL-Prozessor nur für die Patienten-ID. * Der alte Konfigurationsparameter `APP_PSEUDONYMIZER` mit den Werten `GPAS` oder `BUILDIN` sollte nicht mehr verwendet
Andere IDs werden mithilfe des standortbezogenen Präfixes (erneut) anonymisiert, um für den werden.
aktuellen Kontext nicht * Die Pseudonymisierung erfolgt im ETL-Prozessor nur für die Patienten-ID.
vergleichbare IDs bereitzustellen. Andere IDs werden mithilfe des standortbezogenen Präfixes (erneut) anonymisiert, um für den aktuellen Kontext nicht
vergleichbare IDs bereitzustellen.
#### Eingebaute Anonymisierung #### Eingebaute Anonymisierung
Wurde keine oder die Verwendung der eingebauten Anonymisierung konfiguriert, so wird für die Wurde keine oder die Verwendung der eingebauten Anonymisierung konfiguriert, so wird für die Patienten-ID der
Patienten-ID der entsprechende SHA-256-Hash gebildet und Base64-codiert - hier ohne endende "=" - zuzüglich des konfigurierten Präfixes
entsprechende SHA-256-Hash gebildet und Base64-codiert - hier ohne endende "=" - zuzüglich des
konfigurierten Präfixes
als Patienten-Pseudonym verwendet. als Patienten-Pseudonym verwendet.
#### Pseudonymisierung mit gPAS #### Pseudonymisierung mit gPAS
Wurde die Verwendung von gPAS konfiguriert, so sind weitere Angaben zu konfigurieren. Wurde die Verwendung von gPAS konfiguriert, so sind weitere Angaben zu konfigurieren.
* `APP_PSEUDONYMIZE_GPAS_URI`: URI der gPAS-Instanz inklusive Endpoint (z.B. * `APP_PSEUDONYMIZE_GPAS_URI`: URI der gPAS-Instanz inklusive Endpoint (z.B. `http://localhost:8080/ttp-fhir/fhir/gpas/$$pseudonymizeAllowCreate`)
`http://localhost:8080/ttp-fhir/fhir/gpas/$$pseudonymizeAllowCreate`) * `APP_PSEUDONYMIZE_GPAS_TARGET`: gPas Domänenname
* `APP_PSEUDONYMIZE_GPAS_TARGET`: gPas Domänenname für Patienten ID
* `APP_PSEUDONYMIZE_GPAS_USERNAME`: gPas Basic-Auth Benutzername * `APP_PSEUDONYMIZE_GPAS_USERNAME`: gPas Basic-Auth Benutzername
* `APP_PSEUDONYMIZE_GPAS_PASSWORD`: gPas Basic-Auth Passwort * `APP_PSEUDONYMIZE_GPAS_PASSWORD`: gPas Basic-Auth Passwort
* `APP_PSEUDONYMIZE_GPAS_GENOM_DE_DOMAIN`: gPas Multi-Pseudonym-Domäne für genomDE Vorgangsnummern ( * ~~`APP_PSEUDONYMIZE_GPAS_SSLCALOCATION`~~: **Veraltet** - Root Zertifikat für gPas, falls es dediziert hinzugefügt werden muss.
Clinical data node) **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 ### Anmeldung mit einem Passwort
Ein initialer Administrator-Account kann optional konfiguriert werden und sorgt dafür, dass Ein initialer Administrator-Account kann optional konfiguriert werden und sorgt dafür, dass bestimmte Bereiche nur nach
bestimmte Bereiche nur nach
einem erfolgreichen Login erreichbar sind. einem erfolgreichen Login erreichbar sind.
* `APP_SECURITY_ADMIN_USER`: Muss angegeben werden zur Aktivierung der Zugriffsbeschränkung. * `APP_SECURITY_ADMIN_USER`: Muss angegeben werden zur Aktivierung der Zugriffsbeschränkung.
@ -135,34 +114,27 @@ Hier Beispiele für das Beispielpasswort `very-secret`:
* `{bcrypt}$2y$05$CCkfsMr/wbTleMyjVIK8g.Aa3RCvrvoLXVAsL.f6KeouS88vXD9b6` * `{bcrypt}$2y$05$CCkfsMr/wbTleMyjVIK8g.Aa3RCvrvoLXVAsL.f6KeouS88vXD9b6`
* `{sha256}9a34717f0646b5e9cfcba70055de62edb026ff4f68671ba3db96aa29297d2df5f1a037d58c745657` * `{sha256}9a34717f0646b5e9cfcba70055de62edb026ff4f68671ba3db96aa29297d2df5f1a037d58c745657`
Wird kein Administrator-Passwort angegeben, wird ein zufälliger Wert generiert und beim Start der Wird kein Administrator-Passwort angegeben, wird ein zufälliger Wert generiert und beim Start der Anwendung in den Logs
Anwendung in den Logs
angezeigt. angezeigt.
#### Weitere (nicht administrative) Nutzer mit OpenID Connect #### Weitere (nicht administrative) Nutzer mit OpenID Connect
Die folgenden Konfigurationsparameter werden benötigt, um die Authentifizierung weiterer Benutzer an Die folgenden Konfigurationsparameter werden benötigt, um die Authentifizierung weiterer Benutzer an einen OIDC-Provider
einen OIDC-Provider
zu delegieren. zu delegieren.
Ein Admin-Benutzer muss dabei konfiguriert sein. Ein Admin-Benutzer muss dabei konfiguriert sein.
* `APP_SECURITY_ENABLE_OIDC`: Aktiviert die Nutzung von OpenID Connect. Damit sind weitere Parameter * `APP_SECURITY_ENABLE_OIDC`: Aktiviert die Nutzung von OpenID Connect. Damit sind weitere Parameter erforderlich
erforderlich * `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_CUSTOM_CLIENT_NAME`: Name. Wird beim zusätzlichen Loginbutton angezeigt.
* `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_CUSTOM_CLIENT_NAME`: Name. Wird beim zusätzlichen
Loginbutton angezeigt.
* `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_CUSTOM_CLIENT_ID`: Client-ID * `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_CUSTOM_CLIENT_ID`: Client-ID
* `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_CUSTOM_CLIENT_SECRET`: Client-Secret * `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_CUSTOM_CLIENT_SECRET`: Client-Secret
* `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_CUSTOM_CLIENT_SCOPE[0]`: Hier sollte immer `openid` * `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_CUSTOM_CLIENT_SCOPE[0]`: Hier sollte immer `openid` angegeben werden.
angegeben werden.
* `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_CUSTOM_ISSUER_URI`: Die URI des Providers, * `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_CUSTOM_ISSUER_URI`: Die URI des Providers,
z.B. `https://auth.example.com/realm/example` z.B. `https://auth.example.com/realm/example`
* `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_CUSTOM_USER_NAME_ATTRIBUTE`: Name des Attributes, welches * `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_CUSTOM_USER_NAME_ATTRIBUTE`: Name des Attributes, welches den Benutzernamen
den Benutzernamen
enthält. enthält.
Oft verwendet: `preferred_username` Oft verwendet: `preferred_username`
Ist die Nutzung von OpenID Connect konfiguriert, erscheint ein zusätzlicher Login-Button zur Nutzung Ist die Nutzung von OpenID Connect konfiguriert, erscheint ein zusätzlicher Login-Button zur Nutzung mit OpenID Connect
mit OpenID Connect
und dem konfigurierten `CLIENT_NAME`. und dem konfigurierten `CLIENT_NAME`.
![Login mit OpenID Connect](docs/login.png) ![Login mit OpenID Connect](docs/login.png)
@ -173,76 +145,62 @@ zu finden.
#### Rollenbasierte Berechtigungen #### Rollenbasierte Berechtigungen
Wird OpenID Connect verwendet, gibt es eine rollenbasierte Berechtigungszuweisung. Wird OpenID Connect verwendet, gibt es eine rollenbasierte Berechtigungszuweisung.
Die Standardrolle für neue OIDC-Benutzer kann mit der Option `APP_SECURITY_DEFAULT_USER_ROLE` Die Standardrolle für neue OIDC-Benutzer kann mit der Option `APP_SECURITY_DEFAULT_USER_ROLE` festgelegt werden.
festgelegt werden.
Mögliche Werte sind `user` oder `guest`. Standardwert ist `user`. Mögliche Werte sind `user` oder `guest`. Standardwert ist `user`.
Benutzer mit der Rolle "Gast" sehen nur die Inhalte, die auch nicht angemeldete Benutzer sehen. Benutzer mit der Rolle "Gast" sehen nur die Inhalte, die auch nicht angemeldete Benutzer sehen.
Hierdurch ist es möglich, einzelne Benutzer einzuschränken oder durch Änderung der Standardrolle auf Hierdurch ist es möglich, einzelne Benutzer einzuschränken oder durch Änderung der Standardrolle auf `guest` nur
`guest` nur
einzelne Benutzer als vollwertige Nutzer zuzulassen. einzelne Benutzer als vollwertige Nutzer zuzulassen.
![Rollenverwaltung](docs/userroles.png) ![Rollenverwaltung](docs/userroles.png)
Benutzer werden nach dem Entfernen oder der Änderung der vergebenen Rolle automatisch abgemeldet und Benutzer werden nach dem Entfernen oder der Änderung der vergebenen Rolle automatisch abgemeldet und müssen sich neu anmelden.
müssen sich neu anmelden.
Sie bekommen dabei wieder die Standardrolle zugewiesen. Sie bekommen dabei wieder die Standardrolle zugewiesen.
#### Auswirkungen auf den dargestellten Inhalt #### Auswirkungen auf den dargestellten Inhalt
Nur Administratoren haben Zugriff auf den Konfigurationsbereich, nur angemeldete Benutzer können die Nur Administratoren haben Zugriff auf den Konfigurationsbereich, nur angemeldete Benutzer können die anonymisierte oder
anonymisierte oder
pseudonymisierte Patienten-ID sowie den Qualitätsbericht von DNPM:DIP einsehen. pseudonymisierte Patienten-ID sowie den Qualitätsbericht von DNPM:DIP einsehen.
Wurde kein Administrator-Account konfiguriert, sind diese Inhalte generell nicht verfügbar. Wurde kein Administrator-Account konfiguriert, sind diese Inhalte generell nicht verfügbar.
### Tokenbasierte Authentifizierung für MTBFile-Endpunkt ### Tokenbasierte Authentifizierung für MTBFile-Endpunkt
Die Anwendung unterstützt das Erstellen und Nutzen einer tokenbasierten Authentifizierung für den Die Anwendung unterstützt das Erstellen und Nutzen einer tokenbasierten Authentifizierung für den MTB-File-Endpunkt.
MTB-File-Endpunkt.
Dies kann mit der Umgebungsvariable `APP_SECURITY_ENABLE_TOKENS` aktiviert (`true` oder `false`) Dies kann mit der Umgebungsvariable `APP_SECURITY_ENABLE_TOKENS` aktiviert (`true` oder `false`) werden
werden
und ist als Standardeinstellung nicht aktiv. und ist als Standardeinstellung nicht aktiv.
Ist diese Einstellung aktiviert worden, ist es Administratoren möglich, Zugriffstokens für Onkostar Ist diese Einstellung aktiviert worden, ist es Administratoren möglich, Zugriffstokens für Onkostar zu erstellen, die
zu erstellen, die
zur Nutzung des MTB-File-Endpunkts eine HTTP-Basic-Authentifizierung voraussetzen. zur Nutzung des MTB-File-Endpunkts eine HTTP-Basic-Authentifizierung voraussetzen.
![Tokenverwaltung](docs/tokens.png) ![Tokenverwaltung](docs/tokens.png)
In diesem Fall kann der Endpunkt für das Onkostar-Plugin * 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:
*[onkostar-plugin-dnpmexport](https://github.com/CCC-MF/onkostar-plugin-dnpmexport)** wie folgt
konfiguriert werden:
``` ```
https://testonkostar:MTg1NTL...NGU4@etl.example.com/mtbfile https://testonkostar:MTg1NTL...NGU4@etl.example.com/mtbfile
``` ```
Ist die Verwendung von Tokens aktiv, werden Anfragen ohne die Angabe der Token-Information Ist die Verwendung von Tokens aktiv, werden Anfragen ohne die Angabe der Token-Information abgelehnt.
abgelehnt.
Alternativ kann eine Authentifizierung über Benutzername/Passwort oder OIDC erfolgen. Alternativ kann eine Authentifizierung über Benutzername/Passwort oder OIDC erfolgen.
### Transformation von Werten ### Transformation von Werten
In Onkostar kann es vorkommen, dass ein Wert eines Merkmalskatalogs an einem Standort angepasst In Onkostar kann es vorkommen, dass ein Wert eines Merkmalskatalogs an einem Standort angepasst wurde und dadurch nicht dem Wert entspricht,
wurde und dadurch nicht dem Wert entspricht,
der von DNPM:DIP akzeptiert wird. der von DNPM:DIP akzeptiert wird.
Diese Anwendung bietet daher die Möglichkeit, eine Transformation vorzunehmen. Hierzu muss der " Diese Anwendung bietet daher die Möglichkeit, eine Transformation vorzunehmen. Hierzu muss der "Pfad" innerhalb des JSON-MTB-Files angegeben werden und
Pfad" innerhalb des JSON-MTB-Files angegeben werden und
welcher Wert wie ersetzt werden soll. welcher Wert wie ersetzt werden soll.
Hier ein Beispiel für die erste (Index 0 - weitere dann mit 1,2, ...) Transformationsregel: Hier ein Beispiel für die erste (Index 0 - weitere dann mit 1,2, ...) Transformationsregel:
* `APP_TRANSFORMATIONS_0_PATH`: Pfad zum Wert in der JSON-MTB-Datei. Beispiel: * `APP_TRANSFORMATIONS_0_PATH`: Pfad zum Wert in der JSON-MTB-Datei. Beispiel: `diagnoses[*].icd10.version` für **alle** Diagnosen
`diagnoses[*].icd10.version` für **alle** Diagnosen * `APP_TRANSFORMATIONS_0_FROM`: Angabe des Werts, der ersetzt werden soll. Andere Werte bleiben dabei unverändert.
* `APP_TRANSFORMATIONS_0_FROM`: Angabe des Werts, der ersetzt werden soll. Andere Werte bleiben
dabei unverändert.
* `APP_TRANSFORMATIONS_0_TO`: Angabe des neuen Werts. * `APP_TRANSFORMATIONS_0_TO`: Angabe des neuen Werts.
### Mögliche Endpunkte zur Datenübermittlung ### Mögliche Endpunkte zur Datenübermittlung
@ -257,61 +215,53 @@ 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 DNPM:DIP 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 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/bwhc/etl/api` für **bwHC Backend**
* `http://localhost:9000/api` für **dnpm:dip** * `http://localhost:9000/api` für **dnpm:dip**
* `APP_REST_USERNAME`: Basic-Auth-Benutzername für den REST-Endpunkt * `APP_REST_USERNAME`: Basic-Auth-Benutzername für den REST-Endpunkt
* `APP_REST_PASSWORD`: Basic-Auth-Passwort 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** * `APP_REST_IS_BWHC`: `true` für **bwHC Backend**, weglassen oder `false` für **dnpm:dip**
#### Kafka-Topics #### Kafka-Topics
Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an ein Kafka-Topic Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an ein Kafka-Topic übermittelt wird:
übermittelt wird:
* `APP_KAFKA_OUTPUT_TOPIC`: Zu verwendendes Topic zum Versenden von Anfragen. * `APP_KAFKA_OUTPUT_TOPIC`: Zu verwendendes Topic zum Versenden von Anfragen.
* `APP_KAFKA_OUTPUT_RESPONSE_TOPIC`: Topic mit Antworten über den Erfolg des Versendens. Ersetzt ~~`APP_KAFKA_TOPIC`~~, **welches nach Version 0.10 entfernt wird**.
Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_response". * `APP_KAFKA_OUTPUT_RESPONSE_TOPIC`: Topic mit Antworten über den Erfolg des Versendens. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_response".
* `APP_KAFKA_GROUP_ID`: Kafka GroupID des Consumers. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_ Ersetzt ~~`APP_KAFKA_RESPONSE_TOPIC`~~, **welches nach Version 0.10 entfernt wird**.
group". * `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 * `APP_KAFKA_SERVERS`: Zu verwendende Kafka-Bootstrap-Server als kommagetrennte Liste
Wird keine Rückantwort über Apache Kafka empfangen und es gibt keine weitere Möglichkeit den Status Wird keine Rückantwort über Apache Kafka empfangen und es gibt keine weitere Möglichkeit den Status festzustellen, verbleibt der Status auf `UNKNOWN`.
festzustellen, verbleibt der Status auf `UNKNOWN`.
Weitere Einstellungen können über die Parameter von Spring Kafka konfiguriert werden. Weitere Einstellungen können über die Parameter von Spring Kafka konfiguriert werden.
Lässt sich keine Verbindung zu dem Backend aufbauen, wird eine Rückantwort mit Status-Code `900` Lässt sich keine Verbindung zu dem Backend aufbauen, wird eine Rückantwort mit Status-Code `900` erwartet, welchen es
erwartet, welchen es
für HTTP nicht gibt. für HTTP nicht gibt.
Wird die Umgebungsvariable `APP_KAFKA_INPUT_TOPIC` gesetzt, kann eine Nachricht auch über dieses Wird die Umgebungsvariable `APP_KAFKA_INPUT_TOPIC` gesetzt, kann eine Nachricht auch über dieses Kafka-Topic an den ETL-Prozessor übermittelt werden.
Kafka-Topic an den ETL-Prozessor übermittelt werden.
##### Retention Time ##### Retention Time
Generell werden in Apache Kafka alle Records entsprechend der Konfiguration vorgehalten. Generell werden in Apache Kafka alle Records entsprechend der Konfiguration vorgehalten.
So wird ohne spezielle Konfiguration ein Record für 7 Tage in Apache Kafka gespeichert. So wird ohne spezielle Konfiguration ein Record für 7 Tage in Apache Kafka gespeichert.
Es sind innerhalb dieses Zeitraums auch alte Informationen weiterhin enthalten, wenn der Consent Es sind innerhalb dieses Zeitraums auch alte Informationen weiterhin enthalten, wenn der Consent später abgelehnt wurde.
später abgelehnt wurde.
Durch eine entsprechende Konfiguration des Topics kann dies verhindert werden. Durch eine entsprechende Konfiguration des Topics kann dies verhindert werden.
Beispiel - auszuführen innerhalb des Kafka-Containers: Löschen alter Records nach einem Tag Beispiel - auszuführen innerhalb des Kafka-Containers: Löschen alter Records nach einem Tag
``` ```
kafka-configs.sh --bootstrap-server localhost:9092 --alter --topic test --add-config retention.ms=86400000 kafka-configs.sh --bootstrap-server localhost:9092 --alter --topic test --add-config retention.ms=86400000
``` ```
##### Key based Retention ##### Key based Retention
Möchten Sie hingegen immer nur die letzte Meldung für einen Patienten und eine Erkrankung in Apache Möchten Sie hingegen immer nur die letzte Meldung für einen Patienten und eine Erkrankung in Apache Kafka vorhalten,
Kafka vorhalten,
so ist die nachfolgend genannte Konfiguration der Kafka-Topics hilfreich. so ist die nachfolgend genannte Konfiguration der Kafka-Topics hilfreich.
* `retention.ms`: Möglichst kurze Zeit in der alte Records noch erhalten bleiben, z.B. 10 Sekunden
10000 * `retention.ms`: Möglichst kurze Zeit in der alte Records noch erhalten bleiben, z.B. 10 Sekunden 10000
* `cleanup.policy`: Löschen alter Records und Beibehalten des letzten Records zu einem * `cleanup.policy`: Löschen alter Records und Beibehalten des letzten Records zu einem Key [delete,compact]
Key [delete,compact]
Beispiele für ein Topic `test`, hier bitte an die verwendeten Topics anpassen. Beispiele für ein Topic `test`, hier bitte an die verwendeten Topics anpassen.
@ -320,23 +270,17 @@ kafka-configs.sh --bootstrap-server localhost:9092 --alter --topic test --add-co
kafka-configs.sh --bootstrap-server localhost:9092 --alter --topic test --add-config cleanup.policy=[delete,compact] kafka-configs.sh --bootstrap-server localhost:9092 --alter --topic test --add-config cleanup.policy=[delete,compact]
``` ```
Da als Key eines Records die (pseudonymisierte) Patienten-ID verwendet wird, stehen mit obiger Da als Key eines Records die (pseudonymisierte) Patienten-ID verwendet wird, stehen mit obiger Konfiguration
Konfiguration der Kafka-Topics nach 10 Sekunden nur noch der jeweils letzte Eintrag für den entsprechenden Key zur Verfügung.
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 DNPM:DIP, als auch für die Rückantwort identisch 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
aufgebaut ist, lassen sich so auch im Falle eines Consent-Widerspruchs die enthaltenen Daten als auch die Offenlegung durch Verifikationsdaten in der
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. Antwort effektiv verhindern, da diese nach 10 Sekunden gelöscht werden.
Es steht dann nur noch die jeweils letzten Information zur Verfügung, dass für einen Patienten/eine Es steht dann nur noch die jeweils letzten Information zur Verfügung, dass für einen Patienten/eine Erkrankung
Erkrankung
ein Consent-Widerspruch erfolgte. ein Consent-Widerspruch erfolgte.
Dieses Vorgehen empfiehlt sich, wenn Sie gespeicherte Records nachgelagert für andere Auswertungen Dieses Vorgehen empfiehlt sich, wenn Sie gespeicherte Records nachgelagert für andere Auswertungen verwenden möchten.
verwenden möchten.
### Antworten und Statusauswertung ### Antworten und Statusauswertung
@ -348,12 +292,10 @@ Anfragen an das bwHC-Backend aus Versionen bis 0.9.x wurden wie folgt behandelt:
| `HTTP 201` | `WARNING` | | `HTTP 201` | `WARNING` |
| `HTTP 400-...` | `ERROR` | | `HTTP 400-...` | `ERROR` |
Dies konnte dazu führen, dass zwar mit einem `HTTP 201` geantwortet wurde, aber dennoch in der Dies konnte dazu führen, dass zwar mit einem `HTTP 201` geantwortet wurde, aber dennoch in der Issue-Liste die
Issue-Liste die
Severity `error` aufgetaucht ist. Severity `error` aufgetaucht ist.
Ab Version 0.10 wird die Issue-Liste der Antwort verwendet und die darion enthaltene höchste Ab Version 0.10 wird die Issue-Liste der Antwort verwendet und die darion enthaltene höchste Severity-Stufe als Ergebnis verwendet.
Severity-Stufe als Ergebnis verwendet.
| Höchste Severity | Status | | Höchste Severity | Status |
|------------------|-----------| |------------------|-----------|
@ -363,10 +305,9 @@ Severity-Stufe als Ergebnis verwendet.
## Docker-Images ## Docker-Images
Diese Anwendung ist auch als Docker-Image Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/pcvolkmer/etl-processor/pkgs/container/etl-processor
verfügbar: https://github.com/pcvolkmer/etl-processor/pkgs/container/etl-processor
### Images lokal bauen ### Images lokal bauen
```bash ```bash
./gradlew bootBuildImage ./gradlew bootBuildImage
@ -374,25 +315,20 @@ verfügbar: https://github.com/pcvolkmer/etl-processor/pkgs/container/etl-proces
### Integration eines eigenen Root CA Zertifikats ### Integration eines eigenen Root CA Zertifikats
Wird eine eigene Root CA verwendet, die nicht offiziell signiert ist, wird es zu Problemen beim Wird eine eigene Root CA verwendet, die nicht offiziell signiert ist, wird es zu Problemen beim SSL-Handshake kommen, wenn z.B. gPAS zur Generierung von Pseudonymen verwendet wird.
SSL-Handshake kommen, wenn z.B. gPAS zur Generierung von Pseudonymen verwendet wird.
Hier bietet es sich an, das Root CA Zertifikat in das Image zu integrieren. Hier bietet es sich an, das Root CA Zertifikat in das Image zu integrieren.
#### Integration beim Bauen des Images #### Integration beim Bauen des Images
Hier muss die Zeile `"BP_EMBED_CERTS" to "true"` in der Datei `build.gradle.kts` verwendet werden Hier muss die Zeile `"BP_EMBED_CERTS" to "true"` in der Datei `build.gradle.kts` verwendet werden und darf nicht als Kommentar verwendet werden.
und darf nicht als Kommentar verwendet werden.
Die PEM-Datei mit dem/den Root CA Zertifikat(en) muss dabei im vorbereiteten Verzeichnis [ Die PEM-Datei mit dem/den Root CA Zertifikat(en) muss dabei im vorbereiteten Verzeichnis [`bindings/ca-certificates`](bindings/ca-certificates) enthalten sein.
`bindings/ca-certificates`](bindings/ca-certificates) enthalten sein.
#### Integration zur Laufzeit #### Integration zur Laufzeit
Hier muss die Umgebungsvariable `SERVICE_BINDING_ROOT` z.B. auf den Wert `/bindings` gesetzt sein. Hier muss die Umgebungsvariable `SERVICE_BINDING_ROOT` z.B. auf den Wert `/bindings` gesetzt sein.
Zudem muss ein Verzeichnis `bindings/ca-certificates` - analog zum Verzeichnis [ Zudem muss ein Verzeichnis `bindings/ca-certificates` - analog zum Verzeichnis [`bindings/ca-certificates`](bindings/ca-certificates) mit einer PEM-Datei als Docker-Volume eingebunden werden.
`bindings/ca-certificates`](bindings/ca-certificates) mit einer PEM-Datei als Docker-Volume
eingebunden werden.
Beispiel für Docker-Compose: Beispiel für Docker-Compose:
@ -407,14 +343,12 @@ Beispiel für Docker-Compose:
``` ```
## Deployment ## Deployment
*Ausführen als Docker Container:* *Ausführen als Docker Container:*
```bash ```bash
cd ./deploy cd ./deploy
cp env-sample.env .env cp env-sample.env .env
``` ```
Wenn gewünscht, Änderungen in der `.env` vornehmen. Wenn gewünscht, Änderungen in der `.env` vornehmen.
```bash ```bash
@ -423,19 +357,15 @@ docker compose up -d
### Einfaches Beispiel für ein eigenes Docker-Compose-File ### Einfaches Beispiel für ein eigenes Docker-Compose-File
Die Datei [`docs/docker-compose.yml`](docs/docker-compose.yml) zeigt eine einfache Konfiguration für Die Datei [`docs/docker-compose.yml`](docs/docker-compose.yml) zeigt eine einfache Konfiguration für REST-Requests basierend
REST-Requests basierend
auf Docker-Compose mit der gestartet werden kann. auf Docker-Compose mit der gestartet werden kann.
### Betrieb hinter einem Reverse-Proxy ### Betrieb hinter einem Reverse-Proxy
Die Anwendung verarbeitet `X-Forwarded`-HTTP-Header und kann daher auch hinter einem Reverse-Proxy Die Anwendung verarbeitet `X-Forwarded`-HTTP-Header und kann daher auch hinter einem Reverse-Proxy betrieben werden.
betrieben werden.
Dabei werden, je nachdem welche Header durch den Reverse-Proxy gesendet werden auch Protokoll, Host Dabei werden, je nachdem welche Header durch den Reverse-Proxy gesendet werden auch Protokoll, Host oder auch Path-Präfix
oder auch Path-Präfix automatisch erkannt und verwendet werden. Dadurch ist z.B. eine abweichende Angabe des Pfads problemlos möglich.
automatisch erkannt und verwendet werden. Dadurch ist z.B. eine abweichende Angabe des Pfads
problemlos möglich.
#### Beispiel *Traefik* (mit Docker-Labels): #### Beispiel *Traefik* (mit Docker-Labels):
@ -471,17 +401,13 @@ Das folgende Beispiel zeigt die Konfiguration einer _location_ in einer nginx-Ko
## Entwicklungssetup ## Entwicklungssetup
Zum Starten einer lokalen Entwicklungs- und Testumgebung kann die beiliegende Datei Zum Starten einer lokalen Entwicklungs- und Testumgebung kann die beiliegende Datei `dev-compose.yml` verwendet werden.
`dev-compose.yml` verwendet werden.
Diese kann zur Nutzung der Datenbanken **MariaDB** als auch **PostgreSQL** angepasst werden. Diese kann zur Nutzung der Datenbanken **MariaDB** als auch **PostgreSQL** angepasst werden.
Zur Nutzung von Apache Kafka muss dazu ein Eintrag im hosts-File vorgenommen werden und der Hostname Zur Nutzung von Apache Kafka muss dazu ein Eintrag im hosts-File vorgenommen werden und der Hostname `kafka` auf die lokale
`kafka` auf die lokale IP-Adresse verweisen. Ohne diese Einstellung ist eine Nutzung von Apache Kafka außerhalb der Docker-Umgebung nicht möglich.
IP-Adresse verweisen. Ohne diese Einstellung ist eine Nutzung von Apache Kafka außerhalb der
Docker-Umgebung nicht möglich.
Beim Start der Anwendung mit dem Profil `dev` wird die in `dev-compose.yml` definierte Umgebung beim Beim Start der Anwendung mit dem Profil `dev` wird die in `dev-compose.yml` definierte Umgebung beim Start der
Start der
Anwendung mit gestartet: Anwendung mit gestartet:
``` ```
@ -493,5 +419,4 @@ 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. Beim Ausführen der Integrationstests wird eine Testdatenbank in einem Docker-Container gestartet.
Siehe hier auch die Klasse `AbstractTestcontainerTest` unter `src/integrationTest`. Siehe hier auch die Klasse `AbstractTestcontainerTest` unter `src/integrationTest`.
Ein einfaches Entwickler-Setup inklusive DNPM:DIP ist mit Hilfe Ein einfaches Entwickler-Setup inklusive DNPM:DIP ist mit Hilfe von https://github.com/pcvolkmer/dnpmdip-devenv realisierbar.
von https://github.com/pcvolkmer/dnpmdip-devenv realisierbar.

View File

@ -5,7 +5,7 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
plugins { plugins {
war war
id("org.springframework.boot") version "3.5.3" id("org.springframework.boot") version "3.3.10"
id("io.spring.dependency-management") version "1.1.7" id("io.spring.dependency-management") version "1.1.7"
kotlin("jvm") version "1.9.25" kotlin("jvm") version "1.9.25"
kotlin("plugin.spring") version "1.9.25" kotlin("plugin.spring") version "1.9.25"
@ -13,12 +13,12 @@ plugins {
} }
group = "dev.dnpm" group = "dev.dnpm"
version = "0.11.0-SNAPSHOT" version = "0.10.0"
var versions = mapOf( var versions = mapOf(
"bwhc-dto-java" to "0.4.0", "bwhc-dto-java" to "0.4.0",
"mtb-dto" to "0.1.0-SNAPSHOT",
"hapi-fhir" to "7.6.0", "hapi-fhir" to "7.6.0",
"commons-compress" to "1.26.2",
"mockito-kotlin" to "5.4.0", "mockito-kotlin" to "5.4.0",
"archunit" to "1.3.0", "archunit" to "1.3.0",
// Webjars // Webjars
@ -49,18 +49,9 @@ configurations {
compileOnly { compileOnly {
extendsFrom(configurations.annotationProcessor.get()) extendsFrom(configurations.annotationProcessor.get())
} }
all {
resolutionStrategy {
cacheChangingModulesFor(5, "minutes")
}
}
} }
repositories { repositories {
maven {
url = uri("https://git.dnpm.dev/api/packages/public-snapshots/maven")
}
maven { maven {
url = uri("https://git.dnpm.dev/api/packages/public/maven") url = uri("https://git.dnpm.dev/api/packages/public/maven")
} }
@ -82,7 +73,6 @@ dependencies {
implementation("commons-codec:commons-codec") implementation("commons-codec:commons-codec")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("de.ukw.ccc:bwhc-dto-java:${versions["bwhc-dto-java"]}") 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-base:${versions["hapi-fhir"]}")
implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${versions["hapi-fhir"]}") implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${versions["hapi-fhir"]}")
implementation("org.apache.httpcomponents.client5:httpclient5") implementation("org.apache.httpcomponents.client5:httpclient5")
@ -90,7 +80,6 @@ dependencies {
implementation("org.webjars:webjars-locator:${versions["webjars-locator"]}") implementation("org.webjars:webjars-locator:${versions["webjars-locator"]}")
implementation("org.webjars.npm:echarts:${versions["echarts"]}") implementation("org.webjars.npm:echarts:${versions["echarts"]}")
implementation("org.webjars.npm:htmx.org:${versions["htmx.org"]}") implementation("org.webjars.npm:htmx.org:${versions["htmx.org"]}")
implementation ("org.apache.commons:commons-math3:3.6.1")
runtimeOnly("org.mariadb.jdbc:mariadb-java-client") runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
runtimeOnly("org.postgresql:postgresql") runtimeOnly("org.postgresql:postgresql")
@ -110,8 +99,10 @@ dependencies {
integrationTestImplementation("org.testcontainers:junit-jupiter") integrationTestImplementation("org.testcontainers:junit-jupiter")
integrationTestImplementation("org.testcontainers:postgresql") integrationTestImplementation("org.testcontainers:postgresql")
integrationTestImplementation("com.tngtech.archunit:archunit:${versions["archunit"]}") integrationTestImplementation("com.tngtech.archunit:archunit:${versions["archunit"]}")
integrationTestImplementation("org.htmlunit:htmlunit") integrationTestImplementation("net.sourceforge.htmlunit:htmlunit")
integrationTestImplementation("org.springframework:spring-webflux") 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<KotlinCompile> { tasks.withType<KotlinCompile> {
@ -128,9 +119,8 @@ tasks.withType<Test> {
} }
} }
tasks.register<Test>("integrationTest") { task<Test>("integrationTest") {
description = "Runs integration tests" description = "Runs integration tests"
group = "verification"
testClassesDirs = sourceSets["integrationTest"].output.classesDirs testClassesDirs = sourceSets["integrationTest"].output.classesDirs
classpath = sourceSets["integrationTest"].runtimeClasspath classpath = sourceSets["integrationTest"].runtimeClasspath

View File

@ -23,7 +23,6 @@ import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.* import de.ukw.ccc.bwhc.dto.*
import dev.dnpm.etl.processor.monitoring.RequestRepository import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.output.BwhcV1MtbFileRequest
import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.output.MtbFileSender
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
@ -34,10 +33,10 @@ import org.mockito.kotlin.*
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationContext
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.context.TestPropertySource 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.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.post import org.springframework.test.web.servlet.post
@ -46,7 +45,7 @@ import org.testcontainers.junit.jupiter.Testcontainers
@Testcontainers @Testcontainers
@ExtendWith(SpringExtension::class) @ExtendWith(SpringExtension::class)
@SpringBootTest @SpringBootTest
@MockitoBean(types = [MtbFileSender::class]) @MockBean(MtbFileSender::class)
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
"app.rest.uri=http://example.com", "app.rest.uri=http://example.com",
@ -74,7 +73,7 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
) )
inner class TransformationTest { inner class TransformationTest {
@MockitoBean @MockBean
private lateinit var mtbFileSender: MtbFileSender private lateinit var mtbFileSender: MtbFileSender
@Autowired @Autowired
@ -92,7 +91,7 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
fun mtbFileIsTransformed() { fun mtbFileIsTransformed() {
doAnswer { doAnswer {
MtbFileSender.Response(RequestStatus.SUCCESS) MtbFileSender.Response(RequestStatus.SUCCESS)
}.whenever(mtbFileSender).send(any<BwhcV1MtbFileRequest>()) }.whenever(mtbFileSender).send(any<MtbFileSender.MtbFileRequest>())
val mtbFile = MtbFile.builder() val mtbFile = MtbFile.builder()
.withPatient( .withPatient(
@ -135,9 +134,9 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
} }
} }
val captor = argumentCaptor<BwhcV1MtbFileRequest>() val captor = argumentCaptor<MtbFileSender.MtbFileRequest>()
verify(mtbFileSender).send(captor.capture()) verify(mtbFileSender).send(captor.capture())
assertThat(captor.firstValue.content.diagnoses).hasSize(1).allMatch { diagnosis -> assertThat(captor.firstValue.mtbFile.diagnoses).hasSize(1).allMatch { diagnosis ->
diagnosis.icd10.version == "2014" diagnosis.icd10.version == "2014"
} }
} }

View File

@ -26,9 +26,9 @@ import dev.dnpm.etl.processor.output.KafkaMtbFileSender
import dev.dnpm.etl.processor.output.RestMtbFileSender import dev.dnpm.etl.processor.output.RestMtbFileSender
import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator 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.TokenRepository
import dev.dnpm.etl.processor.security.TokenService import dev.dnpm.etl.processor.security.TokenService
import dev.dnpm.etl.processor.services.RequestProcessor
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -36,25 +36,24 @@ import org.junit.jupiter.api.assertThrows
import org.springframework.beans.factory.NoSuchBeanDefinitionException import org.springframework.beans.factory.NoSuchBeanDefinitionException
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.boot.test.mock.mockito.MockBeans
import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationContext
import org.springframework.retry.support.RetryTemplate import org.springframework.retry.support.RetryTemplate
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.provisioning.InMemoryUserDetailsManager import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.bean.override.mockito.MockitoBean
@SpringBootTest @SpringBootTest
@ContextConfiguration( @ContextConfiguration(classes = [
classes = [ AppConfiguration::class,
AppConfiguration::class, AppSecurityConfiguration::class,
AppSecurityConfiguration::class, KafkaAutoConfiguration::class,
KafkaAutoConfiguration::class, AppKafkaConfiguration::class,
AppKafkaConfiguration::class, AppRestConfiguration::class
AppRestConfiguration::class ])
] @MockBean(ObjectMapper::class)
)
@MockitoBean(types = [ObjectMapper::class])
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
"app.pseudonymize.generator=BUILDIN", "app.pseudonymize.generator=BUILDIN",
@ -87,7 +86,7 @@ class AppConfigurationTest {
"app.kafka.group-id=test" "app.kafka.group-id=test"
] ]
) )
@MockitoBean(types = [RequestRepository::class]) @MockBean(RequestRepository::class)
inner class AppConfigurationKafkaTest(private val context: ApplicationContext) { inner class AppConfigurationKafkaTest(private val context: ApplicationContext) {
@Test @Test
@ -146,7 +145,7 @@ class AppConfigurationTest {
"app.kafka.group-id=test" "app.kafka.group-id=test"
] ]
) )
@MockitoBean(types = [RequestProcessor::class]) @MockBean(RequestProcessor::class)
inner class AppConfigurationUsingKafkaInputTest(private val context: ApplicationContext) { inner class AppConfigurationUsingKafkaInputTest(private val context: ApplicationContext) {
@Test @Test
@ -182,7 +181,40 @@ class AppConfigurationTest {
@Nested @Nested
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
"app.pseudonymize.generator=buildin" "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=",
] ]
) )
inner class AppConfigurationPseudonymizeGeneratorBuildinTest(private val context: ApplicationContext) { inner class AppConfigurationPseudonymizeGeneratorBuildinTest(private val context: ApplicationContext) {
@ -197,7 +229,8 @@ class AppConfigurationTest {
@Nested @Nested
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
"app.pseudonymize.generator=gpas" "app.pseudonymize.generator=gpas",
"app.pseudonymizer=",
] ]
) )
inner class AppConfigurationPseudonymizeGeneratorGpasTest(private val context: ApplicationContext) { inner class AppConfigurationPseudonymizeGeneratorGpasTest(private val context: ApplicationContext) {
@ -215,13 +248,11 @@ class AppConfigurationTest {
"app.security.enable-tokens=true" "app.security.enable-tokens=true"
] ]
) )
@MockitoBean( @MockBeans(value = [
types = [ MockBean(InMemoryUserDetailsManager::class),
InMemoryUserDetailsManager::class, MockBean(PasswordEncoder::class),
PasswordEncoder::class, MockBean(TokenRepository::class)
TokenRepository::class ])
]
)
inner class AppConfigurationTokenEnabledTest(private val context: ApplicationContext) { inner class AppConfigurationTokenEnabledTest(private val context: ApplicationContext) {
@Test @Test
@ -232,13 +263,11 @@ class AppConfigurationTest {
} }
@Nested @Nested
@MockitoBean( @MockBeans(value = [
types = [ MockBean(InMemoryUserDetailsManager::class),
InMemoryUserDetailsManager::class, MockBean(PasswordEncoder::class),
PasswordEncoder::class, MockBean(TokenRepository::class)
TokenRepository::class ])
]
)
inner class AppConfigurationTokenDisabledTest(private val context: ApplicationContext) { inner class AppConfigurationTokenDisabledTest(private val context: ApplicationContext) {
@Test @Test

View File

@ -37,13 +37,13 @@ import org.mockito.kotlin.times
import org.mockito.kotlin.verify import org.mockito.kotlin.verify
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 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.http.MediaType
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository 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.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource 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.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.delete import org.springframework.test.web.servlet.delete
@ -57,7 +57,7 @@ import org.springframework.test.web.servlet.post
AppSecurityConfiguration::class AppSecurityConfiguration::class
] ]
) )
@MockitoBean(types = [TokenRepository::class, RequestProcessor::class]) @MockBean(TokenRepository::class, RequestProcessor::class)
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
"app.pseudonymize.generator=BUILDIN", "app.pseudonymize.generator=BUILDIN",
@ -91,7 +91,7 @@ class MtbFileRestControllerTest {
status { isAccepted() } status { isAccepted() }
} }
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>()) verify(requestProcessor, times(1)).processMtbFile(any())
} }
@Test @Test
@ -104,7 +104,7 @@ class MtbFileRestControllerTest {
status { isAccepted() } status { isAccepted() }
} }
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>()) verify(requestProcessor, times(1)).processMtbFile(any())
} }
@Test @Test
@ -117,7 +117,7 @@ class MtbFileRestControllerTest {
status { isUnauthorized() } status { isUnauthorized() }
} }
verify(requestProcessor, never()).processMtbFile(any<MtbFile>()) verify(requestProcessor, never()).processMtbFile(any())
} }
@Test @Test
@ -130,7 +130,7 @@ class MtbFileRestControllerTest {
status { isForbidden() } status { isForbidden() }
} }
verify(requestProcessor, never()).processMtbFile(any<MtbFile>()) verify(requestProcessor, never()).processMtbFile(any())
} }
@Test @Test
@ -156,7 +156,7 @@ class MtbFileRestControllerTest {
} }
@Nested @Nested
@MockitoBean(types = [UserRoleRepository::class, ClientRegistrationRepository::class]) @MockBean(UserRoleRepository::class, ClientRegistrationRepository::class)
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
"app.pseudonymize.generator=BUILDIN", "app.pseudonymize.generator=BUILDIN",
@ -177,7 +177,7 @@ class MtbFileRestControllerTest {
status { isAccepted() } status { isAccepted() }
} }
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>()) verify(requestProcessor, times(1)).processMtbFile(any())
} }
@Test @Test
@ -190,7 +190,7 @@ class MtbFileRestControllerTest {
status { isAccepted() } status { isAccepted() }
} }
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>()) verify(requestProcessor, times(1)).processMtbFile(any())
} }
} }

View File

@ -27,8 +27,8 @@ import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase 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.TestPropertySource
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.testcontainers.junit.jupiter.Testcontainers import org.testcontainers.junit.jupiter.Testcontainers
@ -39,7 +39,7 @@ import java.time.Instant
@DataJdbcTest @DataJdbcTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Transactional @Transactional
@MockitoBean(types = [MtbFileSender::class]) @MockBean(MtbFileSender::class)
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
"app.pseudonymize.generator=buildin", "app.pseudonymize.generator=buildin",

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -47,8 +47,9 @@ class GpasPseudonymGeneratorTest {
fun setup() { fun setup() {
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
val gPasConfigProperties = GPasConfigProperties( val gPasConfigProperties = GPasConfigProperties(
"https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate", "http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate",
"test", "test2", "test",
null,
null, null,
null null
) )
@ -62,15 +63,9 @@ class GpasPseudonymGeneratorTest {
fun shouldReturnExpectedPseudonym() { fun shouldReturnExpectedPseudonym() {
this.mockRestServiceServer.expect { this.mockRestServiceServer.expect {
method(HttpMethod.POST) method(HttpMethod.POST)
requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") requestTo("http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
}.andRespond { }.andRespond {
withStatus(HttpStatus.OK).body( withStatus(HttpStatus.OK).body(getDummyResponseBody("1234", "test", "test1234ABCDEF567890"))
getDummyResponseBody(
"1234",
"test",
"test1234ABCDEF567890"
)
)
.createResponse(it) .createResponse(it)
} }
@ -81,7 +76,7 @@ class GpasPseudonymGeneratorTest {
fun shouldThrowExceptionIfGpasNotAvailable() { fun shouldThrowExceptionIfGpasNotAvailable() {
this.mockRestServiceServer.expect { this.mockRestServiceServer.expect {
method(HttpMethod.POST) method(HttpMethod.POST)
requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") requestTo("http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
}.andRespond { }.andRespond {
withException(IOException("Simulated IO error")).createResponse(it) withException(IOException("Simulated IO error")).createResponse(it)
} }
@ -93,13 +88,10 @@ class GpasPseudonymGeneratorTest {
fun shouldThrowExceptionIfGpasDoesNotReturn2xxResponse() { fun shouldThrowExceptionIfGpasDoesNotReturn2xxResponse() {
this.mockRestServiceServer.expect { this.mockRestServiceServer.expect {
method(HttpMethod.POST) method(HttpMethod.POST)
requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") requestTo("http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
}.andRespond { }.andRespond {
withStatus(HttpStatus.FOUND) withStatus(HttpStatus.FOUND)
.header( .header(HttpHeaders.LOCATION, "https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
HttpHeaders.LOCATION,
"https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate"
)
.createResponse(it) .createResponse(it)
} }

View File

@ -31,8 +31,8 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.test.context.TestPropertySource 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.context.junit.jupiter.SpringExtension
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.testcontainers.junit.jupiter.Testcontainers import org.testcontainers.junit.jupiter.Testcontainers
@ -42,7 +42,7 @@ import java.time.Instant
@ExtendWith(SpringExtension::class) @ExtendWith(SpringExtension::class)
@SpringBootTest @SpringBootTest
@Transactional @Transactional
@MockitoBean(types = [MtbFileSender::class]) @MockBean(MtbFileSender::class)
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
"app.pseudonymize.generator=buildin", "app.pseudonymize.generator=buildin",

View File

@ -19,6 +19,8 @@
package dev.dnpm.etl.processor.web 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.AppConfiguration
import dev.dnpm.etl.processor.config.AppSecurityConfiguration import dev.dnpm.etl.processor.config.AppSecurityConfiguration
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
@ -27,13 +29,11 @@ import dev.dnpm.etl.processor.monitoring.RestConnectionCheckService
import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.output.MtbFileSender
import dev.dnpm.etl.processor.pseudonym.Generator import dev.dnpm.etl.processor.pseudonym.Generator
import dev.dnpm.etl.processor.security.Role import dev.dnpm.etl.processor.security.Role
import dev.dnpm.etl.processor.security.TokenService
import dev.dnpm.etl.processor.security.UserRoleService
import dev.dnpm.etl.processor.services.RequestProcessor 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.services.TransformationService
import dev.dnpm.etl.processor.security.UserRoleService
import org.assertj.core.api.Assertions.assertThat 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.BeforeEach
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -46,6 +46,7 @@ import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 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.HttpHeaders
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.MediaType.TEXT_EVENT_STREAM import org.springframework.http.MediaType.TEXT_EVENT_STREAM
@ -54,7 +55,6 @@ import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequ
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource 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.context.junit.jupiter.SpringExtension
import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.servlet.* import org.springframework.test.web.servlet.*
@ -81,16 +81,14 @@ abstract class MockSink : Sinks.Many<Boolean>
"app.pseudonymize.generator=BUILDIN" "app.pseudonymize.generator=BUILDIN"
] ]
) )
@MockitoBean(name = "configsUpdateProducer", types = [MockSink::class]) @MockBean(name = "configsUpdateProducer", classes = [MockSink::class])
@MockitoBean( @MockBean(
types = [ Generator::class,
Generator::class, MtbFileSender::class,
MtbFileSender::class, RequestProcessor::class,
RequestProcessor::class, TransformationService::class,
TransformationService::class, GPasConnectionCheckService::class,
GPasConnectionCheckService::class, RestConnectionCheckService::class,
RestConnectionCheckService::class
]
) )
class ConfigControllerTest { class ConfigControllerTest {
@ -145,10 +143,8 @@ class ConfigControllerTest {
"app.security.admin-user=admin" "app.security.admin-user=admin"
] ]
) )
@MockitoBean( @MockBean(
types = [ TokenService::class
TokenService::class
]
) )
inner class WithTokensEnabled { inner class WithTokensEnabled {
private lateinit var tokenService: TokenService private lateinit var tokenService: TokenService
@ -256,10 +252,8 @@ class ConfigControllerTest {
"app.security.admin-password={noop}very-secret" "app.security.admin-password={noop}very-secret"
] ]
) )
@MockitoBean( @MockBean(
types = [ UserRoleService::class
UserRoleService::class
]
) )
inner class WithUserRolesEnabled { inner class WithUserRolesEnabled {
private lateinit var userRoleService: UserRoleService private lateinit var userRoleService: UserRoleService

View File

@ -19,6 +19,8 @@
package dev.dnpm.etl.processor.web 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.*
import dev.dnpm.etl.processor.config.AppConfiguration import dev.dnpm.etl.processor.config.AppConfiguration
import dev.dnpm.etl.processor.config.AppSecurityConfiguration import dev.dnpm.etl.processor.config.AppSecurityConfiguration
@ -28,8 +30,6 @@ import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.monitoring.RequestType import dev.dnpm.etl.processor.monitoring.RequestType
import dev.dnpm.etl.processor.services.RequestService import dev.dnpm.etl.processor.services.RequestService
import org.assertj.core.api.Assertions.assertThat 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.BeforeEach
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -40,13 +40,13 @@ import org.mockito.kotlin.any
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 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.Page
import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.security.test.context.support.WithMockUser import org.springframework.security.test.context.support.WithMockUser
import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource 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.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.get
@ -71,8 +71,8 @@ import java.util.*
"app.security.admin-password={noop}very-secret" "app.security.admin-password={noop}very-secret"
] ]
) )
@MockitoBean( @MockBean(
types = [RequestService::class] RequestService::class
) )
class HomeControllerTest { class HomeControllerTest {

View File

@ -19,21 +19,21 @@
package dev.dnpm.etl.processor.web 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.AppConfiguration
import dev.dnpm.etl.processor.config.AppSecurityConfiguration import dev.dnpm.etl.processor.config.AppSecurityConfiguration
import dev.dnpm.etl.processor.security.TokenService import dev.dnpm.etl.processor.security.TokenService
import org.assertj.core.api.Assertions.assertThat 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.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoExtension
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 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.ContextConfiguration
import org.springframework.test.context.TestPropertySource 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.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.get
@ -56,8 +56,8 @@ import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
"app.security.enable-tokens=true" "app.security.enable-tokens=true"
] ]
) )
@MockitoBean( @MockBean(
types = [TokenService::class] TokenService::class,
) )
class LoginControllerTest { class LoginControllerTest {

View File

@ -19,9 +19,9 @@
package dev.dnpm.etl.processor.web package dev.dnpm.etl.processor.web
import com.gargoylesoftware.htmlunit.WebClient
import dev.dnpm.etl.processor.config.AppConfiguration import dev.dnpm.etl.processor.config.AppConfiguration
import dev.dnpm.etl.processor.config.AppSecurityConfiguration import dev.dnpm.etl.processor.config.AppSecurityConfiguration
import org.htmlunit.WebClient
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith

View File

@ -41,10 +41,10 @@ import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 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.http.MediaType.TEXT_EVENT_STREAM
import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource 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.context.junit.jupiter.SpringExtension
import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
@ -74,8 +74,8 @@ import java.time.temporal.ChronoUnit
"app.security.admin-password={noop}very-secret" "app.security.admin-password={noop}very-secret"
] ]
) )
@MockitoBean( @MockBean(
types = [RequestService::class] RequestService::class
) )
class StatisticsRestControllerTest { class StatisticsRestControllerTest {

View File

@ -23,6 +23,4 @@ public interface Generator {
String generate(String id); String generate(String id);
String generateGenomDeTan(String id);
} }

View File

@ -38,41 +38,30 @@ public class GpasPseudonymGenerator implements Generator {
private final static FhirContext r4Context = FhirContext.forR4(); private final static FhirContext r4Context = FhirContext.forR4();
private final String gPasUrl; private final String gPasUrl;
private final String psnTargetDomain;
private final HttpHeaders httpHeader; private final HttpHeaders httpHeader;
private final RetryTemplate retryTemplate; private final RetryTemplate retryTemplate;
private final Logger log = LoggerFactory.getLogger(GpasPseudonymGenerator.class); private final Logger log = LoggerFactory.getLogger(GpasPseudonymGenerator.class);
private final RestTemplate restTemplate;
private final @NotNull String genomDeDomain;
private final @NotNull String psnTargetDomain;
public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate, private final RestTemplate restTemplate;
RestTemplate restTemplate) {
public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate, RestTemplate restTemplate) {
this.retryTemplate = retryTemplate; this.retryTemplate = retryTemplate;
this.restTemplate = restTemplate; this.restTemplate = restTemplate;
this.gPasUrl = gpasCfg.getUri(); this.gPasUrl = gpasCfg.getUri();
this.psnTargetDomain = gpasCfg.getTarget(); this.psnTargetDomain = gpasCfg.getTarget();
this.genomDeDomain = gpasCfg.getGenomDeDomain();
httpHeader = getHttpHeaders(gpasCfg.getUsername(), gpasCfg.getPassword()); httpHeader = getHttpHeaders(gpasCfg.getUsername(), gpasCfg.getPassword());
log.debug("{} has been initialized", this.getClass().getName()); log.debug(String.format("%s has been initialized", this.getClass().getName()));
} }
@Override @Override
public String generate(String id) { public String generate(String id) {
return generate(id, psnTargetDomain); var gPasRequestBody = getGpasRequestBody(id);
}
@Override
public String generateGenomDeTan(String id) {
return generate(id, genomDeDomain);
}
protected String generate(String id, String targetDomain) {
var gPasRequestBody = getGpasRequestBody(id, targetDomain);
var responseEntity = getGpasPseudonym(gPasRequestBody); var responseEntity = getGpasPseudonym(gPasRequestBody);
var gPasPseudonymResult = (Parameters) r4Context.newJsonParser() var gPasPseudonymResult = (Parameters) r4Context.newJsonParser()
.parseResource(responseEntity.getBody()); .parseResource(responseEntity.getBody());
return unwrapPseudonym(gPasPseudonymResult); return unwrapPseudonym(gPasPseudonymResult);
} }
@ -86,9 +75,9 @@ public class GpasPseudonymGenerator implements Generator {
} }
final var identifier = (Identifier) parameters.get().getPart().stream() final var identifier = (Identifier) parameters.get().getPart().stream()
.filter(a -> a.getName().equals("pseudonym")) .filter(a -> a.getName().equals("pseudonym"))
.findFirst() .findFirst()
.orElseGet(ParametersParameterComponent::new).getValue(); .orElseGet(ParametersParameterComponent::new).getValue();
// pseudonym // pseudonym
return sanitizeValue(identifier.getValue()); return sanitizeValue(identifier.getValue());
@ -117,8 +106,8 @@ public class GpasPseudonymGenerator implements Generator {
try { try {
responseEntity = retryTemplate.execute( responseEntity = retryTemplate.execute(
ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity, ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity,
String.class)); String.class));
if (responseEntity.getStatusCode().is2xxSuccessful()) { if (responseEntity.getStatusCode().is2xxSuccessful()) {
log.debug("API request succeeded. Response: {}", responseEntity.getStatusCode()); log.debug("API request succeeded. Response: {}", responseEntity.getStatusCode());
@ -130,16 +119,16 @@ public class GpasPseudonymGenerator implements Generator {
return responseEntity; return responseEntity;
} catch (Exception unexpected) { } catch (Exception unexpected) {
throw new PseudonymRequestFailed( throw new PseudonymRequestFailed(
"API request due unexpected error unsuccessful gPas unsuccessful.", unexpected); "API request due unexpected error unsuccessful gPas unsuccessful.", unexpected);
} }
} }
protected String getGpasRequestBody(String id, String targetDomain) { protected String getGpasRequestBody(String id) {
var requestParameters = new Parameters(); var requestParameters = new Parameters();
requestParameters.addParameter().setName("target") requestParameters.addParameter().setName("target")
.setValue(new StringType().setValue(targetDomain)); .setValue(new StringType().setValue(psnTargetDomain));
requestParameters.addParameter().setName("original") requestParameters.addParameter().setName("original")
.setValue(new StringType().setValue(id)); .setValue(new StringType().setValue(id));
final IParser iParser = r4Context.newJsonParser(); final IParser iParser = r4Context.newJsonParser();
return iParser.encodeResourceToString(requestParameters); return iParser.encodeResourceToString(requestParameters);
} }

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -21,10 +21,16 @@ package dev.dnpm.etl.processor.config
import dev.dnpm.etl.processor.security.Role import dev.dnpm.etl.processor.security.Role
import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.DeprecatedConfigurationProperty
@ConfigurationProperties(AppConfigProperties.NAME) @ConfigurationProperties(AppConfigProperties.NAME)
data class AppConfigProperties( data class AppConfigProperties(
var bwhcUri: String?, 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<TransformationProperties> = listOf(), var transformations: List<TransformationProperties> = listOf(),
var maxRetryAttempts: Int = 3, var maxRetryAttempts: Int = 3,
var duplicationDetection: Boolean = true var duplicationDetection: Boolean = true
@ -48,9 +54,12 @@ data class PseudonymizeConfigProperties(
data class GPasConfigProperties( data class GPasConfigProperties(
val uri: String?, val uri: String?,
val target: String = "etl-processor", val target: String = "etl-processor",
val genomDeDomain: String = "ccdn",
val username: String?, val username: String?,
val password: String?, val password: String?,
@get:DeprecatedConfigurationProperty(
reason = "Deprecated in favor of including Root CA"
)
val sslCaLocation: String?
) { ) {
companion object { companion object {
const val NAME = "app.pseudonymize.gpas" const val NAME = "app.pseudonymize.gpas"
@ -73,8 +82,18 @@ data class RestTargetProperties(
data class KafkaProperties( data class KafkaProperties(
val inputTopic: String?, val inputTopic: String?,
val outputTopic: String = "etl-processor", val outputTopic: String = "etl-processor",
@get:DeprecatedConfigurationProperty(
reason = "Deprecated",
replacement = "outputTopic"
)
val topic: String = outputTopic,
val outputResponseTopic: String = "${outputTopic}_response", val outputResponseTopic: String = "${outputTopic}_response",
val groupId: String = "${outputTopic}_group", @get:DeprecatedConfigurationProperty(
reason = "Deprecated",
replacement = "outputResponseTopic"
)
val responseTopic: String = outputResponseTopic,
val groupId: String = "${topic}_group",
val servers: String = "" val servers: String = ""
) { ) {
companion object { companion object {

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -32,6 +32,12 @@ import dev.dnpm.etl.processor.security.TokenRepository
import dev.dnpm.etl.processor.security.TokenService import dev.dnpm.etl.processor.security.TokenService
import dev.dnpm.etl.processor.services.Transformation import dev.dnpm.etl.processor.services.Transformation
import dev.dnpm.etl.processor.services.TransformationService 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.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
@ -39,6 +45,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory
import org.springframework.retry.RetryCallback import org.springframework.retry.RetryCallback
import org.springframework.retry.RetryContext import org.springframework.retry.RetryContext
import org.springframework.retry.RetryListener import org.springframework.retry.RetryListener
@ -51,6 +58,13 @@ import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.web.client.HttpClientErrorException import org.springframework.web.client.HttpClientErrorException
import org.springframework.web.client.RestTemplate import org.springframework.web.client.RestTemplate
import reactor.core.publisher.Sinks 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.Duration.Companion.seconds
import kotlin.time.toJavaDuration import kotlin.time.toJavaDuration
@ -76,6 +90,18 @@ class AppConfiguration {
@ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS") @ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS")
@Bean @Bean
fun gpasPseudonymGenerator(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate, restTemplate: RestTemplate): Generator { 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) return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate)
} }
@ -85,6 +111,92 @@ class AppConfiguration {
return AnonymizingGenerator() return AnonymizingGenerator()
} }
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS")
@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<ConnectionSocketFactory>()
.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
fun buildinPseudonymGeneratorOnDeprecatedProperty(): Generator {
return AnonymizingGenerator()
}
@Bean @Bean
fun pseudonymizeService( fun pseudonymizeService(
generator: Generator, generator: Generator,

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -71,7 +71,7 @@ class AppKafkaConfiguration {
kafkaProperties: KafkaProperties, kafkaProperties: KafkaProperties,
kafkaResponseProcessor: KafkaResponseProcessor kafkaResponseProcessor: KafkaResponseProcessor
): KafkaMessageListenerContainer<String, String> { ): KafkaMessageListenerContainer<String, String> {
val containerProperties = ContainerProperties(kafkaProperties.outputResponseTopic) val containerProperties = ContainerProperties(kafkaProperties.responseTopic)
containerProperties.messageListener = kafkaResponseProcessor containerProperties.messageListener = kafkaResponseProcessor
return KafkaMessageListenerContainer(consumerFactory, containerProperties) return KafkaMessageListenerContainer(consumerFactory, containerProperties)
} }

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -87,14 +87,9 @@ class AppSecurityConfiguration(
@Bean @Bean
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true") @ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
fun filterChainOidc( fun filterChainOidc(http: HttpSecurity, passwordEncoder: PasswordEncoder, userRoleRepository: UserRoleRepository, sessionRegistry: SessionRegistry): SecurityFilterChain {
http: HttpSecurity,
passwordEncoder: PasswordEncoder,
userRoleRepository: UserRoleRepository,
sessionRegistry: SessionRegistry
): SecurityFilterChain {
http { http {
authorizeHttpRequests { authorizeRequests {
authorize("/configs/**", hasRole("ADMIN")) authorize("/configs/**", hasRole("ADMIN"))
authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN", "USER")) authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN", "USER"))
authorize("/report/**", hasAnyRole("ADMIN", "USER")) authorize("/report/**", hasAnyRole("ADMIN", "USER"))
@ -132,22 +127,13 @@ class AppSecurityConfiguration(
@Bean @Bean
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true") @ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
fun grantedAuthoritiesMapper( fun grantedAuthoritiesMapper(userRoleRepository: UserRoleRepository, appSecurityConfigProperties: SecurityConfigProperties): GrantedAuthoritiesMapper {
userRoleRepository: UserRoleRepository,
appSecurityConfigProperties: SecurityConfigProperties
): GrantedAuthoritiesMapper {
return GrantedAuthoritiesMapper { grantedAuthority -> return GrantedAuthoritiesMapper { grantedAuthority ->
grantedAuthority.filterIsInstance<OidcUserAuthority>() grantedAuthority.filterIsInstance<OidcUserAuthority>()
.onEach { .onEach {
val userRole = userRoleRepository.findByUsername(it.userInfo.preferredUsername) val userRole = userRoleRepository.findByUsername(it.userInfo.preferredUsername)
if (userRole.isEmpty) { if (userRole.isEmpty) {
userRoleRepository.save( userRoleRepository.save(UserRole(null, it.userInfo.preferredUsername, appSecurityConfigProperties.defaultNewUserRole))
UserRole(
null,
it.userInfo.preferredUsername,
appSecurityConfigProperties.defaultNewUserRole
)
)
} }
} }
.map { .map {
@ -161,7 +147,7 @@ class AppSecurityConfiguration(
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "false", matchIfMissing = true) @ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "false", matchIfMissing = true)
fun filterChain(http: HttpSecurity, passwordEncoder: PasswordEncoder): SecurityFilterChain { fun filterChain(http: HttpSecurity, passwordEncoder: PasswordEncoder): SecurityFilterChain {
http { http {
authorizeHttpRequests { authorizeRequests {
authorize("/configs/**", hasRole("ADMIN")) authorize("/configs/**", hasRole("ADMIN"))
authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN")) authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN"))
authorize("/report/**", hasRole("ADMIN")) authorize("/report/**", hasRole("ADMIN"))

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -22,13 +22,11 @@ package dev.dnpm.etl.processor.input
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.Consent
import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.MtbFile
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.PatientId import dev.dnpm.etl.processor.PatientId
import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.services.RequestProcessor import dev.dnpm.etl.processor.services.RequestProcessor
import org.apache.kafka.clients.consumer.ConsumerRecord import org.apache.kafka.clients.consumer.ConsumerRecord
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.MediaType
import org.springframework.kafka.listener.MessageListener import org.springframework.kafka.listener.MessageListener
class KafkaInputListener( class KafkaInputListener(
@ -37,29 +35,10 @@ class KafkaInputListener(
) : MessageListener<String, String> { ) : MessageListener<String, String> {
private val logger = LoggerFactory.getLogger(KafkaInputListener::class.java) private val logger = LoggerFactory.getLogger(KafkaInputListener::class.java)
override fun onMessage(record: ConsumerRecord<String, String>) { override fun onMessage(data: ConsumerRecord<String, String>) {
when (guessMimeType(record)) { val mtbFile = objectMapper.readValue(data.value(), MtbFile::class.java)
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, String>): 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<String, String>) {
val mtbFile = objectMapper.readValue(record.value(), MtbFile::class.java)
val patientId = PatientId(mtbFile.patient.id) val patientId = PatientId(mtbFile.patient.id)
val firstRequestIdHeader = record.headers().headers("requestId")?.firstOrNull() val firstRequestIdHeader = data.headers().headers("requestId")?.firstOrNull()
val requestId = if (null != firstRequestIdHeader) { val requestId = if (null != firstRequestIdHeader) {
RequestId(String(firstRequestIdHeader.value())) RequestId(String(firstRequestIdHeader.value()))
} else { } else {
@ -82,10 +61,4 @@ class KafkaInputListener(
} }
} }
} }
}
private fun handleDnpmV2Message(record: ConsumerRecord<String, String>) {
// Do not handle DNPM-V2 for now
logger.warn("Ignoring MTB File in DNPM V2 format: Not implemented yet")
}
}

View File

@ -21,12 +21,9 @@ package dev.dnpm.etl.processor.input
import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.Consent
import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.MtbFile
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.PatientId import dev.dnpm.etl.processor.PatientId
import dev.dnpm.etl.processor.services.RequestProcessor import dev.dnpm.etl.processor.services.RequestProcessor
import dev.pcvolkmer.mv64e.mtb.Mtb
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
@ -43,26 +40,19 @@ class MtbFileRestController(
return ResponseEntity.ok("Test") return ResponseEntity.ok("Test")
} }
@PostMapping( consumes = [ MediaType.APPLICATION_JSON_VALUE ] ) @PostMapping
fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity<Unit> { fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity<Unit> {
if (mtbFile.consent.status == Consent.Status.ACTIVE) { if (mtbFile.consent.status == Consent.Status.ACTIVE) {
logger.debug("Accepted MTB File (bwHC V1) for processing") logger.debug("Accepted MTB File for processing")
requestProcessor.processMtbFile(mtbFile) requestProcessor.processMtbFile(mtbFile)
} else { } else {
logger.debug("Accepted MTB File (bwHC V1) and process deletion") logger.debug("Accepted MTB File and process deletion")
val patientId = PatientId(mtbFile.patient.id) val patientId = PatientId(mtbFile.patient.id)
requestProcessor.processDeletion(patientId) requestProcessor.processDeletion(patientId)
} }
return ResponseEntity.accepted().build() return ResponseEntity.accepted().build()
} }
@PostMapping( consumes = [ CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE] )
fun mtbFile(@RequestBody mtbFile: Mtb): ResponseEntity<Unit> {
logger.debug("Accepted MTB File (DNPM V2) for processing")
requestProcessor.processMtbFile(mtbFile)
return ResponseEntity.accepted().build()
}
@DeleteMapping(path = ["{patientId}"]) @DeleteMapping(path = ["{patientId}"])
fun deleteData(@PathVariable patientId: String): ResponseEntity<Unit> { fun deleteData(@PathVariable patientId: String): ResponseEntity<Unit> {
logger.debug("Accepted patient ID to process deletion") logger.debug("Accepted patient ID to process deletion")
@ -70,4 +60,4 @@ class MtbFileRestController(
return ResponseEntity.accepted().build() return ResponseEntity.accepted().build()
} }
} }

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -22,12 +22,10 @@ package dev.dnpm.etl.processor.output
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.Consent
import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.MtbFile
import dev.dnpm.etl.processor.CustomMediaType import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.config.KafkaProperties import dev.dnpm.etl.processor.config.KafkaProperties
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.apache.kafka.clients.producer.ProducerRecord
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.MediaType
import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.core.KafkaTemplate
import org.springframework.retry.support.RetryTemplate import org.springframework.retry.support.RetryTemplate
@ -40,20 +38,14 @@ class KafkaMtbFileSender(
private val logger = LoggerFactory.getLogger(KafkaMtbFileSender::class.java) private val logger = LoggerFactory.getLogger(KafkaMtbFileSender::class.java)
override fun <T> send(request: MtbFileRequest<T>): MtbFileSender.Response { override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response {
return try { return try {
return retryTemplate.execute<MtbFileSender.Response, Exception> { return retryTemplate.execute<MtbFileSender.Response, Exception> {
val record = val result = kafkaTemplate.send(
ProducerRecord(kafkaProperties.outputTopic, key(request), objectMapper.writeValueAsString(request)) kafkaProperties.topic,
when (request) { key(request),
is BwhcV1MtbFileRequest -> record.headers() objectMapper.writeValueAsString(Data(request.requestId, request.mtbFile))
.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) { if (result.get() != null) {
logger.debug("Sent file via KafkaMtbFileSender") logger.debug("Sent file via KafkaMtbFileSender")
MtbFileSender.Response(RequestStatus.UNKNOWN) MtbFileSender.Response(RequestStatus.UNKNOWN)
@ -67,7 +59,7 @@ class KafkaMtbFileSender(
} }
} }
override fun send(request: DeleteRequest): MtbFileSender.Response { override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response {
val dummyMtbFile = MtbFile.builder() val dummyMtbFile = MtbFile.builder()
.withConsent( .withConsent(
Consent.builder() Consent.builder()
@ -79,15 +71,12 @@ class KafkaMtbFileSender(
return try { return try {
return retryTemplate.execute<MtbFileSender.Response, Exception> { return retryTemplate.execute<MtbFileSender.Response, Exception> {
val record = val result = kafkaTemplate.send(
ProducerRecord( kafkaProperties.topic,
kafkaProperties.outputTopic, key(request),
key(request), objectMapper.writeValueAsString(Data(request.requestId, dummyMtbFile))
// Always use old BwhcV1FileRequest with Consent REJECT )
objectMapper.writeValueAsString(BwhcV1MtbFileRequest(request.requestId, dummyMtbFile))
)
val result = kafkaTemplate.send(record)
if (result.get() != null) { if (result.get() != null) {
logger.debug("Sent deletion request via KafkaMtbFileSender") logger.debug("Sent deletion request via KafkaMtbFileSender")
MtbFileSender.Response(RequestStatus.UNKNOWN) MtbFileSender.Response(RequestStatus.UNKNOWN)
@ -102,15 +91,16 @@ class KafkaMtbFileSender(
} }
override fun endpoint(): String { override fun endpoint(): String {
return "${this.kafkaProperties.servers} (${this.kafkaProperties.outputTopic}/${this.kafkaProperties.outputResponseTopic})" return "${this.kafkaProperties.servers} (${this.kafkaProperties.topic}/${this.kafkaProperties.responseTopic})"
} }
private fun key(request: MtbRequest): String { private fun key(request: MtbFileSender.MtbFileRequest): String {
return when (request) { return "{\"pid\": \"${request.mtbFile.patient.id}\"}"
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)
}

View File

@ -19,17 +19,25 @@
package dev.dnpm.etl.processor.output 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 dev.dnpm.etl.processor.monitoring.RequestStatus
import org.springframework.http.HttpStatusCode import org.springframework.http.HttpStatusCode
interface MtbFileSender { interface MtbFileSender {
fun <T> send(request: MtbFileRequest<T>): Response fun send(request: MtbFileRequest): Response
fun send(request: DeleteRequest): Response fun send(request: DeleteRequest): Response
fun endpoint(): String fun endpoint(): String
data class Response(val status: RequestStatus, val body: 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 { fun Int.asRequestStatus(): RequestStatus {
@ -43,4 +51,4 @@ fun Int.asRequestStatus(): RequestStatus {
fun HttpStatusCode.asRequestStatus(): RequestStatus { fun HttpStatusCode.asRequestStatus(): RequestStatus {
return this.value().asRequestStatus() return this.value().asRequestStatus()
} }

View File

@ -1,59 +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 <https://www.gnu.org/licenses/>.
*/
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<out T> : MtbRequest {
override val requestId: RequestId
val content: T
fun patientPseudonym(): PatientPseudonym
}
data class BwhcV1MtbFileRequest(
override val requestId: RequestId,
override val content: MtbFile
) : MtbFileRequest<MtbFile> {
override fun patientPseudonym(): PatientPseudonym {
return PatientPseudonym(content.patient.id)
}
}
data class DnpmV2MtbFileRequest(
override val requestId: RequestId,
override val content: Mtb
) : MtbFileRequest<Mtb> {
override fun patientPseudonym(): PatientPseudonym {
return PatientPseudonym(content.patient.id)
}
}
data class DeleteRequest(
override val requestId: RequestId,
val patientId: PatientPseudonym
) : MtbRequest

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -19,11 +19,10 @@
package dev.dnpm.etl.processor.output 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.config.RestTargetProperties
import dev.dnpm.etl.processor.monitoring.ReportService
import dev.dnpm.etl.processor.monitoring.RequestStatus 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 dev.dnpm.etl.processor.monitoring.asRequestStatus
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.HttpEntity import org.springframework.http.HttpEntity
@ -47,11 +46,11 @@ abstract class RestMtbFileSender(
abstract fun deleteUrl(patientId: PatientPseudonym): String abstract fun deleteUrl(patientId: PatientPseudonym): String
override fun <T> send(request: MtbFileRequest<T>): MtbFileSender.Response { override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response {
try { try {
return retryTemplate.execute<MtbFileSender.Response, Exception> { return retryTemplate.execute<MtbFileSender.Response, Exception> {
val headers = getHttpHeaders(request) val headers = getHttpHeaders()
val entityReq = HttpEntity(request.content, headers) val entityReq = HttpEntity(request.mtbFile, headers)
val response = restTemplate.postForEntity( val response = restTemplate.postForEntity(
sendUrl(), sendUrl(),
entityReq, entityReq,
@ -77,10 +76,10 @@ abstract class RestMtbFileSender(
return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung") return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
} }
override fun send(request: DeleteRequest): MtbFileSender.Response { override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response {
try { try {
return retryTemplate.execute<MtbFileSender.Response, Exception> { return retryTemplate.execute<MtbFileSender.Response, Exception> {
val headers = getHttpHeaders(request) val headers = getHttpHeaders()
val entityReq = HttpEntity(null, headers) val entityReq = HttpEntity(null, headers)
restTemplate.delete( restTemplate.delete(
deleteUrl(request.patientId), deleteUrl(request.patientId),
@ -103,15 +102,11 @@ abstract class RestMtbFileSender(
return this.restTargetProperties.uri.orEmpty() return this.restTargetProperties.uri.orEmpty()
} }
private fun getHttpHeaders(request: MtbRequest): HttpHeaders { private fun getHttpHeaders(): HttpHeaders {
val username = restTargetProperties.username val username = restTargetProperties.username
val password = restTargetProperties.password val password = restTargetProperties.password
val headers = HttpHeaders() val headers = HttpHeaders()
headers.contentType = when (request) { headers.contentType = MediaType.APPLICATION_JSON
is BwhcV1MtbFileRequest -> MediaType.APPLICATION_JSON
is DnpmV2MtbFileRequest -> CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON
else -> MediaType.APPLICATION_JSON
}
if (username.isNullOrBlank() || password.isNullOrBlank()) { if (username.isNullOrBlank() || password.isNullOrBlank()) {
return headers return headers
@ -121,4 +116,4 @@ abstract class RestMtbFileSender(
return headers return headers
} }
} }

View File

@ -21,7 +21,6 @@ package dev.dnpm.etl.processor.pseudonym
import org.apache.commons.codec.binary.Base32 import org.apache.commons.codec.binary.Base32
import org.apache.commons.codec.digest.DigestUtils import org.apache.commons.codec.digest.DigestUtils
import org.apache.commons.math3.random.RandomDataGenerator
class AnonymizingGenerator : Generator { class AnonymizingGenerator : Generator {
@ -32,9 +31,4 @@ class AnonymizingGenerator : Generator {
.lowercase() .lowercase()
} }
override fun generateGenomDeTan(id: String?): String? {
val randomDataGenerator = RandomDataGenerator()
return randomDataGenerator.nextSecureHexString(64).lowercase()
}
} }

View File

@ -35,10 +35,6 @@ class PseudonymizeService(
} }
} }
fun genomDeTan(patientId: PatientId): String {
return generator.generateGenomDeTan(patientId.value)
}
fun prefix(): String { fun prefix(): String {
return configProperties.prefix return configProperties.prefix
} }

View File

@ -21,12 +21,12 @@ package dev.dnpm.etl.processor.pseudonym
import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.MtbFile
import dev.dnpm.etl.processor.PatientId import dev.dnpm.etl.processor.PatientId
import dev.pcvolkmer.mv64e.mtb.Mtb
import org.apache.commons.codec.digest.DigestUtils import org.apache.commons.codec.digest.DigestUtils
/** Replaces patient ID with generated patient pseudonym /** Replaces patient ID with generated patient pseudonym
* *
* @param pseudonymizeService The pseudonymizeService to be used * @param pseudonymizeService The pseudonymizeService to be used
*
* @return The MTB file containing patient pseudonymes * @return The MTB file containing patient pseudonymes
*/ */
infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) { infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
@ -49,11 +49,7 @@ infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
} }
this.lastGuidelineTherapies?.forEach { it.patient = patientPseudonym } this.lastGuidelineTherapies?.forEach { it.patient = patientPseudonym }
this.molecularPathologyFindings?.forEach { it.patient = patientPseudonym } this.molecularPathologyFindings?.forEach { it.patient = patientPseudonym }
this.molecularTherapies?.forEach { molecularTherapy -> this.molecularTherapies?.forEach { molecularTherapy -> molecularTherapy.history.forEach { it.patient = patientPseudonym } }
molecularTherapy.history.forEach {
it.patient = patientPseudonym
}
}
this.ngsReports?.forEach { it.patient = patientPseudonym } this.ngsReports?.forEach { it.patient = patientPseudonym }
this.previousGuidelineTherapies?.forEach { it.patient = patientPseudonym } this.previousGuidelineTherapies?.forEach { it.patient = patientPseudonym }
this.rebiopsyRequests?.forEach { it.patient = patientPseudonym } this.rebiopsyRequests?.forEach { it.patient = patientPseudonym }
@ -67,6 +63,7 @@ infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
* Creates new hash of content IDs with given prefix except for patient IDs * Creates new hash of content IDs with given prefix except for patient IDs
* *
* @param pseudonymizeService The pseudonymizeService to be used * @param pseudonymizeService The pseudonymizeService to be used
*
* @return The MTB file containing rehashed content IDs * @return The MTB file containing rehashed content IDs
*/ */
infix fun MtbFile.anonymizeContentWith(pseudonymizeService: PseudonymizeService) { infix fun MtbFile.anonymizeContentWith(pseudonymizeService: PseudonymizeService) {
@ -123,8 +120,8 @@ infix fun MtbFile.anonymizeContentWith(pseudonymizeService: PseudonymizeService)
id = id?.let { anonymize(it) } id = id?.let { anonymize(it) }
} }
} }
this.geneticCounsellingRequests?.onEach { geneticCounsellingRequest -> this.geneticCounsellingRequests?.onEach { geneticCounsellingRequest ->
geneticCounsellingRequest?.apply { geneticCounsellingRequest?.apply {
id = id?.let { anonymize(it) } id = id?.let { anonymize(it) }
} }
} }
@ -226,99 +223,4 @@ infix fun MtbFile.anonymizeContentWith(pseudonymizeService: PseudonymizeService)
id = id?.let { anonymize(it) } id = id?.let { anonymize(it) }
} }
} }
}
/** 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.familyMemberHistories?.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
}
}
this.followUps?.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
}
infix fun Mtb.addGenomDeTan(pseudonymizeService: PseudonymizeService)
{
this.metadata.transferTan = pseudonymizeService.genomDeTan(PatientId(this.patient.id))
} }

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -27,14 +27,10 @@ import dev.dnpm.etl.processor.monitoring.Report
import dev.dnpm.etl.processor.monitoring.Request import dev.dnpm.etl.processor.monitoring.Request
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.monitoring.RequestType import dev.dnpm.etl.processor.monitoring.RequestType
import dev.dnpm.etl.processor.output.* import dev.dnpm.etl.processor.output.MtbFileSender
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService import dev.dnpm.etl.processor.pseudonym.PseudonymizeService
import dev.dnpm.etl.processor.pseudonym.addGenomDeTan
import dev.dnpm.etl.processor.pseudonym.anonymizeContentWith import dev.dnpm.etl.processor.pseudonym.anonymizeContentWith
import dev.dnpm.etl.processor.pseudonym.pseudonymizeWith import dev.dnpm.etl.processor.pseudonym.pseudonymizeWith
import dev.pcvolkmer.mv64e.mtb.ConsentProvision
import dev.pcvolkmer.mv64e.mtb.ModelProjectConsentPurpose
import dev.pcvolkmer.mv64e.mtb.Mtb
import org.apache.commons.codec.binary.Base32 import org.apache.commons.codec.binary.Base32
import org.apache.commons.codec.digest.DigestUtils import org.apache.commons.codec.digest.DigestUtils
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
@ -59,47 +55,29 @@ class RequestProcessor(
fun processMtbFile(mtbFile: MtbFile, requestId: RequestId) { fun processMtbFile(mtbFile: MtbFile, requestId: RequestId) {
val pid = PatientId(mtbFile.patient.id) val pid = PatientId(mtbFile.patient.id)
mtbFile pseudonymizeWith pseudonymizeService mtbFile pseudonymizeWith pseudonymizeService
mtbFile anonymizeContentWith pseudonymizeService mtbFile anonymizeContentWith pseudonymizeService
val request = BwhcV1MtbFileRequest(requestId, transformationService.transform(mtbFile))
saveAndSend(request, pid)
}
fun processMtbFile(mtbFile: Mtb) { val request = MtbFileSender.MtbFileRequest(requestId, transformationService.transform(mtbFile))
processMtbFile(mtbFile, randomRequestId())
}
fun processMtbFile(mtbFile: Mtb, requestId: RequestId) { val patientPseudonym = PatientPseudonym(request.mtbFile.patient.id)
val pid = PatientId(mtbFile.patient.id)
val isModelProjectConsented = mtbFile.metadata?.modelProjectConsent?.provisions?.any { p ->
p.purpose == ModelProjectConsentPurpose.SEQUENCING
&& p.type == ConsentProvision.PERMIT
} == true
if (isModelProjectConsented) {
mtbFile addGenomDeTan pseudonymizeService
}
mtbFile pseudonymizeWith pseudonymizeService
mtbFile anonymizeContentWith pseudonymizeService
val request = DnpmV2MtbFileRequest(requestId, transformationService.transform(mtbFile))
saveAndSend(request, pid)
}
private fun <T> saveAndSend(request: MtbFileRequest<T>, pid: PatientId) {
requestService.save( requestService.save(
Request( Request(
request.requestId, requestId,
request.patientPseudonym(), patientPseudonym,
pid, pid,
fingerprint(request), fingerprint(request.mtbFile),
RequestType.MTB_FILE, RequestType.MTB_FILE,
RequestStatus.UNKNOWN RequestStatus.UNKNOWN
) )
) )
if (appConfigProperties.duplicationDetection && isDuplication(request)) { if (appConfigProperties.duplicationDetection && isDuplication(mtbFile)) {
applicationEventPublisher.publishEvent( applicationEventPublisher.publishEvent(
ResponseEvent( ResponseEvent(
request.requestId, requestId,
Instant.now(), Instant.now(),
RequestStatus.DUPLICATION RequestStatus.DUPLICATION
) )
@ -111,7 +89,7 @@ class RequestProcessor(
applicationEventPublisher.publishEvent( applicationEventPublisher.publishEvent(
ResponseEvent( ResponseEvent(
request.requestId, requestId,
Instant.now(), Instant.now(),
responseStatus.status, responseStatus.status,
when (responseStatus.status) { when (responseStatus.status) {
@ -122,22 +100,16 @@ class RequestProcessor(
) )
} }
private fun <T> isDuplication(pseudonymizedMtbFileRequest: MtbFileRequest<T>): Boolean { private fun isDuplication(pseudonymizedMtbFile: MtbFile): Boolean {
val patientPseudonym = when (pseudonymizedMtbFileRequest) { val patientPseudonym = PatientPseudonym(pseudonymizedMtbFile.patient.id)
is BwhcV1MtbFileRequest -> PatientPseudonym(pseudonymizedMtbFileRequest.content.patient.id)
is DnpmV2MtbFileRequest -> PatientPseudonym(pseudonymizedMtbFileRequest.content.patient.id)
}
val lastMtbFileRequestForPatient = val lastMtbFileRequestForPatient =
requestService.lastMtbFileRequestForPatientPseudonym(patientPseudonym) requestService.lastMtbFileRequestForPatientPseudonym(patientPseudonym)
val isLastRequestDeletion = val isLastRequestDeletion = requestService.isLastRequestWithKnownStatusDeletion(patientPseudonym)
requestService.isLastRequestWithKnownStatusDeletion(patientPseudonym)
return null != lastMtbFileRequestForPatient return null != lastMtbFileRequestForPatient
&& !isLastRequestDeletion && !isLastRequestDeletion
&& lastMtbFileRequestForPatient.fingerprint == fingerprint( && lastMtbFileRequestForPatient.fingerprint == fingerprint(pseudonymizedMtbFile)
pseudonymizedMtbFileRequest
)
} }
fun processDeletion(patientId: PatientId) { fun processDeletion(patientId: PatientId) {
@ -159,7 +131,7 @@ class RequestProcessor(
) )
) )
val responseStatus = sender.send(DeleteRequest(requestId, patientPseudonym)) val responseStatus = sender.send(MtbFileSender.DeleteRequest(requestId, patientPseudonym))
applicationEventPublisher.publishEvent( applicationEventPublisher.publishEvent(
ResponseEvent( ResponseEvent(
@ -188,11 +160,8 @@ class RequestProcessor(
} }
} }
private fun <T> fingerprint(request: MtbFileRequest<T>): Fingerprint { private fun fingerprint(mtbFile: MtbFile): Fingerprint {
return when (request) { return fingerprint(objectMapper.writeValueAsString(mtbFile))
is BwhcV1MtbFileRequest -> fingerprint(objectMapper.writeValueAsString(request.content))
is DnpmV2MtbFileRequest -> fingerprint(objectMapper.writeValueAsString(request.content))
}
} }
private fun fingerprint(s: String): Fingerprint { private fun fingerprint(s: String): Fingerprint {
@ -203,4 +172,4 @@ class RequestProcessor(
) )
} }
} }

View File

@ -23,21 +23,10 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.jayway.jsonpath.JsonPath import com.jayway.jsonpath.JsonPath
import com.jayway.jsonpath.PathNotFoundException import com.jayway.jsonpath.PathNotFoundException
import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.MtbFile
import dev.pcvolkmer.mv64e.mtb.Mtb
class TransformationService(private val objectMapper: ObjectMapper, private val transformations: List<Transformation>) { class TransformationService(private val objectMapper: ObjectMapper, private val transformations: List<Transformation>) {
fun transform(mtbFile: MtbFile): MtbFile { fun transform(mtbFile: MtbFile): MtbFile {
val json = transform(objectMapper.writeValueAsString(mtbFile)) var json = 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 -> transformations.forEach { transformation ->
val jsonPath = JsonPath.parse(json) val jsonPath = JsonPath.parse(json)
@ -59,7 +48,7 @@ class TransformationService(private val objectMapper: ObjectMapper, private val
json = jsonPath.jsonString() json = jsonPath.jsonString()
} }
return json return objectMapper.readValue(json, MtbFile::class.java)
} }
fun getTransformations(): List<Transformation> { fun getTransformations(): List<Transformation> {

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -19,7 +19,6 @@
package dev.dnpm.etl.processor package dev.dnpm.etl.processor
import org.springframework.http.MediaType
import java.util.* import java.util.*
class Fingerprint(val value: String) { class Fingerprint(val value: String) {
@ -47,17 +46,4 @@ value class PatientId(val value: String)
@JvmInline @JvmInline
value class PatientPseudonym(val value: String) value class PatientPseudonym(val value: String)
fun emptyPatientPseudonym() = PatientPseudonym("") 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"
}

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -23,7 +23,6 @@ import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.Consent
import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.MtbFile
import de.ukw.ccc.bwhc.dto.Patient import de.ukw.ccc.bwhc.dto.Patient
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.services.RequestProcessor import dev.dnpm.etl.processor.services.RequestProcessor
import org.apache.kafka.clients.consumer.ConsumerRecord import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.common.header.internals.RecordHeader import org.apache.kafka.common.header.internals.RecordHeader
@ -64,17 +63,9 @@ class KafkaInputListenerTest {
.withConsent(Consent.builder().withStatus(Consent.Status.ACTIVE).build()) .withConsent(Consent.builder().withStatus(Consent.Status.ACTIVE).build())
.build() .build()
kafkaInputListener.onMessage( kafkaInputListener.onMessage(ConsumerRecord("testtopic", 0, 0, "", this.objectMapper.writeValueAsString(mtbFile)))
ConsumerRecord(
"testtopic",
0,
0,
"",
this.objectMapper.writeValueAsString(mtbFile)
)
)
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>()) verify(requestProcessor, times(1)).processMtbFile(any())
} }
@Test @Test
@ -84,15 +75,7 @@ class KafkaInputListenerTest {
.withConsent(Consent.builder().withStatus(Consent.Status.REJECTED).build()) .withConsent(Consent.builder().withStatus(Consent.Status.REJECTED).build())
.build() .build()
kafkaInputListener.onMessage( kafkaInputListener.onMessage(ConsumerRecord("testtopic", 0, 0, "", this.objectMapper.writeValueAsString(mtbFile)))
ConsumerRecord(
"testtopic",
0,
0,
"",
this.objectMapper.writeValueAsString(mtbFile)
)
)
verify(requestProcessor, times(1)).processDeletion(anyValueClass()) verify(requestProcessor, times(1)).processDeletion(anyValueClass())
} }
@ -106,22 +89,10 @@ class KafkaInputListenerTest {
val headers = RecordHeaders(listOf(RecordHeader("requestId", UUID.randomUUID().toString().toByteArray()))) val headers = RecordHeaders(listOf(RecordHeader("requestId", UUID.randomUUID().toString().toByteArray())))
kafkaInputListener.onMessage( kafkaInputListener.onMessage(
ConsumerRecord( ConsumerRecord("testtopic", 0, 0, -1L, TimestampType.NO_TIMESTAMP_TYPE, -1, -1, "", this.objectMapper.writeValueAsString(mtbFile), headers, Optional.empty())
"testtopic",
0,
0,
-1L,
TimestampType.NO_TIMESTAMP_TYPE,
-1,
-1,
"",
this.objectMapper.writeValueAsString(mtbFile),
headers,
Optional.empty()
)
) )
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>(), anyValueClass()) verify(requestProcessor, times(1)).processMtbFile(any(), anyValueClass())
} }
@Test @Test
@ -133,52 +104,9 @@ class KafkaInputListenerTest {
val headers = RecordHeaders(listOf(RecordHeader("requestId", UUID.randomUUID().toString().toByteArray()))) val headers = RecordHeaders(listOf(RecordHeader("requestId", UUID.randomUUID().toString().toByteArray())))
kafkaInputListener.onMessage( kafkaInputListener.onMessage(
ConsumerRecord( ConsumerRecord("testtopic", 0, 0, -1L, TimestampType.NO_TIMESTAMP_TYPE, -1, -1, "", this.objectMapper.writeValueAsString(mtbFile), headers, Optional.empty())
"testtopic",
0,
0,
-1L,
TimestampType.NO_TIMESTAMP_TYPE,
-1,
-1,
"",
this.objectMapper.writeValueAsString(mtbFile),
headers,
Optional.empty()
)
) )
verify(requestProcessor, times(1)).processDeletion(anyValueClass(), anyValueClass()) verify(requestProcessor, times(1)).processDeletion(anyValueClass(), anyValueClass())
} }
@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())
}
}

View File

@ -21,9 +21,7 @@ package dev.dnpm.etl.processor.input
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.* import de.ukw.ccc.bwhc.dto.*
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.services.RequestProcessor import dev.dnpm.etl.processor.services.RequestProcessor
import dev.pcvolkmer.mv64e.mtb.Mtb
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -34,7 +32,6 @@ import org.mockito.Mockito.verify
import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any import org.mockito.kotlin.any
import org.mockito.kotlin.anyValueClass import org.mockito.kotlin.anyValueClass
import org.springframework.core.io.ClassPathResource
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.delete import org.springframework.test.web.servlet.delete
@ -73,7 +70,7 @@ class MtbFileRestControllerTest {
} }
} }
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>()) verify(requestProcessor, times(1)).processMtbFile(any())
} }
@Test @Test
@ -129,7 +126,7 @@ class MtbFileRestControllerTest {
} }
} }
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>()) verify(requestProcessor, times(1)).processMtbFile(any())
} }
@Test @Test
@ -158,40 +155,6 @@ 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 {
isAccepted()
}
}
verify(requestProcessor, times(1)).processMtbFile(any<Mtb>())
}
}
companion object { companion object {
fun bwhcMtbFileContent(consentStatus: Consent.Status) = MtbFile.builder() fun bwhcMtbFileContent(consentStatus: Consent.Status) = MtbFile.builder()
.withPatient( .withPatient(

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -21,262 +21,166 @@ package dev.dnpm.etl.processor.output
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.* 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.PatientPseudonym
import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.config.KafkaProperties import dev.dnpm.etl.processor.config.KafkaProperties
import dev.dnpm.etl.processor.monitoring.RequestStatus 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.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.MethodSource
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.* import org.mockito.kotlin.*
import org.springframework.http.MediaType
import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.support.SendResult import org.springframework.kafka.support.SendResult
import org.springframework.retry.policy.SimpleRetryPolicy import org.springframework.retry.policy.SimpleRetryPolicy
import org.springframework.retry.support.RetryTemplateBuilder import org.springframework.retry.support.RetryTemplateBuilder
import java.time.Instant
import java.util.*
import java.util.concurrent.CompletableFuture.completedFuture import java.util.concurrent.CompletableFuture.completedFuture
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
@ExtendWith(MockitoExtension::class) @ExtendWith(MockitoExtension::class)
class KafkaMtbFileSenderTest { class KafkaMtbFileSenderTest {
@Nested private lateinit var kafkaTemplate: KafkaTemplate<String, String>
inner class BwhcV1Record {
private lateinit var kafkaTemplate: KafkaTemplate<String, String> private lateinit var kafkaMtbFileSender: KafkaMtbFileSender
private lateinit var kafkaMtbFileSender: KafkaMtbFileSender private lateinit var objectMapper: ObjectMapper
private lateinit var objectMapper: ObjectMapper @BeforeEach
fun setup(
@Mock kafkaTemplate: KafkaTemplate<String, String>
) {
val kafkaProperties = KafkaProperties("testtopic")
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
@BeforeEach this.objectMapper = ObjectMapper()
fun setup( this.kafkaTemplate = kafkaTemplate
@Mock kafkaTemplate: KafkaTemplate<String, String>
) {
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<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
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<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
val response = kafkaMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
assertThat(response.status).isEqualTo(testData.requestStatus)
}
@Test
fun shouldSendMtbFileRequestWithCorrectKeyAndHeaderAndBody() {
doAnswer {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
kafkaMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1MtbFile(Consent.Status.ACTIVE)))
val captor = argumentCaptor<ProducerRecord<String, String>>()
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<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
kafkaMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
val captor = argumentCaptor<ProducerRecord<String, String>>()
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<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
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<ProducerRecord<String, String>>())
}
@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<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
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<ProducerRecord<String, String>>())
}
this.kafkaMtbFileSender = KafkaMtbFileSender(kafkaTemplate, kafkaProperties, retryTemplate, objectMapper)
} }
@Nested @ParameterizedTest
inner class DnpmV2Record { @MethodSource("requestWithResponseSource")
fun shouldSendMtbFileRequestAndReturnExpectedState(testData: TestData) {
private lateinit var kafkaTemplate: KafkaTemplate<String, String> doAnswer {
if (null != testData.exception) {
private lateinit var kafkaMtbFileSender: KafkaMtbFileSender throw testData.exception
private lateinit var objectMapper: ObjectMapper
@BeforeEach
fun setup(
@Mock kafkaTemplate: KafkaTemplate<String, String>
) {
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<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
val response = kafkaMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile()))
assertThat(response.status).isEqualTo(testData.requestStatus)
}
@Test
fun shouldSendMtbFileRequestWithCorrectKeyAndHeaderAndBody() {
doAnswer {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
kafkaMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile()))
val captor = argumentCaptor<ProducerRecord<String, String>>()
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<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
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)
} }
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
verify(kafkaTemplate, expectedCount).send(any<ProducerRecord<String, String>>()) 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<String, String>(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<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile(Consent.Status.ACTIVE)))
val captor = argumentCaptor<String>()
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<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
kafkaMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
val captor = argumentCaptor<String>()
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<String, String>(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)
} }
verify(kafkaTemplate, expectedCount).send(anyString(), anyString(), anyString())
}
@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<String, String>(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)
}
verify(kafkaTemplate, expectedCount).send(anyString(), anyString(), anyString())
} }
companion object { companion object {
val TEST_REQUEST_ID = RequestId("TestId") val TEST_REQUEST_ID = RequestId("TestId")
val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID") val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID")
fun bwhcV1MtbFile(consentStatus: Consent.Status): MtbFile { fun mtbFile(consentStatus: Consent.Status): MtbFile {
return if (consentStatus == Consent.Status.ACTIVE) { return if (consentStatus == Consent.Status.ACTIVE) {
MtbFile.builder() MtbFile.builder()
.withPatient( .withPatient(
@ -311,35 +215,8 @@ class KafkaMtbFileSenderTest {
}.build() }.build()
} }
fun dnpmV2MtbFile(): Mtb { fun kafkaRecordData(requestId: RequestId, consentStatus: Consent.Status): KafkaMtbFileSender.Data {
return Mtb().apply { return KafkaMtbFileSender.Data(requestId, mtbFile(consentStatus))
this.patient = dev.pcvolkmer.mv64e.mtb.Patient().apply {
this.id = "PID"
this.birthDate = Date.from(Instant.now())
this.gender = GenderCoding().apply {
this.code = GenderCodingCode.MALE
}
}
this.episodesOfCare = listOf(
MtbEpisodeOfCare().apply {
this.id = "1"
this.patient = Reference().apply {
this.id = "PID"
}
this.period = PeriodDate().apply {
this.start = Date.from(Instant.now())
}
}
)
}
}
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) data class TestData(val requestStatus: RequestStatus, val exception: Throwable? = null)
@ -354,4 +231,4 @@ class KafkaMtbFileSenderTest {
} }
} }
} }

View File

@ -30,16 +30,16 @@ import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.MethodSource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.retry.policy.SimpleRetryPolicy import org.springframework.retry.policy.SimpleRetryPolicy
import org.springframework.retry.support.RetryTemplateBuilder import org.springframework.retry.support.RetryTemplateBuilder
import org.springframework.test.web.client.ExpectedCount import org.springframework.test.web.client.ExpectedCount
import org.springframework.test.web.client.MockRestServiceServer import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.match.MockRestRequestMatchers.* 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.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.web.client.RestTemplate import org.springframework.web.client.RestTemplate
@ -73,7 +73,7 @@ class RestBwhcMtbFileSenderTest {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
} }
val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body) assertThat(response.body).isEqualTo(requestWithResponse.response.body)
} }
@ -84,12 +84,11 @@ class RestBwhcMtbFileSenderTest {
this.mockRestServiceServer this.mockRestServiceServer
.expect(method(HttpMethod.POST)) .expect(method(HttpMethod.POST))
.andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile")) .andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile"))
.andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))
.andRespond { .andRespond {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
} }
val response = restMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, mtbFile)) val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile))
assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body) assertThat(response.body).isEqualTo(requestWithResponse.response.body)
} }
@ -119,7 +118,7 @@ class RestBwhcMtbFileSenderTest {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
} }
val response = restMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, mtbFile)) val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile))
assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body) assertThat(response.body).isEqualTo(requestWithResponse.response.body)
} }
@ -149,7 +148,7 @@ class RestBwhcMtbFileSenderTest {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
} }
val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body) assertThat(response.body).isEqualTo(requestWithResponse.response.body)
} }
@ -310,4 +309,4 @@ class RestBwhcMtbFileSenderTest {
} }
} }

View File

@ -22,8 +22,6 @@ package dev.dnpm.etl.processor.output
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule import com.fasterxml.jackson.module.kotlin.KotlinModule
import de.ukw.ccc.bwhc.dto.* 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.PatientPseudonym
import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.config.AppConfigProperties import dev.dnpm.etl.processor.config.AppConfigProperties
@ -31,208 +29,136 @@ import dev.dnpm.etl.processor.config.AppConfiguration
import dev.dnpm.etl.processor.config.RestTargetProperties import dev.dnpm.etl.processor.config.RestTargetProperties
import dev.dnpm.etl.processor.monitoring.ReportService import dev.dnpm.etl.processor.monitoring.ReportService
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.pcvolkmer.mv64e.mtb.* import dev.dnpm.etl.processor.output.RestBwhcMtbFileSenderTest.Companion
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.MethodSource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.retry.backoff.NoBackOffPolicy import org.springframework.retry.backoff.NoBackOffPolicy
import org.springframework.retry.policy.SimpleRetryPolicy import org.springframework.retry.policy.SimpleRetryPolicy
import org.springframework.retry.support.RetryTemplateBuilder import org.springframework.retry.support.RetryTemplateBuilder
import org.springframework.test.web.client.ExpectedCount import org.springframework.test.web.client.ExpectedCount
import org.springframework.test.web.client.MockRestServiceServer import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.match.MockRestRequestMatchers.* 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.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.web.client.RestTemplate import org.springframework.web.client.RestTemplate
import java.time.Instant
import java.util.*
class RestDipMtbFileSenderTest { class RestDipMtbFileSenderTest {
@Nested private lateinit var mockRestServiceServer: MockRestServiceServer
inner class BwhcV1ContentRequest {
private lateinit var mockRestServiceServer: MockRestServiceServer private lateinit var restMtbFileSender: RestMtbFileSender
private lateinit var restMtbFileSender: RestMtbFileSender private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build()))
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()
@BeforeEach this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
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 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(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1mtbFile))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
}
@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)
}
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)
}
this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
} }
@Nested @ParameterizedTest
inner class DnpmV2ContentRequest { @MethodSource("deleteRequestWithResponseSource")
fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) {
private lateinit var mockRestServiceServer: MockRestServiceServer this.mockRestServiceServer
.expect(method(HttpMethod.DELETE))
private lateinit var restMtbFileSender: RestMtbFileSender .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient/${TEST_PATIENT_PSEUDONYM.value}"))
.andRespond {
private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build())) withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
@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 val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
.expect(expectedCount, method(HttpMethod.DELETE)) assertThat(response.status).isEqualTo(requestWithResponse.response.status)
.andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient/${TEST_PATIENT_PSEUDONYM.value}")) assertThat(response.body).isEqualTo(requestWithResponse.response.body)
.andRespond { }
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
}
val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) @ParameterizedTest
assertThat(response.status).isEqualTo(requestWithResponse.response.status) @MethodSource("mtbFileRequestWithResponseSource")
assertThat(response.body).isEqualTo(requestWithResponse.response.body) 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.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(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)
}
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(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
} }
companion object { companion object {
@ -245,7 +171,7 @@ class RestDipMtbFileSenderTest {
val TEST_REQUEST_ID = RequestId("TestId") val TEST_REQUEST_ID = RequestId("TestId")
val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID") val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID")
val bwhcV1mtbFile: MtbFile = MtbFile.builder() val mtbFile: MtbFile = MtbFile.builder()
.withPatient( .withPatient(
Patient.builder() Patient.builder()
.withId("PID") .withId("PID")
@ -269,29 +195,6 @@ class RestDipMtbFileSenderTest {
) )
.build() .build()
fun dnpmV2MtbFile(): Mtb {
return Mtb().apply {
this.patient = dev.pcvolkmer.mv64e.mtb.Patient().apply {
this.id = "PID"
this.birthDate = Date.from(Instant.now())
this.gender = GenderCoding().apply {
this.code = GenderCodingCode.MALE
}
}
this.episodesOfCare = listOf(
MtbEpisodeOfCare().apply {
this.id = "1"
this.patient = Reference().apply {
this.id = "PID"
}
this.period = PeriodDate().apply {
this.start = Date.from(Instant.now())
}
}
)
}
}
private const val ERROR_RESPONSE_BODY = "Sonstiger Fehler bei der Übertragung" private const val ERROR_RESPONSE_BODY = "Sonstiger Fehler bei der Übertragung"
/** /**
@ -408,4 +311,4 @@ class RestDipMtbFileSenderTest {
} }
} }

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -21,10 +21,7 @@ package dev.dnpm.etl.processor.pseudonym
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.* import de.ukw.ccc.bwhc.dto.*
import de.ukw.ccc.bwhc.dto.Patient
import dev.pcvolkmer.mv64e.mtb.*
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
@ -34,242 +31,168 @@ import org.mockito.kotlin.anyValueClass
import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import org.springframework.core.io.ClassPathResource import org.springframework.core.io.ClassPathResource
import java.time.Instant
import java.util.* const val FAKE_MTB_FILE_PATH = "fake_MTBFile.json"
const val CLEAN_PATIENT_ID = "5dad2f0b-49c6-47d8-a952-7b9e9e0f7549"
@ExtendWith(MockitoExtension::class) @ExtendWith(MockitoExtension::class)
class ExtensionsTest { class ExtensionsTest {
@Nested private fun fakeMtbFile(): MtbFile {
inner class UsingBwhcDatamodel { val mtbFile = ClassPathResource(FAKE_MTB_FILE_PATH).inputStream
return ObjectMapper().readValue(mtbFile, MtbFile::class.java)
val FAKE_MTB_FILE_PATH = "fake_MTBFile.json"
val CLEAN_PATIENT_ID = "5dad2f0b-49c6-47d8-a952-7b9e9e0f7549"
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<IllegalStateException> {
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<sha256(TESTDOMAIN-1)[0-41]>
.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()
}
} }
@Nested private fun MtbFile.serialized(): String {
inner class UsingDnpmV2Datamodel { return ObjectMapper().writeValueAsString(this)
val FAKE_MTB_FILE_PATH = "mv64e-mtb-fake-patient.json"
val CLEAN_PATIENT_ID = "644bae7a-56f6-4ee8-b02f-c532e65af5b1"
private fun fakeMtbFile(): Mtb {
val mtbFile = ClassPathResource(FAKE_MTB_FILE_PATH).inputStream
return ObjectMapper().readValue(mtbFile, Mtb::class.java)
}
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())
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().apply {
this.patient = dev.pcvolkmer.mv64e.mtb.Patient().apply {
this.id = "PID"
this.birthDate = Date.from(Instant.now())
this.gender = GenderCoding().apply {
this.code = GenderCodingCode.MALE
}
}
this.episodesOfCare = listOf(
MtbEpisodeOfCare().apply {
this.id = "1"
this.patient = Reference().apply {
this.id = "PID"
}
this.period = PeriodDate().apply {
this.start = Date.from(Instant.now())
}
}
)
}
mtbFile.pseudonymizeWith(pseudonymizeService)
mtbFile.anonymizeContentWith(pseudonymizeService)
assertThat(mtbFile.episodesOfCare).hasSize(1)
assertThat(mtbFile.episodesOfCare.map { it.id }).isNotNull
}
} }
}
@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<IllegalStateException> {
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<sha256(TESTDOMAIN-1)[0-41]>
.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()
}
}

View File

@ -72,7 +72,7 @@ class PseudonymizeServiceTest {
@Test @Test
fun sanitizeFileName(@Mock generator: GpasPseudonymGenerator) { fun sanitizeFileName(@Mock generator: GpasPseudonymGenerator) {
val result = GpasPseudonymGenerator.sanitizeValue("l://a\\bs;1*2?3>") val result= GpasPseudonymGenerator.sanitizeValue("l://a\\bs;1*2?3>")
assertThat(result).isEqualTo("l___a_bs_1_2_3_") assertThat(result).isEqualTo("l___a_bs_1_2_3_")
} }
@ -90,10 +90,4 @@ class PseudonymizeServiceTest {
assertThat(mtbFile.patient.id).isEqualTo("UNKNOWN_123") assertThat(mtbFile.patient.id).isEqualTo("UNKNOWN_123")
} }
@Test
fun shouldReturnDifferentValues() {
val ag = AnonymizingGenerator()
val tan = ag.generateGenomDeTan("12345")
assertThat(tan).hasSize(64)
}
} }

View File

@ -21,19 +21,14 @@ package dev.dnpm.etl.processor.services
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.* import de.ukw.ccc.bwhc.dto.*
import dev.dnpm.etl.processor.Fingerprint import dev.dnpm.etl.processor.*
import dev.dnpm.etl.processor.PatientId
import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.config.AppConfigProperties import dev.dnpm.etl.processor.config.AppConfigProperties
import dev.dnpm.etl.processor.monitoring.Request import dev.dnpm.etl.processor.monitoring.Request
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.monitoring.RequestType 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.MtbFileSender
import dev.dnpm.etl.processor.output.RestMtbFileSender import dev.dnpm.etl.processor.output.RestMtbFileSender
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService import dev.dnpm.etl.processor.pseudonym.PseudonymizeService
import dev.dnpm.etl.processor.randomRequestId
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -114,7 +109,7 @@ class RequestProcessorTest {
doAnswer { doAnswer {
it.arguments[0] it.arguments[0]
}.whenever(transformationService).transform(any<MtbFile>()) }.whenever(transformationService).transform(any())
val mtbFile = MtbFile.builder() val mtbFile = MtbFile.builder()
.withPatient( .withPatient(
@ -173,7 +168,7 @@ class RequestProcessorTest {
doAnswer { doAnswer {
it.arguments[0] it.arguments[0]
}.whenever(transformationService).transform(any<MtbFile>()) }.whenever(transformationService).transform(any())
val mtbFile = MtbFile.builder() val mtbFile = MtbFile.builder()
.withPatient( .withPatient(
@ -228,7 +223,7 @@ class RequestProcessorTest {
doAnswer { doAnswer {
MtbFileSender.Response(status = RequestStatus.SUCCESS) MtbFileSender.Response(status = RequestStatus.SUCCESS)
}.whenever(sender).send(any<BwhcV1MtbFileRequest>()) }.whenever(sender).send(any<MtbFileSender.MtbFileRequest>())
doAnswer { doAnswer {
it.arguments[0] as String it.arguments[0] as String
@ -236,7 +231,7 @@ class RequestProcessorTest {
doAnswer { doAnswer {
it.arguments[0] it.arguments[0]
}.whenever(transformationService).transform(any<MtbFile>()) }.whenever(transformationService).transform(any())
val mtbFile = MtbFile.builder() val mtbFile = MtbFile.builder()
.withPatient( .withPatient(
@ -291,7 +286,7 @@ class RequestProcessorTest {
doAnswer { doAnswer {
MtbFileSender.Response(status = RequestStatus.ERROR) MtbFileSender.Response(status = RequestStatus.ERROR)
}.whenever(sender).send(any<BwhcV1MtbFileRequest>()) }.whenever(sender).send(any<MtbFileSender.MtbFileRequest>())
doAnswer { doAnswer {
it.arguments[0] as String it.arguments[0] as String
@ -299,7 +294,7 @@ class RequestProcessorTest {
doAnswer { doAnswer {
it.arguments[0] it.arguments[0]
}.whenever(transformationService).transform(any<MtbFile>()) }.whenever(transformationService).transform(any())
val mtbFile = MtbFile.builder() val mtbFile = MtbFile.builder()
.withPatient( .withPatient(
@ -341,7 +336,7 @@ class RequestProcessorTest {
doAnswer { doAnswer {
MtbFileSender.Response(status = RequestStatus.UNKNOWN) MtbFileSender.Response(status = RequestStatus.UNKNOWN)
}.whenever(sender).send(any<DeleteRequest>()) }.whenever(sender).send(any<MtbFileSender.DeleteRequest>())
this.requestProcessor.processDeletion(TEST_PATIENT_ID) this.requestProcessor.processDeletion(TEST_PATIENT_ID)
@ -359,7 +354,7 @@ class RequestProcessorTest {
doAnswer { doAnswer {
MtbFileSender.Response(status = RequestStatus.SUCCESS) MtbFileSender.Response(status = RequestStatus.SUCCESS)
}.whenever(sender).send(any<DeleteRequest>()) }.whenever(sender).send(any<MtbFileSender.DeleteRequest>())
this.requestProcessor.processDeletion(TEST_PATIENT_ID) this.requestProcessor.processDeletion(TEST_PATIENT_ID)
@ -377,7 +372,7 @@ class RequestProcessorTest {
doAnswer { doAnswer {
MtbFileSender.Response(status = RequestStatus.ERROR) MtbFileSender.Response(status = RequestStatus.ERROR)
}.whenever(sender).send(any<DeleteRequest>()) }.whenever(sender).send(any<MtbFileSender.DeleteRequest>())
this.requestProcessor.processDeletion(TEST_PATIENT_ID) this.requestProcessor.processDeletion(TEST_PATIENT_ID)
@ -409,11 +404,11 @@ class RequestProcessorTest {
doAnswer { doAnswer {
it.arguments[0] it.arguments[0]
}.whenever(transformationService).transform(any<MtbFile>()) }.whenever(transformationService).transform(any())
doAnswer { doAnswer {
MtbFileSender.Response(status = RequestStatus.SUCCESS) MtbFileSender.Response(status = RequestStatus.SUCCESS)
}.whenever(sender).send(any<BwhcV1MtbFileRequest>()) }.whenever(sender).send(any<MtbFileSender.MtbFileRequest>())
val mtbFile = MtbFile.builder() val mtbFile = MtbFile.builder()
.withPatient( .withPatient(
@ -451,4 +446,4 @@ class RequestProcessorTest {
val TEST_PATIENT_ID = PatientId("TEST_12345678901") val TEST_PATIENT_ID = PatientId("TEST_12345678901")
} }
} }

File diff suppressed because it is too large Load Diff